From 7e213012bd44a5434553c1517b823a4223546d1f Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:19:36 -0300 Subject: [PATCH 1/3] Print shell completions and man pages jevgate completions SHELL and jevgate man [COMMAND] are generated from the command-line definition, so they match --help. The Homebrew formula installs both. --- .github/homebrew-formula.sh | 6 ++++++ CHANGELOG.md | 1 + Cargo.lock | 27 +++++++++++++++++++++++++ Cargo.toml | 4 +++- README.md | 2 ++ src/main.rs | 13 ++++++++++-- src/manual.rs | 37 ++++++++++++++++++++++++++++++++++ src/options/commands.rs | 32 ++++++++++++++++++++++++++++- tests/cli/main.rs | 1 + tests/cli/manual.rs | 40 +++++++++++++++++++++++++++++++++++++ 10 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/manual.rs create mode 100644 tests/cli/manual.rs diff --git a/.github/homebrew-formula.sh b/.github/homebrew-formula.sh index 521a17e..2d0241f 100755 --- a/.github/homebrew-formula.sh +++ b/.github/homebrew-formula.sh @@ -45,6 +45,12 @@ class Jevgate < Formula def install bin.install "jevgate" + generate_completions_from_executable(bin/"jevgate", "completions") + man1.mkpath + (man1/"jevgate.1").write Utils.safe_popen_read(bin/"jevgate", "man") + %w[auth check baseline rules init serve completions man].each do |command| + (man1/"jevgate-#{command}.1").write Utils.safe_popen_read(bin/"jevgate", "man", command) + end end test do diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c908a4..c867ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] +- `jevgate completions SHELL` prints a completion script for bash, zsh, fish, elvish or PowerShell, and `jevgate man [COMMAND]` a man page, both generated from the same definitions as `--help`. The Homebrew formula installs them. - Homebrew: `brew install tech-byte-frontier/tap/jevgate` installs the release binaries on macOS and Linux, and each release updates the formula. ## [0.17.0] - 2026-09-25 diff --git a/Cargo.lock b/Cargo.lock index 1a6bc77..491c413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,6 +360,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -378,6 +387,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "cmov" version = "0.5.4" @@ -945,6 +964,8 @@ version = "0.17.0" dependencies = [ "anyhow", "clap", + "clap_complete", + "clap_mangen", "globset", "ignore", "keyring", @@ -1282,6 +1303,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rpassword" version = "7.5.4" diff --git a/Cargo.toml b/Cargo.toml index 5d5b9fb..41d62dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,9 @@ pkg-fmt = "zip" [dependencies] anyhow = "=1.0.104" -clap = { version = "=4.6.7", features = ["derive"] } +clap = { version = "=4.6.7", features = ["derive", "string"] } +clap_complete = "=4.6.11" +clap_mangen = "=0.3.3" globset = "=0.4.20" ignore = "=0.4.33" keyring = { version = "=4.2.0", default-features = false, features = ["v1"] } diff --git a/README.md b/README.md index f9c9c09..637ba17 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ cargo install jevgate --locked # build from source; needs Rust 1.90 or later Each [release](https://github.com/Tech-Byte-Frontier/jevgate/releases) has binaries for Linux (x86_64 and arm64, static), macOS (Apple silicon and Intel) and Windows (x86_64), with SHA-256 checksums and build provenance: `gh attestation verify --repo Tech-Byte-Frontier/jevgate`. The install script checks the checksum and installs to `~/.local/bin`; set `JEVGATE_VERSION` or `JEVGATE_INSTALL_DIR` to change the version or place. +`jevgate completions bash|zsh|fish|powershell` prints a shell completion script and `jevgate man` a man page; Homebrew installs both. + Reviewing needs a [TypeSafe API key](https://console.typesafe.ai/settings/keys). Git is needed only for `--base` and the staleness rule. ## Quick start diff --git a/src/main.rs b/src/main.rs index 3824aeb..a9e37ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ mod init; mod inventory; mod line_ranges; mod locations; +mod manual; mod options; mod output; mod packages; @@ -73,7 +74,7 @@ use options::{CheckArgs, Format, JevCommand}; /// --include-tests), and the opt-in security and documentation groups. #[derive(Parser)] #[command(version, after_long_help = options::OVERVIEW)] -struct Cli { +pub struct Cli { #[command(subcommand)] command: JevCommand, } @@ -94,6 +95,11 @@ fn run(command: JevCommand) -> Result { if let JevCommand::Auth { command } = command { return auth::run(command); } + match &command { + JevCommand::Completions { shell } => return manual::completions(*shell).map(|()| 0), + JevCommand::Man { command } => return manual::man(command.as_deref()).map(|()| 0), + _ => {} + } if let JevCommand::Init { force } = command { // Before reading configuration, so an invalid file can be replaced. let root = config::repository_root(&std::env::current_dir()?.canonicalize()?); @@ -113,7 +119,10 @@ fn run(command: JevCommand) -> Result { }; let context = ConfigContext::discover(file.as_deref())?; match command { - JevCommand::Auth { .. } | JevCommand::Init { .. } => { + JevCommand::Auth { .. } + | JevCommand::Init { .. } + | JevCommand::Completions { .. } + | JevCommand::Man { .. } => { unreachable!("handled before repository configuration") } JevCommand::Check(mut args) => { diff --git a/src/manual.rs b/src/manual.rs new file mode 100644 index 0000000..8ebb785 --- /dev/null +++ b/src/manual.rs @@ -0,0 +1,37 @@ +//! Shell completion scripts and man pages, generated from the command-line +//! definition so they never drift from `--help`. +use anyhow::{Context, Result}; +use clap::CommandFactory; +use std::io::Write; + +pub fn completions(shell: clap_complete::Shell) -> Result<()> { + let mut command = crate::Cli::command(); + let mut out = Vec::new(); + clap_complete::generate(shell, &mut command, "jevgate", &mut out); + std::io::stdout().write_all(&out)?; + Ok(()) +} + +/// The page for `jevgate`, or for one of its commands as `jevgate-NAME`. +pub fn man(name: Option<&str>) -> Result<()> { + let mut root = crate::Cli::command(); + root.build(); + let page = match name { + None => root, + Some(name) => { + let command = root + .find_subcommand(name) + .filter(|c| c.get_name() != "help") + .with_context(|| format!("Unknown command: {name}"))?; + let title = format!("jevgate-{}", command.get_name()); + command + .clone() + .name(title) + .version(env!("CARGO_PKG_VERSION")) + } + }; + let mut out = Vec::new(); + clap_mangen::Man::new(page).render(&mut out)?; + std::io::stdout().write_all(&out)?; + Ok(()) +} diff --git a/src/options/commands.rs b/src/options/commands.rs index afeadaa..45da17b 100644 --- a/src/options/commands.rs +++ b/src/options/commands.rs @@ -84,6 +84,22 @@ pub enum JevCommand { #[arg(long)] force: bool, }, + /// Print a shell completion script (offline) + #[command(after_long_help = COMPLETIONS_EXAMPLES)] + Completions { + /// bash, zsh, fish, elvish or powershell + #[arg(value_enum)] + shell: clap_complete::Shell, + }, + /// Print a man page in roff (offline) + /// + /// Without a command, the page for `jevgate`; with one, the page for that + /// command, such as `jevgate-check`. + #[command(after_long_help = MAN_EXAMPLES)] + Man { + /// A command: auth, check, baseline, rules, init, serve or completions + command: Option, + }, /// Serve the latest report as read-only JSON on localhost (run alongside `check --watch`) /// /// Answers GET requests from local tools, never from a browser page: @@ -183,7 +199,8 @@ Environment: JEVGATE_CONFIG_DIR Absolute directory for file-stored credentials CI When set, --report writes the dashboard without opening a browser -`jevgate --help` explains each command; -h prints a summary."; +`jevgate --help` explains each command; -h prints a summary. `jevgate completions SHELL` +and `jevgate man [COMMAND]` print shell completions and man pages."; const CHECK_EXAMPLES: &str = "\ Examples: @@ -210,6 +227,19 @@ Reading the JSON report (--format json or .jevgate/latest.json): files[].judgments every raw answer, first pass and follow-ups api_requests, paid_input_tokens, paid_output_tokens this run's usage"; +const COMPLETIONS_EXAMPLES: &str = "\ +Examples: + jevgate completions bash > ~/.local/share/bash-completion/completions/jevgate + jevgate completions zsh > \"${fpath[1]}/_jevgate\" + jevgate completions fish > ~/.config/fish/completions/jevgate.fish + jevgate completions powershell >> $PROFILE"; + +const MAN_EXAMPLES: &str = "\ +Examples: + jevgate man > ~/.local/share/man/man1/jevgate.1 + jevgate man check > ~/.local/share/man/man1/jevgate-check.1 + jevgate man check | man -l - Read a page without installing it (man-db)"; + const AUTH_EXAMPLES: &str = "\ Examples: jevgate auth login Hidden prompt; saved in the OS credential store diff --git a/tests/cli/main.rs b/tests/cli/main.rs index b6a95f4..1de16dd 100644 --- a/tests/cli/main.rs +++ b/tests/cli/main.rs @@ -2,6 +2,7 @@ //! project helper is here. mod auth; mod changes; +mod manual; mod preview; mod rules; #[path = "../support/temp_dir.rs"] diff --git a/tests/cli/manual.rs b/tests/cli/manual.rs new file mode 100644 index 0000000..3ef742a --- /dev/null +++ b/tests/cli/manual.rs @@ -0,0 +1,40 @@ +//! Shell completions and man pages. +use super::*; + +#[test] +fn completions_cover_every_shell_and_command_offline() { + let project = Project::new(); + for shell in ["bash", "zsh", "fish", "elvish", "powershell"] { + let output = project + .command() + .args(["completions", shell]) + .output() + .unwrap(); + assert!(output.status.success(), "{shell}"); + let script = String::from_utf8(output.stdout).unwrap(); + // Fish names long options without dashes (`-l base`). + for word in ["check", "baseline", "base", "include-tests"] { + assert!(script.contains(word), "{shell} completes {word}"); + } + } + assert!(!project.0.join(".jevgate").exists()); +} + +#[test] +fn man_pages_are_named_by_command_and_unknown_commands_fail() { + let project = Project::new(); + let page = |arguments: &[&str]| project.command().args(arguments).output().unwrap(); + let root = page(&["man"]); + assert!(root.status.success()); + assert!(String::from_utf8_lossy(&root.stdout).contains(".TH jevgate 1")); + let check = page(&["man", "check"]); + let check = String::from_utf8_lossy(&check.stdout); + assert!(check.contains(".TH jevgate-check 1"), "{check}"); + assert!(check.contains(env!("CARGO_PKG_VERSION"))); + assert!(check.contains("Reading the JSON report")); + for unknown in ["nope", "help"] { + let output = page(&["man", unknown]); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("Unknown command")); + } +} From a7834a0c76fd1d1f924f5767874ae8fc933e1ede Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:27:13 -0300 Subject: [PATCH 2/3] Run each command from its own module main.rs keeps the command-line definition and entry point; command.rs runs each command, as the self-review suggested. --- src/command.rs | 222 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 219 +--------------------------------------------- src/tests/mod.rs | 1 + 3 files changed, 226 insertions(+), 216 deletions(-) create mode 100644 src/command.rs diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..c62fe59 --- /dev/null +++ b/src/command.rs @@ -0,0 +1,222 @@ +//! Running each command: offline commands first, then the ones that read +//! the repository's configuration, with `check` and its evaluation. +use crate::{ + auth, baseline, cancellation, catalog, changes, config, + config::ConfigContext, + evaluate, gate, html_report, init, inventory, manual, + options::{self, CheckArgs, Format, JevCommand}, + output, revision, schema, server, storage, token_budget, transport, watch, +}; +use anyhow::Result; + +pub fn run(command: JevCommand) -> Result { + if let JevCommand::Auth { command } = command { + return auth::run(command); + } + match &command { + JevCommand::Completions { shell } => return manual::completions(*shell).map(|()| 0), + JevCommand::Man { command } => return manual::man(command.as_deref()).map(|()| 0), + _ => {} + } + if let JevCommand::Init { force } = command { + // Before reading configuration, so an invalid file can be replaced. + let root = config::repository_root(&std::env::current_dir()?.canonicalize()?); + let (path, allow) = init::run(&root, force)?; + say!("Wrote {}", path.display()); + if allow.is_empty() { + say!("No supported source found; set upload_allow before checking."); + } else { + say!("Uploads limited to: {}", allow.join(", ")); + } + say!("Next: jevgate auth login, then jevgate check --dry-run --show-requests"); + return Ok(0); + } + let file = match &command { + JevCommand::Check(args) => args.config.clone(), + _ => None, + }; + let context = ConfigContext::discover(file.as_deref())?; + match command { + JevCommand::Auth { .. } + | JevCommand::Init { .. } + | JevCommand::Completions { .. } + | JevCommand::Man { .. } => { + unreachable!("handled before repository configuration") + } + JevCommand::Check(mut args) => { + context.configure(&mut args)?; + if let Some(base) = &args.base { + args.base = Some(revision::resolve(&context.root, base)?); + } + check(&args, &context) + } + JevCommand::Baseline { + action: Some(action), + .. + } => baseline_action(&context, action), + JevCommand::Baseline { + merge, + reason, + action: None, + } => { + let written = baseline::write(&context.root, merge, reason)?; + let path = written.path.display(); + if merge { + say!( + "Accepted {} finding(s) from the last check in {path}; kept {} earlier finding(s) for files it did not cover", + written.accepted, + written.kept + ); + } else { + say!("Accepted {} finding(s) in {path}", written.accepted); + } + Ok(0) + } + JevCommand::Rules { format } => { + match format { + options::RulesFormat::Json => { + say!("{}", serde_json::to_string_pretty(&catalog::describe())?) + } + options::RulesFormat::Table => say!("{}", catalog::table()), + } + Ok(0) + } + JevCommand::Serve { port } => { + cancellation::install()?; + server::run(&context.root, port)?; + Ok(0) + } + } +} + +/// `baseline mark` and `baseline stats`: offline edits and counts of the baseline. +fn baseline_action(context: &ConfigContext, action: options::BaselineAction) -> Result { + match action { + options::BaselineAction::Mark { + reason, + targets, + rules, + } => { + let mut keys = Vec::new(); + for name in &rules { + keys.extend( + catalog::select(name) + .ok_or_else(|| anyhow::anyhow!("Unknown rule or group: {name}"))?, + ); + } + let marked = baseline::mark(&context.root, reason, &targets, &keys)?; + say!( + "Marked {marked} accepted finding(s) as {}", + output::label(&reason) + ); + } + options::BaselineAction::Stats { format } => { + let counts = baseline::stats(&context.root)?; + match format { + options::RulesFormat::Json => say!("{}", serde_json::to_string_pretty(&counts)?), + options::RulesFormat::Table => say!("{}", baseline::stats_table(&counts)), + } + } + } + Ok(0) +} + +fn validate_check(args: &CheckArgs) -> Result<()> { + anyhow::ensure!( + !args.show_requests || args.output_format() == Format::Json, + "--show-requests uses JSON output; omit --format or use --format json" + ); + anyhow::ensure!( + !(args.watch && args.dry_run), + "--watch cannot be combined with --dry-run" + ); + anyhow::ensure!( + !(args.watch && matches!(args.output_format(), Format::Json | Format::Github)), + "Use --format jsonl for watch snapshots" + ); + Ok(()) +} + +/// The credential file: `--env-file` from the invocation directory, else the root `.env`. +fn credential_path(args: &CheckArgs, context: &ConfigContext) -> std::path::PathBuf { + args.env_file + .as_ref() + .map(|p| context.input_path(p)) + .unwrap_or_else(|| context.root.join(".env")) +} + +/// Record a failed evaluation in the snapshot (and report) before returning the error. +fn publish_failure( + session: &evaluate::Session<'_>, + report: &mut schema::Report, + error: anyhow::Error, +) -> Result { + report.watcher_pid = None; + report.errors.push(error.to_string()); + report.update_status(); + session.publish(report)?; + if session.args.report { + html_report::open(&session.context.root); + } + Err(error) +} + +fn check(args: &CheckArgs, context: &ConfigContext) -> Result { + validate_check(args)?; + cancellation::install()?; + let scope = inventory::scope(args, context)?; + let inputs = inventory::collect(args, context, &scope)?; + let store = if args.dry_run { + None + } else { + Some(storage::Store::open(&context.root)?) + }; + let baseline = storage::read_latest(&context.root).ok(); + let previous = evaluate::previous_judgments(baseline.as_ref(), args.refresh); + let mut report = evaluate::snapshot( + &inputs, + &previous, + args, + evaluate::SnapshotContext { + root: &context.root, + generation: baseline.as_ref().map_or(1, |r| r.generation + 1), + requests: 0, + }, + ); + if args.dry_run { + output::emit(&report, args)?; + return Ok(0); + } + let store = store.unwrap(); + let mut client = + transport::Client::new(&credential_path(args, context), args.env_file.is_some()); + let mut session = evaluate::Session { + args, + context, + store: &store, + evaluator: &mut client, + requests: 0, + paid_input_tokens: 0, + paid_output_tokens: 0, + budget: token_budget::TokenBudget::load(&context.root), + observed: (0, 0), + }; + if let Err(error) = session.evaluate(&inputs, &mut report) { + return publish_failure(&session, &mut report, error); + } + changes::compare(baseline.as_ref(), &mut report); + gate::settle(&context.root, &mut report, args)?; + report.settled = true; + session.publish(&report)?; + if args.report { + html_report::open(&context.root); + } + if args.output_format() != Format::Jsonl { + output::emit(&report, args)?; + } + if args.watch { + watch::run(&mut session, scope, inputs, report)?; + return Ok(0); + } + Ok(gate::exit_code(&report)) +} diff --git a/src/main.rs b/src/main.rs index a9e37ca..674614e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ mod boundary; mod cancellation; mod catalog; mod changes; +mod command; mod components; mod config; mod context; @@ -56,10 +57,8 @@ mod transport; mod units; mod watch; -use anyhow::Result; use clap::Parser; -use config::ConfigContext; -use options::{CheckArgs, Format, JevCommand}; +use options::JevCommand; /// Code review gate that asks TypeSafe Jev small, literal questions about your code /// @@ -80,7 +79,7 @@ pub struct Cli { } fn main() -> std::process::ExitCode { - let result = run(Cli::parse().command); + let result = command::run(Cli::parse().command); let code = match result { Ok(code) => code, Err(error) => { @@ -91,217 +90,5 @@ fn main() -> std::process::ExitCode { std::process::ExitCode::from(cancellation::signal().map_or(code, |s| (128 + s) as u8)) } -fn run(command: JevCommand) -> Result { - if let JevCommand::Auth { command } = command { - return auth::run(command); - } - match &command { - JevCommand::Completions { shell } => return manual::completions(*shell).map(|()| 0), - JevCommand::Man { command } => return manual::man(command.as_deref()).map(|()| 0), - _ => {} - } - if let JevCommand::Init { force } = command { - // Before reading configuration, so an invalid file can be replaced. - let root = config::repository_root(&std::env::current_dir()?.canonicalize()?); - let (path, allow) = init::run(&root, force)?; - say!("Wrote {}", path.display()); - if allow.is_empty() { - say!("No supported source found; set upload_allow before checking."); - } else { - say!("Uploads limited to: {}", allow.join(", ")); - } - say!("Next: jevgate auth login, then jevgate check --dry-run --show-requests"); - return Ok(0); - } - let file = match &command { - JevCommand::Check(args) => args.config.clone(), - _ => None, - }; - let context = ConfigContext::discover(file.as_deref())?; - match command { - JevCommand::Auth { .. } - | JevCommand::Init { .. } - | JevCommand::Completions { .. } - | JevCommand::Man { .. } => { - unreachable!("handled before repository configuration") - } - JevCommand::Check(mut args) => { - context.configure(&mut args)?; - if let Some(base) = &args.base { - args.base = Some(revision::resolve(&context.root, base)?); - } - check(&args, &context) - } - JevCommand::Baseline { - action: Some(action), - .. - } => baseline_action(&context, action), - JevCommand::Baseline { - merge, - reason, - action: None, - } => { - let written = baseline::write(&context.root, merge, reason)?; - let path = written.path.display(); - if merge { - say!( - "Accepted {} finding(s) from the last check in {path}; kept {} earlier finding(s) for files it did not cover", - written.accepted, - written.kept - ); - } else { - say!("Accepted {} finding(s) in {path}", written.accepted); - } - Ok(0) - } - JevCommand::Rules { format } => { - match format { - options::RulesFormat::Json => { - say!("{}", serde_json::to_string_pretty(&catalog::describe())?) - } - options::RulesFormat::Table => say!("{}", catalog::table()), - } - Ok(0) - } - JevCommand::Serve { port } => { - cancellation::install()?; - server::run(&context.root, port)?; - Ok(0) - } - } -} - -/// `baseline mark` and `baseline stats`: offline edits and counts of the baseline. -fn baseline_action(context: &ConfigContext, action: options::BaselineAction) -> Result { - match action { - options::BaselineAction::Mark { - reason, - targets, - rules, - } => { - let mut keys = Vec::new(); - for name in &rules { - keys.extend( - catalog::select(name) - .ok_or_else(|| anyhow::anyhow!("Unknown rule or group: {name}"))?, - ); - } - let marked = baseline::mark(&context.root, reason, &targets, &keys)?; - say!( - "Marked {marked} accepted finding(s) as {}", - output::label(&reason) - ); - } - options::BaselineAction::Stats { format } => { - let counts = baseline::stats(&context.root)?; - match format { - options::RulesFormat::Json => say!("{}", serde_json::to_string_pretty(&counts)?), - options::RulesFormat::Table => say!("{}", baseline::stats_table(&counts)), - } - } - } - Ok(0) -} - -fn validate_check(args: &CheckArgs) -> Result<()> { - anyhow::ensure!( - !args.show_requests || args.output_format() == Format::Json, - "--show-requests uses JSON output; omit --format or use --format json" - ); - anyhow::ensure!( - !(args.watch && args.dry_run), - "--watch cannot be combined with --dry-run" - ); - anyhow::ensure!( - !(args.watch && matches!(args.output_format(), Format::Json | Format::Github)), - "Use --format jsonl for watch snapshots" - ); - Ok(()) -} - -/// The credential file: `--env-file` from the invocation directory, else the root `.env`. -fn credential_path(args: &CheckArgs, context: &ConfigContext) -> std::path::PathBuf { - args.env_file - .as_ref() - .map(|p| context.input_path(p)) - .unwrap_or_else(|| context.root.join(".env")) -} - -/// Record a failed evaluation in the snapshot (and report) before returning the error. -fn publish_failure( - session: &evaluate::Session<'_>, - report: &mut schema::Report, - error: anyhow::Error, -) -> Result { - report.watcher_pid = None; - report.errors.push(error.to_string()); - report.update_status(); - session.publish(report)?; - if session.args.report { - html_report::open(&session.context.root); - } - Err(error) -} - -fn check(args: &CheckArgs, context: &ConfigContext) -> Result { - validate_check(args)?; - cancellation::install()?; - let scope = inventory::scope(args, context)?; - let inputs = inventory::collect(args, context, &scope)?; - let store = if args.dry_run { - None - } else { - Some(storage::Store::open(&context.root)?) - }; - let baseline = storage::read_latest(&context.root).ok(); - let previous = evaluate::previous_judgments(baseline.as_ref(), args.refresh); - let mut report = evaluate::snapshot( - &inputs, - &previous, - args, - evaluate::SnapshotContext { - root: &context.root, - generation: baseline.as_ref().map_or(1, |r| r.generation + 1), - requests: 0, - }, - ); - if args.dry_run { - output::emit(&report, args)?; - return Ok(0); - } - let store = store.unwrap(); - let mut client = - transport::Client::new(&credential_path(args, context), args.env_file.is_some()); - let mut session = evaluate::Session { - args, - context, - store: &store, - evaluator: &mut client, - requests: 0, - paid_input_tokens: 0, - paid_output_tokens: 0, - budget: token_budget::TokenBudget::load(&context.root), - observed: (0, 0), - }; - if let Err(error) = session.evaluate(&inputs, &mut report) { - return publish_failure(&session, &mut report, error); - } - changes::compare(baseline.as_ref(), &mut report); - gate::settle(&context.root, &mut report, args)?; - report.settled = true; - session.publish(&report)?; - if args.report { - html_report::open(&context.root); - } - if args.output_format() != Format::Jsonl { - output::emit(&report, args)?; - } - if args.watch { - watch::run(&mut session, scope, inputs, report)?; - return Ok(0); - } - Ok(gate::exit_code(&report)) -} - #[cfg(test)] mod tests; diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 6fde41e..394eee8 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -2,6 +2,7 @@ //! the gate and baseline tests are in `gating`, and what a run judges //! (unsupported or oversized input, roles, context) in `scope`. use super::*; +use crate::{config::ConfigContext, options::CheckArgs}; use clap::Parser; use serde_json::{Value, json}; use std::path::PathBuf; From 4a8a6bfd09691b7cee4d1dede571583a261949c5 Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:34:19 -0300 Subject: [PATCH 3/3] Keep init, baseline and check out of the command dispatch run only routes each command; init and accepting the baseline are their own functions, and check runs from its own module, as the self-review suggested. --- src/check.rs | 111 +++++++++++++++++++++++++++++ src/command.rs | 189 +++++++++++++------------------------------------ src/main.rs | 1 + 3 files changed, 162 insertions(+), 139 deletions(-) create mode 100644 src/check.rs diff --git a/src/check.rs b/src/check.rs new file mode 100644 index 0000000..cc730f3 --- /dev/null +++ b/src/check.rs @@ -0,0 +1,111 @@ +//! `check`: collect the selected files, evaluate them, apply the gate, and +//! report once or keep watching. +use crate::{ + cancellation, changes, + config::ConfigContext, + evaluate, gate, html_report, inventory, + options::{CheckArgs, Format}, + output, schema, storage, token_budget, transport, watch, +}; +use anyhow::Result; + +fn validate(args: &CheckArgs) -> Result<()> { + anyhow::ensure!( + !args.show_requests || args.output_format() == Format::Json, + "--show-requests uses JSON output; omit --format or use --format json" + ); + anyhow::ensure!( + !(args.watch && args.dry_run), + "--watch cannot be combined with --dry-run" + ); + anyhow::ensure!( + !(args.watch && matches!(args.output_format(), Format::Json | Format::Github)), + "Use --format jsonl for watch snapshots" + ); + Ok(()) +} + +/// The credential file: `--env-file` from the invocation directory, else the root `.env`. +fn credential_path(args: &CheckArgs, context: &ConfigContext) -> std::path::PathBuf { + args.env_file + .as_ref() + .map(|p| context.input_path(p)) + .unwrap_or_else(|| context.root.join(".env")) +} + +/// Record a failed evaluation in the snapshot (and report) before returning the error. +fn publish_failure( + session: &evaluate::Session<'_>, + report: &mut schema::Report, + error: anyhow::Error, +) -> Result { + report.watcher_pid = None; + report.errors.push(error.to_string()); + report.update_status(); + session.publish(report)?; + if session.args.report { + html_report::open(&session.context.root); + } + Err(error) +} + +/// `check`: judge the selected files, apply the gate and report. +pub fn run(args: &CheckArgs, context: &ConfigContext) -> Result { + validate(args)?; + cancellation::install()?; + let scope = inventory::scope(args, context)?; + let inputs = inventory::collect(args, context, &scope)?; + let store = if args.dry_run { + None + } else { + Some(storage::Store::open(&context.root)?) + }; + let baseline = storage::read_latest(&context.root).ok(); + let previous = evaluate::previous_judgments(baseline.as_ref(), args.refresh); + let mut report = evaluate::snapshot( + &inputs, + &previous, + args, + evaluate::SnapshotContext { + root: &context.root, + generation: baseline.as_ref().map_or(1, |r| r.generation + 1), + requests: 0, + }, + ); + if args.dry_run { + output::emit(&report, args)?; + return Ok(0); + } + let store = store.unwrap(); + let mut client = + transport::Client::new(&credential_path(args, context), args.env_file.is_some()); + let mut session = evaluate::Session { + args, + context, + store: &store, + evaluator: &mut client, + requests: 0, + paid_input_tokens: 0, + paid_output_tokens: 0, + budget: token_budget::TokenBudget::load(&context.root), + observed: (0, 0), + }; + if let Err(error) = session.evaluate(&inputs, &mut report) { + return publish_failure(&session, &mut report, error); + } + changes::compare(baseline.as_ref(), &mut report); + gate::settle(&context.root, &mut report, args)?; + report.settled = true; + session.publish(&report)?; + if args.report { + html_report::open(&context.root); + } + if args.output_format() != Format::Jsonl { + output::emit(&report, args)?; + } + if args.watch { + watch::run(&mut session, scope, inputs, report)?; + return Ok(0); + } + Ok(gate::exit_code(&report)) +} diff --git a/src/command.rs b/src/command.rs index c62fe59..729f7bc 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,36 +1,40 @@ //! Running each command: offline commands first, then the ones that read -//! the repository's configuration, with `check` and its evaluation. +//! the repository's configuration; `check` runs in its own module. use crate::{ - auth, baseline, cancellation, catalog, changes, config, + auth, baseline, cancellation, catalog, config, config::ConfigContext, - evaluate, gate, html_report, init, inventory, manual, - options::{self, CheckArgs, Format, JevCommand}, - output, revision, schema, server, storage, token_budget, transport, watch, + init, manual, + options::{self, JevCommand}, + output, revision, server, }; use anyhow::Result; pub fn run(command: JevCommand) -> Result { - if let JevCommand::Auth { command } = command { - return auth::run(command); - } - match &command { - JevCommand::Completions { shell } => return manual::completions(*shell).map(|()| 0), - JevCommand::Man { command } => return manual::man(command.as_deref()).map(|()| 0), - _ => {} + match command { + JevCommand::Auth { command } => auth::run(command), + JevCommand::Completions { shell } => manual::completions(shell).map(|()| 0), + JevCommand::Man { command } => manual::man(command.as_deref()).map(|()| 0), + JevCommand::Init { force } => init(force), + command => configured(command), } - if let JevCommand::Init { force } = command { - // Before reading configuration, so an invalid file can be replaced. - let root = config::repository_root(&std::env::current_dir()?.canonicalize()?); - let (path, allow) = init::run(&root, force)?; - say!("Wrote {}", path.display()); - if allow.is_empty() { - say!("No supported source found; set upload_allow before checking."); - } else { - say!("Uploads limited to: {}", allow.join(", ")); - } - say!("Next: jevgate auth login, then jevgate check --dry-run --show-requests"); - return Ok(0); +} + +/// `init` runs before configuration is read, so an invalid file can be replaced. +fn init(force: bool) -> Result { + let root = config::repository_root(&std::env::current_dir()?.canonicalize()?); + let (path, allow) = init::run(&root, force)?; + say!("Wrote {}", path.display()); + if allow.is_empty() { + say!("No supported source found; set upload_allow before checking."); + } else { + say!("Uploads limited to: {}", allow.join(", ")); } + say!("Next: jevgate auth login, then jevgate check --dry-run --show-requests"); + Ok(0) +} + +/// The commands that read the repository's configuration. +fn configured(command: JevCommand) -> Result { let file = match &command { JevCommand::Check(args) => args.config.clone(), _ => None, @@ -48,7 +52,7 @@ pub fn run(command: JevCommand) -> Result { if let Some(base) = &args.base { args.base = Some(revision::resolve(&context.root, base)?); } - check(&args, &context) + crate::check::run(&args, &context) } JevCommand::Baseline { action: Some(action), @@ -58,20 +62,7 @@ pub fn run(command: JevCommand) -> Result { merge, reason, action: None, - } => { - let written = baseline::write(&context.root, merge, reason)?; - let path = written.path.display(); - if merge { - say!( - "Accepted {} finding(s) from the last check in {path}; kept {} earlier finding(s) for files it did not cover", - written.accepted, - written.kept - ); - } else { - say!("Accepted {} finding(s) in {path}", written.accepted); - } - Ok(0) - } + } => accept(&context, merge, reason), JevCommand::Rules { format } => { match format { options::RulesFormat::Json => { @@ -89,6 +80,26 @@ pub fn run(command: JevCommand) -> Result { } } +/// `baseline`: accept the last check's findings. +fn accept( + context: &ConfigContext, + merge: bool, + reason: Option, +) -> Result { + let written = baseline::write(&context.root, merge, reason)?; + let path = written.path.display(); + if merge { + say!( + "Accepted {} finding(s) from the last check in {path}; kept {} earlier finding(s) for files it did not cover", + written.accepted, + written.kept + ); + } else { + say!("Accepted {} finding(s) in {path}", written.accepted); + } + Ok(0) +} + /// `baseline mark` and `baseline stats`: offline edits and counts of the baseline. fn baseline_action(context: &ConfigContext, action: options::BaselineAction) -> Result { match action { @@ -120,103 +131,3 @@ fn baseline_action(context: &ConfigContext, action: options::BaselineAction) -> } Ok(0) } - -fn validate_check(args: &CheckArgs) -> Result<()> { - anyhow::ensure!( - !args.show_requests || args.output_format() == Format::Json, - "--show-requests uses JSON output; omit --format or use --format json" - ); - anyhow::ensure!( - !(args.watch && args.dry_run), - "--watch cannot be combined with --dry-run" - ); - anyhow::ensure!( - !(args.watch && matches!(args.output_format(), Format::Json | Format::Github)), - "Use --format jsonl for watch snapshots" - ); - Ok(()) -} - -/// The credential file: `--env-file` from the invocation directory, else the root `.env`. -fn credential_path(args: &CheckArgs, context: &ConfigContext) -> std::path::PathBuf { - args.env_file - .as_ref() - .map(|p| context.input_path(p)) - .unwrap_or_else(|| context.root.join(".env")) -} - -/// Record a failed evaluation in the snapshot (and report) before returning the error. -fn publish_failure( - session: &evaluate::Session<'_>, - report: &mut schema::Report, - error: anyhow::Error, -) -> Result { - report.watcher_pid = None; - report.errors.push(error.to_string()); - report.update_status(); - session.publish(report)?; - if session.args.report { - html_report::open(&session.context.root); - } - Err(error) -} - -fn check(args: &CheckArgs, context: &ConfigContext) -> Result { - validate_check(args)?; - cancellation::install()?; - let scope = inventory::scope(args, context)?; - let inputs = inventory::collect(args, context, &scope)?; - let store = if args.dry_run { - None - } else { - Some(storage::Store::open(&context.root)?) - }; - let baseline = storage::read_latest(&context.root).ok(); - let previous = evaluate::previous_judgments(baseline.as_ref(), args.refresh); - let mut report = evaluate::snapshot( - &inputs, - &previous, - args, - evaluate::SnapshotContext { - root: &context.root, - generation: baseline.as_ref().map_or(1, |r| r.generation + 1), - requests: 0, - }, - ); - if args.dry_run { - output::emit(&report, args)?; - return Ok(0); - } - let store = store.unwrap(); - let mut client = - transport::Client::new(&credential_path(args, context), args.env_file.is_some()); - let mut session = evaluate::Session { - args, - context, - store: &store, - evaluator: &mut client, - requests: 0, - paid_input_tokens: 0, - paid_output_tokens: 0, - budget: token_budget::TokenBudget::load(&context.root), - observed: (0, 0), - }; - if let Err(error) = session.evaluate(&inputs, &mut report) { - return publish_failure(&session, &mut report, error); - } - changes::compare(baseline.as_ref(), &mut report); - gate::settle(&context.root, &mut report, args)?; - report.settled = true; - session.publish(&report)?; - if args.report { - html_report::open(&context.root); - } - if args.output_format() != Format::Jsonl { - output::emit(&report, args)?; - } - if args.watch { - watch::run(&mut session, scope, inputs, report)?; - return Ok(0); - } - Ok(gate::exit_code(&report)) -} diff --git a/src/main.rs b/src/main.rs index 674614e..492fbf4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ mod boundary; mod cancellation; mod catalog; mod changes; +mod check; mod command; mod components; mod config;