Skip to main content

drep/cli/
mod.rs

1//! Command-line surface.
2//!
3//! Four 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 auth;
11pub mod check;
12pub mod doctor;
13pub mod init;
14pub mod lint_docs;
15pub mod render;
16
17use anyhow::Result;
18use clap::builder::{PossibleValuesParser, TypedValueParser};
19use clap::{Parser, Subcommand, ValueEnum};
20
21use crate::analysis::findings::Severity;
22
23use crate::Exit;
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}
55
56/// Parse a `--fail-on` severity, for whichever command takes one.
57///
58/// Built from `Severity::ALL` rather than a literal list, so `--help` shows
59/// exactly the values `FromStr` accepts and neither can drift. Shared by
60/// `check` and `lint-docs`: two commands in one binary that disagree about the
61/// severity vocabulary are two contracts a hook author has to learn.
62pub(crate) fn severity_parser() -> impl TypedValueParser<Value = Severity> {
63    PossibleValuesParser::new(Severity::ALL.map(Severity::as_str))
64        .map(|name| name.parse::<Severity>().expect("possible values parse"))
65}
66
67/// How findings are rendered.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
69pub enum OutputFormat {
70    /// Human-readable, for a terminal.
71    Text,
72    /// Machine-readable. Carries `unanalyzed` alongside `findings`, so a
73    /// consumer can tell a clean run from one that never happened.
74    Json,
75}
76
77/// Dispatch a parsed command.
78pub async fn run(cli: Cli) -> Result<Exit> {
79    match cli.command {
80        Command::Check(args) => check::run(&args, std::path::Path::new(".")).await,
81        Command::LintDocs(args) => lint_docs::run(&args, std::path::Path::new(".")).await,
82        Command::Doctor(args) => doctor::run(&args),
83        Command::Init(args) => init::run(&args).await,
84        Command::Auth(args) => auth::run(&args),
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::analysis::findings::Severity;
92    use clap::CommandFactory;
93
94    /// Parse a `check` invocation and hand back its arguments.
95    fn check_args<const N: usize>(argv: [&str; N]) -> CheckArgs {
96        let cli = Cli::try_parse_from(argv).expect("should parse");
97        match cli.command {
98            Command::Check(args) => args,
99            other => panic!("expected check, got {other:?}"),
100        }
101    }
102
103    /// Parse a `lint-docs` invocation and hand back its arguments.
104    fn lint_docs_args<const N: usize>(argv: [&str; N]) -> LintDocsArgs {
105        let cli = Cli::try_parse_from(argv).expect("should parse");
106        match cli.command {
107            Command::LintDocs(args) => args,
108            other => panic!("expected lint-docs, got {other:?}"),
109        }
110    }
111
112    #[test]
113    fn lint_docs_gating_is_off_by_default() {
114        let args = lint_docs_args(["drep", "lint-docs"]);
115        assert!(!args.strict);
116        assert_eq!(args.fail_on, None);
117        assert_eq!(args.threshold(), None);
118    }
119
120    #[test]
121    fn lint_docs_fail_on_accepts_the_whole_severity_vocabulary() {
122        for expected in Severity::ALL {
123            let args = lint_docs_args(["drep", "lint-docs", "--fail-on", expected.as_str()]);
124            assert_eq!(args.fail_on, Some(expected));
125            assert_eq!(args.threshold(), Some(expected));
126        }
127        assert!(Cli::try_parse_from(["drep", "lint-docs", "--fail-on", "critical"]).is_err());
128    }
129
130    /// `--strict` is the shorthand, not a second mechanism.
131    #[test]
132    fn lint_docs_strict_is_fail_on_info() {
133        assert_eq!(
134            lint_docs_args(["drep", "lint-docs", "--strict"]).threshold(),
135            Some(Severity::Info)
136        );
137    }
138
139    /// Passing both asks two different questions at once. Which wins is not a
140    /// thing a user should have to remember, so clap refuses.
141    #[test]
142    fn lint_docs_strict_and_fail_on_are_mutually_exclusive() {
143        assert!(
144            Cli::try_parse_from(["drep", "lint-docs", "--strict", "--fail-on", "error"]).is_err()
145        );
146    }
147
148    #[test]
149    fn cli_definition_is_valid() {
150        Cli::command().debug_assert();
151    }
152
153    #[test]
154    fn input_modes_are_mutually_exclusive() {
155        assert!(Cli::try_parse_from(["drep", "check", "--staged", "a.rs"]).is_err());
156        assert!(Cli::try_parse_from(["drep", "check", "--staged", "--diff", "main"]).is_err());
157        assert!(Cli::try_parse_from(["drep", "check", "--diff", "main", "a.rs"]).is_err());
158    }
159
160    #[test]
161    fn each_input_mode_parses_alone() {
162        assert_eq!(check_args(["drep", "check", "a.rs", "src/"]).paths.len(), 2);
163        assert!(check_args(["drep", "check", "--staged"]).staged);
164        assert_eq!(
165            check_args(["drep", "check", "--diff", "origin/main"])
166                .diff
167                .as_deref(),
168            Some("origin/main")
169        );
170        assert!(check_args(["drep", "check"]).paths.is_empty());
171    }
172
173    #[test]
174    fn format_defaults_to_text_and_fail_on_defaults_to_off() {
175        let args = check_args(["drep", "check"]);
176        assert_eq!(args.format, OutputFormat::Text);
177        assert_eq!(args.fail_on, None);
178    }
179
180    #[test]
181    fn fail_on_accepts_the_whole_severity_vocabulary() {
182        // Driven off `Severity::ALL` so a new severity is covered here the
183        // moment it is added, rather than passing on a stale subset.
184        for expected in Severity::ALL {
185            let args = check_args(["drep", "check", "--fail-on", expected.as_str()]);
186            assert_eq!(args.fail_on, Some(expected));
187        }
188        assert!(Cli::try_parse_from(["drep", "check", "--fail-on", "critical"]).is_err());
189    }
190
191    #[test]
192    fn every_command_dispatches_to_an_implementation() {
193        // The last stub (`lint-docs`) landed in Phase 6, so there is no
194        // `unimplemented` arm left to pin. What replaces that test is the
195        // guarantee it was really protecting: no subcommand may reach `run`
196        // and fall through to a clean exit. `run`'s match is exhaustive over
197        // `Command`, so the compiler enforces it - this asserts the enum is
198        // still the commands the contract names, so one added without an arm
199        // is a compile error rather than a silent pass.
200        let names: Vec<String> = Cli::command()
201            .get_subcommands()
202            .map(|c| c.get_name().to_owned())
203            .collect();
204        assert_eq!(names, vec!["check", "lint-docs", "doctor", "init", "auth"]);
205    }
206
207    #[test]
208    fn lint_docs_takes_paths_and_strict() {
209        let cli = Cli::try_parse_from(["drep", "lint-docs", "--strict", "a.md"]).unwrap();
210        match cli.command {
211            Command::LintDocs(args) => {
212                assert!(args.strict);
213                assert_eq!(args.paths.len(), 1);
214            }
215            other => panic!("expected lint-docs, got {other:?}"),
216        }
217        let cli = Cli::try_parse_from(["drep", "lint-docs"]).unwrap();
218        match cli.command {
219            Command::LintDocs(args) => {
220                assert!(!args.strict, "report-only is the default");
221                assert!(args.paths.is_empty());
222            }
223            other => panic!("expected lint-docs, got {other:?}"),
224        }
225    }
226}