Skip to main content

drep/cli/
mod.rs

1//! Command-line surface.
2//!
3//! Six commands and two git-hook triggers: pre-commit and pre-push.
4//!
5//! Each command owns its arguments in its own module, so a command's contract
6//! and its behaviour stay together.
7
8pub mod acknowledge;
9pub mod auth;
10pub mod check;
11pub mod doctor;
12pub mod init;
13pub mod lint_docs;
14pub mod render;
15
16use anyhow::Result;
17use clap::builder::{PossibleValuesParser, TypedValueParser};
18use clap::{Parser, Subcommand, ValueEnum};
19
20use crate::analysis::findings::Severity;
21
22use crate::Exit;
23use acknowledge::AcknowledgeArgs;
24use auth::AuthArgs;
25use check::CheckArgs;
26use doctor::DoctorArgs;
27use init::InitArgs;
28use lint_docs::LintDocsArgs;
29
30#[derive(Debug, Parser)]
31#[command(
32    name = "drep",
33    version,
34    about = "Run the linters your repo configures, and have an LLM review what changed",
35    long_about = None,
36)]
37pub struct Cli {
38    #[command(subcommand)]
39    pub command: Command,
40}
41
42#[derive(Debug, Subcommand)]
43pub enum Command {
44    /// Analyze local files. Intended for pre-commit and pre-push hooks.
45    Check(CheckArgs),
46    /// Lint markdown. Rule-based only - no LLM, no network.
47    LintDocs(LintDocsArgs),
48    /// Report which languages and tools drep can see in this repository.
49    Doctor(DoctorArgs),
50    /// Write the git hooks and LLM endpoint configuration.
51    Init(InitArgs),
52    /// Manage the API keys drep holds for this machine.
53    Auth(AuthArgs),
54    /// Stop re-reporting an LLM finding until its surrounding source changes.
55    Acknowledge(AcknowledgeArgs),
56}
57
58/// Parse a `--fail-on` severity, for whichever command takes one.
59///
60/// Built from `Severity::ALL` rather than a literal list, so `--help` shows
61/// exactly the values `FromStr` accepts and neither can drift. Shared by
62/// `check` and `lint-docs`: two commands in one binary that disagree about the
63/// severity vocabulary are two contracts a hook author has to learn.
64pub(crate) fn severity_parser() -> impl TypedValueParser<Value = Severity> {
65    PossibleValuesParser::new(Severity::ALL.map(Severity::as_str))
66        .map(|name| name.parse::<Severity>().expect("possible values parse"))
67}
68
69/// The two machine-level files a command reads from outside the repository.
70///
71/// Grouped rather than passed as two adjacent `&Path` positionals, which is what
72/// `check::run_against` and `doctor::run_at` took. Transposing those compiles,
73/// and the transposition is silent in the worst direction: `AuthStore` has no
74/// `deny_unknown_fields`, so a policy file deserializes into an empty store, and
75/// `site::load` reads an absent `auth.toml` as no policy at all - so the swap
76/// leaves the fleet's ceiling and `refuse_markers` unapplied while the run
77/// reports as compliance. The same swap hazard `auth::Declared` and
78/// `check::refusal::Locations` already exist to remove.
79pub struct MachineFiles<'a> {
80    /// The credential store, from [`crate::auth::default_path`].
81    pub auth: &'a std::path::Path,
82    /// The site policy file, from [`crate::config::site::default_path`].
83    pub policy: &'a std::path::Path,
84}
85
86/// How findings are rendered.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
88pub enum OutputFormat {
89    /// Human-readable, for a terminal.
90    Text,
91    /// Machine-readable. Carries `unanalyzed` alongside `findings`, so a
92    /// consumer can tell a clean run from one that never happened.
93    Json,
94}
95
96/// Dispatch a parsed command.
97pub async fn run(cli: Cli) -> Result<Exit> {
98    match cli.command {
99        Command::Check(args) => check::run(&args, std::path::Path::new(".")).await,
100        Command::LintDocs(args) => lint_docs::run(&args, std::path::Path::new(".")).await,
101        Command::Doctor(args) => doctor::run(&args).await,
102        Command::Init(args) => init::run(&args).await,
103        Command::Auth(args) => auth::run(&args),
104        Command::Acknowledge(args) => acknowledge::run(&args, std::path::Path::new(".")),
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::analysis::findings::Severity;
112    use crate::config;
113    use clap::CommandFactory;
114
115    /// Parse a `check` invocation and hand back its arguments.
116    fn check_args<const N: usize>(argv: [&str; N]) -> CheckArgs {
117        let cli = Cli::try_parse_from(argv).expect("should parse");
118        match cli.command {
119            Command::Check(args) => args,
120            other => panic!("expected check, got {other:?}"),
121        }
122    }
123
124    /// Parse a `lint-docs` invocation and hand back its arguments.
125    fn lint_docs_args<const N: usize>(argv: [&str; N]) -> LintDocsArgs {
126        let cli = Cli::try_parse_from(argv).expect("should parse");
127        match cli.command {
128            Command::LintDocs(args) => args,
129            other => panic!("expected lint-docs, got {other:?}"),
130        }
131    }
132
133    #[test]
134    fn lint_docs_gating_is_off_by_default() {
135        let args = lint_docs_args(["drep", "lint-docs"]);
136        assert!(!args.strict);
137        assert_eq!(args.fail_on, None);
138        assert_eq!(args.threshold(), None);
139    }
140
141    #[test]
142    fn lint_docs_fail_on_accepts_the_whole_severity_vocabulary() {
143        for expected in Severity::ALL {
144            let args = lint_docs_args(["drep", "lint-docs", "--fail-on", expected.as_str()]);
145            assert_eq!(args.fail_on, Some(expected));
146            assert_eq!(args.threshold(), Some(expected));
147        }
148        assert!(Cli::try_parse_from(["drep", "lint-docs", "--fail-on", "critical"]).is_err());
149    }
150
151    /// `--strict` is the shorthand, not a second mechanism.
152    #[test]
153    fn lint_docs_strict_is_fail_on_info() {
154        assert_eq!(
155            lint_docs_args(["drep", "lint-docs", "--strict"]).threshold(),
156            Some(Severity::Info)
157        );
158    }
159
160    /// Passing both asks two different questions at once. Which wins is not a
161    /// thing a user should have to remember, so clap refuses.
162    #[test]
163    fn lint_docs_strict_and_fail_on_are_mutually_exclusive() {
164        assert!(
165            Cli::try_parse_from(["drep", "lint-docs", "--strict", "--fail-on", "error"]).is_err()
166        );
167    }
168
169    #[test]
170    fn cli_definition_is_valid() {
171        Cli::command().debug_assert();
172    }
173
174    #[test]
175    fn input_modes_are_mutually_exclusive() {
176        assert!(Cli::try_parse_from(["drep", "check", "--staged", "a.rs"]).is_err());
177        assert!(Cli::try_parse_from(["drep", "check", "--staged", "--diff", "main"]).is_err());
178        assert!(Cli::try_parse_from(["drep", "check", "--diff", "main", "a.rs"]).is_err());
179    }
180
181    #[test]
182    fn each_input_mode_parses_alone() {
183        assert_eq!(check_args(["drep", "check", "a.rs", "src/"]).paths.len(), 2);
184        assert!(check_args(["drep", "check", "--staged"]).staged);
185        assert_eq!(
186            check_args(["drep", "check", "--diff", "origin/main"])
187                .diff
188                .as_deref(),
189            Some("origin/main")
190        );
191        assert!(check_args(["drep", "check"]).paths.is_empty());
192        assert!(check_args(["drep", "check", "--pre-commit-push"]).pre_commit_push);
193    }
194
195    #[test]
196    fn pre_commit_push_is_an_input_mode_not_a_modifier() {
197        assert!(Cli::try_parse_from(["drep", "check", "--pre-commit-push", "a.rs"]).is_err());
198        assert!(Cli::try_parse_from(["drep", "check", "--pre-commit-push", "--staged"]).is_err());
199        assert!(
200            Cli::try_parse_from(["drep", "check", "--pre-commit-push", "--diff", "main",]).is_err()
201        );
202    }
203
204    #[test]
205    fn format_defaults_to_text_and_fail_on_defaults_to_off() {
206        let args = check_args(["drep", "check"]);
207        assert_eq!(args.format, OutputFormat::Text);
208        assert_eq!(args.fail_on, None);
209        assert!(!args.cache_only);
210        assert!(!args.push_gate);
211        assert_eq!(args.max_review_rounds, None);
212        assert!(!args.unlimited_reviews);
213    }
214
215    #[test]
216    fn review_limit_override_and_unlimited_mode_are_mutually_exclusive() {
217        assert_eq!(
218            check_args(["drep", "check", "--max-review-rounds", "7"]).max_review_rounds,
219            Some(7)
220        );
221        assert!(check_args(["drep", "check", "--unlimited-reviews"]).unlimited_reviews);
222        assert!(
223            Cli::try_parse_from([
224                "drep",
225                "check",
226                "--max-review-rounds",
227                "7",
228                "--unlimited-reviews",
229            ])
230            .is_err()
231        );
232        assert!(Cli::try_parse_from(["drep", "check", "--max-review-rounds", "0"]).is_err());
233    }
234
235    #[test]
236    fn cache_only_and_push_gate_parse_individually_but_not_together() {
237        assert!(check_args(["drep", "check", "--cache-only"]).cache_only);
238        assert!(check_args(["drep", "check", "--push-gate"]).push_gate);
239        assert!(Cli::try_parse_from(["drep", "check", "--cache-only", "--push-gate"]).is_err());
240    }
241
242    #[test]
243    fn default_repository_config_path_is_relative_to_the_requested_root() {
244        assert!(config::default_config_path().is_relative());
245    }
246
247    #[test]
248    fn fail_on_accepts_the_whole_severity_vocabulary() {
249        // Driven off `Severity::ALL` so a new severity is covered here the
250        // moment it is added, rather than passing on a stale subset.
251        for expected in Severity::ALL {
252            let args = check_args(["drep", "check", "--fail-on", expected.as_str()]);
253            assert_eq!(args.fail_on, Some(expected));
254        }
255        assert!(Cli::try_parse_from(["drep", "check", "--fail-on", "critical"]).is_err());
256    }
257
258    #[test]
259    fn every_command_dispatches_to_an_implementation() {
260        // No subcommand may reach `run` and fall through to a clean exit.
261        // `run`'s match is exhaustive over `Command`, so the compiler enforces
262        // it - this asserts the enum is still the commands the contract names,
263        // so one added without an arm is a compile error rather than a silent
264        // pass.
265        let names: Vec<String> = Cli::command()
266            .get_subcommands()
267            .map(|c| c.get_name().to_owned())
268            .collect();
269        assert_eq!(
270            names,
271            vec![
272                "check",
273                "lint-docs",
274                "doctor",
275                "init",
276                "auth",
277                "acknowledge"
278            ]
279        );
280    }
281
282    #[test]
283    fn lint_docs_takes_paths_and_strict() {
284        let cli = Cli::try_parse_from(["drep", "lint-docs", "--strict", "a.md"]).unwrap();
285        match cli.command {
286            Command::LintDocs(args) => {
287                assert!(args.strict);
288                assert_eq!(args.paths.len(), 1);
289            }
290            other => panic!("expected lint-docs, got {other:?}"),
291        }
292        let cli = Cli::try_parse_from(["drep", "lint-docs"]).unwrap();
293        match cli.command {
294            Command::LintDocs(args) => {
295                assert!(!args.strict, "report-only is the default");
296                assert!(args.paths.is_empty());
297            }
298            other => panic!("expected lint-docs, got {other:?}"),
299        }
300    }
301}