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/// How findings are rendered.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
71pub enum OutputFormat {
72    /// Human-readable, for a terminal.
73    Text,
74    /// Machine-readable. Carries `unanalyzed` alongside `findings`, so a
75    /// consumer can tell a clean run from one that never happened.
76    Json,
77}
78
79/// Dispatch a parsed command.
80pub async fn run(cli: Cli) -> Result<Exit> {
81    match cli.command {
82        Command::Check(args) => check::run(&args, std::path::Path::new(".")).await,
83        Command::LintDocs(args) => lint_docs::run(&args, std::path::Path::new(".")).await,
84        Command::Doctor(args) => doctor::run(&args),
85        Command::Init(args) => init::run(&args).await,
86        Command::Auth(args) => auth::run(&args),
87        Command::Acknowledge(args) => acknowledge::run(&args, std::path::Path::new(".")),
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::analysis::findings::Severity;
95    use crate::config;
96    use clap::CommandFactory;
97
98    /// Parse a `check` invocation and hand back its arguments.
99    fn check_args<const N: usize>(argv: [&str; N]) -> CheckArgs {
100        let cli = Cli::try_parse_from(argv).expect("should parse");
101        match cli.command {
102            Command::Check(args) => args,
103            other => panic!("expected check, got {other:?}"),
104        }
105    }
106
107    /// Parse a `lint-docs` invocation and hand back its arguments.
108    fn lint_docs_args<const N: usize>(argv: [&str; N]) -> LintDocsArgs {
109        let cli = Cli::try_parse_from(argv).expect("should parse");
110        match cli.command {
111            Command::LintDocs(args) => args,
112            other => panic!("expected lint-docs, got {other:?}"),
113        }
114    }
115
116    #[test]
117    fn lint_docs_gating_is_off_by_default() {
118        let args = lint_docs_args(["drep", "lint-docs"]);
119        assert!(!args.strict);
120        assert_eq!(args.fail_on, None);
121        assert_eq!(args.threshold(), None);
122    }
123
124    #[test]
125    fn lint_docs_fail_on_accepts_the_whole_severity_vocabulary() {
126        for expected in Severity::ALL {
127            let args = lint_docs_args(["drep", "lint-docs", "--fail-on", expected.as_str()]);
128            assert_eq!(args.fail_on, Some(expected));
129            assert_eq!(args.threshold(), Some(expected));
130        }
131        assert!(Cli::try_parse_from(["drep", "lint-docs", "--fail-on", "critical"]).is_err());
132    }
133
134    /// `--strict` is the shorthand, not a second mechanism.
135    #[test]
136    fn lint_docs_strict_is_fail_on_info() {
137        assert_eq!(
138            lint_docs_args(["drep", "lint-docs", "--strict"]).threshold(),
139            Some(Severity::Info)
140        );
141    }
142
143    /// Passing both asks two different questions at once. Which wins is not a
144    /// thing a user should have to remember, so clap refuses.
145    #[test]
146    fn lint_docs_strict_and_fail_on_are_mutually_exclusive() {
147        assert!(
148            Cli::try_parse_from(["drep", "lint-docs", "--strict", "--fail-on", "error"]).is_err()
149        );
150    }
151
152    #[test]
153    fn cli_definition_is_valid() {
154        Cli::command().debug_assert();
155    }
156
157    #[test]
158    fn input_modes_are_mutually_exclusive() {
159        assert!(Cli::try_parse_from(["drep", "check", "--staged", "a.rs"]).is_err());
160        assert!(Cli::try_parse_from(["drep", "check", "--staged", "--diff", "main"]).is_err());
161        assert!(Cli::try_parse_from(["drep", "check", "--diff", "main", "a.rs"]).is_err());
162    }
163
164    #[test]
165    fn each_input_mode_parses_alone() {
166        assert_eq!(check_args(["drep", "check", "a.rs", "src/"]).paths.len(), 2);
167        assert!(check_args(["drep", "check", "--staged"]).staged);
168        assert_eq!(
169            check_args(["drep", "check", "--diff", "origin/main"])
170                .diff
171                .as_deref(),
172            Some("origin/main")
173        );
174        assert!(check_args(["drep", "check"]).paths.is_empty());
175        assert!(check_args(["drep", "check", "--pre-commit-push"]).pre_commit_push);
176    }
177
178    #[test]
179    fn pre_commit_push_is_an_input_mode_not_a_modifier() {
180        assert!(Cli::try_parse_from(["drep", "check", "--pre-commit-push", "a.rs"]).is_err());
181        assert!(Cli::try_parse_from(["drep", "check", "--pre-commit-push", "--staged"]).is_err());
182        assert!(
183            Cli::try_parse_from(["drep", "check", "--pre-commit-push", "--diff", "main",]).is_err()
184        );
185    }
186
187    #[test]
188    fn format_defaults_to_text_and_fail_on_defaults_to_off() {
189        let args = check_args(["drep", "check"]);
190        assert_eq!(args.format, OutputFormat::Text);
191        assert_eq!(args.fail_on, None);
192        assert!(!args.cache_only);
193        assert!(!args.push_gate);
194        assert_eq!(args.max_review_rounds, None);
195        assert!(!args.unlimited_reviews);
196    }
197
198    #[test]
199    fn review_limit_override_and_unlimited_mode_are_mutually_exclusive() {
200        assert_eq!(
201            check_args(["drep", "check", "--max-review-rounds", "7"]).max_review_rounds,
202            Some(7)
203        );
204        assert!(check_args(["drep", "check", "--unlimited-reviews"]).unlimited_reviews);
205        assert!(
206            Cli::try_parse_from([
207                "drep",
208                "check",
209                "--max-review-rounds",
210                "7",
211                "--unlimited-reviews",
212            ])
213            .is_err()
214        );
215        assert!(Cli::try_parse_from(["drep", "check", "--max-review-rounds", "0"]).is_err());
216    }
217
218    #[test]
219    fn cache_only_and_push_gate_parse_individually_but_not_together() {
220        assert!(check_args(["drep", "check", "--cache-only"]).cache_only);
221        assert!(check_args(["drep", "check", "--push-gate"]).push_gate);
222        assert!(Cli::try_parse_from(["drep", "check", "--cache-only", "--push-gate"]).is_err());
223    }
224
225    #[test]
226    fn default_repository_config_path_is_relative_to_the_requested_root() {
227        assert!(config::default_config_path().is_relative());
228    }
229
230    #[test]
231    fn fail_on_accepts_the_whole_severity_vocabulary() {
232        // Driven off `Severity::ALL` so a new severity is covered here the
233        // moment it is added, rather than passing on a stale subset.
234        for expected in Severity::ALL {
235            let args = check_args(["drep", "check", "--fail-on", expected.as_str()]);
236            assert_eq!(args.fail_on, Some(expected));
237        }
238        assert!(Cli::try_parse_from(["drep", "check", "--fail-on", "critical"]).is_err());
239    }
240
241    #[test]
242    fn every_command_dispatches_to_an_implementation() {
243        // No subcommand may reach `run` and fall through to a clean exit.
244        // `run`'s match is exhaustive over `Command`, so the compiler enforces
245        // it - this asserts the enum is still the commands the contract names,
246        // so one added without an arm is a compile error rather than a silent
247        // pass.
248        let names: Vec<String> = Cli::command()
249            .get_subcommands()
250            .map(|c| c.get_name().to_owned())
251            .collect();
252        assert_eq!(
253            names,
254            vec![
255                "check",
256                "lint-docs",
257                "doctor",
258                "init",
259                "auth",
260                "acknowledge"
261            ]
262        );
263    }
264
265    #[test]
266    fn lint_docs_takes_paths_and_strict() {
267        let cli = Cli::try_parse_from(["drep", "lint-docs", "--strict", "a.md"]).unwrap();
268        match cli.command {
269            Command::LintDocs(args) => {
270                assert!(args.strict);
271                assert_eq!(args.paths.len(), 1);
272            }
273            other => panic!("expected lint-docs, got {other:?}"),
274        }
275        let cli = Cli::try_parse_from(["drep", "lint-docs"]).unwrap();
276        match cli.command {
277            Command::LintDocs(args) => {
278                assert!(!args.strict, "report-only is the default");
279                assert!(args.paths.is_empty());
280            }
281            other => panic!("expected lint-docs, got {other:?}"),
282        }
283    }
284}