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    }
195
196    #[test]
197    fn cache_only_and_push_gate_parse_individually_but_not_together() {
198        assert!(check_args(["drep", "check", "--cache-only"]).cache_only);
199        assert!(check_args(["drep", "check", "--push-gate"]).push_gate);
200        assert!(Cli::try_parse_from(["drep", "check", "--cache-only", "--push-gate"]).is_err());
201    }
202
203    #[test]
204    fn default_repository_config_path_is_relative_to_the_requested_root() {
205        assert!(config::default_config_path().is_relative());
206    }
207
208    #[test]
209    fn fail_on_accepts_the_whole_severity_vocabulary() {
210        // Driven off `Severity::ALL` so a new severity is covered here the
211        // moment it is added, rather than passing on a stale subset.
212        for expected in Severity::ALL {
213            let args = check_args(["drep", "check", "--fail-on", expected.as_str()]);
214            assert_eq!(args.fail_on, Some(expected));
215        }
216        assert!(Cli::try_parse_from(["drep", "check", "--fail-on", "critical"]).is_err());
217    }
218
219    #[test]
220    fn every_command_dispatches_to_an_implementation() {
221        // No subcommand may reach `run` and fall through to a clean exit.
222        // `run`'s match is exhaustive over `Command`, so the compiler enforces
223        // it - this asserts the enum is still the commands the contract names,
224        // so one added without an arm is a compile error rather than a silent
225        // pass.
226        let names: Vec<String> = Cli::command()
227            .get_subcommands()
228            .map(|c| c.get_name().to_owned())
229            .collect();
230        assert_eq!(
231            names,
232            vec![
233                "check",
234                "lint-docs",
235                "doctor",
236                "init",
237                "auth",
238                "acknowledge"
239            ]
240        );
241    }
242
243    #[test]
244    fn lint_docs_takes_paths_and_strict() {
245        let cli = Cli::try_parse_from(["drep", "lint-docs", "--strict", "a.md"]).unwrap();
246        match cli.command {
247            Command::LintDocs(args) => {
248                assert!(args.strict);
249                assert_eq!(args.paths.len(), 1);
250            }
251            other => panic!("expected lint-docs, got {other:?}"),
252        }
253        let cli = Cli::try_parse_from(["drep", "lint-docs"]).unwrap();
254        match cli.command {
255            Command::LintDocs(args) => {
256                assert!(!args.strict, "report-only is the default");
257                assert!(args.paths.is_empty());
258            }
259            other => panic!("expected lint-docs, got {other:?}"),
260        }
261    }
262}