Skip to main content

drep/cli/
mod.rs

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