Skip to main content

omni_dev/cli/git/
lint.rs

1//! Lint command — deterministic, no-AI validation of commit messages against
2//! guidelines. The AI sibling is [`super::check`]; the two share
3//! [`crate::data::check::CheckReport`] so they're interchangeable in
4//! scripts. Core rule logic lives in [`crate::git::lint`].
5
6use std::io::Read;
7use std::path::Path;
8
9use anyhow::{Context, Result};
10use clap::Parser;
11
12use crate::data::check::{CheckReport, CommitCheckResult, OutputFormat};
13
14/// Lint command options - deterministically validates commit messages
15/// against guidelines (no AI, no network).
16#[derive(Parser)]
17pub struct LintCommand {
18    /// Commit range to lint (e.g., HEAD~3..HEAD, abc123..def456).
19    /// Defaults to commits ahead of the default base branch
20    /// (origin/main, origin/master, main, or master).
21    #[arg(value_name = "COMMIT_RANGE")]
22    pub commit_range: Option<String>,
23
24    /// Path to custom context directory (defaults to .omni-dev/).
25    #[arg(long)]
26    pub context_dir: Option<std::path::PathBuf>,
27
28    /// Accepted for CLI parity with `check`; unused by `lint` (there is no
29    /// AI prose guidelines file to load — deterministic rules come from
30    /// `.omni-dev/commit-rules.yaml` and `.omni-dev/scopes.yaml` instead).
31    #[arg(long)]
32    pub guidelines: Option<std::path::PathBuf>,
33
34    /// Output format.
35    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
36    pub output: OutputFormat,
37
38    /// Exits with error code if any issues found (including warnings).
39    #[arg(long)]
40    pub strict: bool,
41
42    /// Only shows errors/warnings, suppresses info-level output.
43    #[arg(long)]
44    pub quiet: bool,
45
46    /// Shows the resolved rules/scopes configuration sources.
47    #[arg(long)]
48    pub verbose: bool,
49
50    /// Includes passing commits in output (hidden by default).
51    #[arg(long)]
52    pub show_passing: bool,
53
54    /// Reads a single commit message from standard input instead of a
55    /// commit range (for a `commit-msg` git hook call site).
56    #[arg(long)]
57    pub stdin: bool,
58
59    /// Populates a deterministic corrected-scope suggestion for each commit
60    /// with an `unknown-scope`/`missing-scope` issue, resolved from the
61    /// commit's changed files against `.omni-dev/scopes.yaml` + ecosystem
62    /// defaults — no AI, no network. Report-only; use `--fix` to apply.
63    /// Requires a commit range (incompatible with `--stdin`, which has no
64    /// changed-files list to resolve a scope from).
65    #[arg(long)]
66    pub suggest: bool,
67
68    /// Applies `--suggest`'s deterministic scope corrections directly to
69    /// the repository, via the same `AmendmentHandler` path `git commit
70    /// message amend` uses. No AI, no confirmation prompt — only ever
71    /// touches a commit with a resolvable `unknown-scope`/`missing-scope`
72    /// issue. Implies `--suggest`. Requires a commit range (incompatible
73    /// with `--stdin`).
74    #[arg(long)]
75    pub fix: bool,
76
77    /// Permits `--fix` to amend commits already present in a detected
78    /// remote main branch (rewrites published history). Mirrors `git
79    /// commit message amend --allow-pushed` / `twiddle --allow-pushed`.
80    /// Ignored without `--fix`.
81    #[arg(long)]
82    pub allow_pushed: bool,
83}
84
85impl LintCommand {
86    /// Executes the lint command, validating commit messages against
87    /// deterministic rules.
88    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
89        if self.stdin && (self.suggest || self.fix) {
90            anyhow::bail!(
91                "--suggest/--fix require a commit range (not --stdin) — a bare message has no \
92                 changed-files list to resolve a scope from"
93            );
94        }
95
96        let repo_root = match repo {
97            Some(p) => p.to_path_buf(),
98            None => std::env::current_dir().context("Failed to determine current directory")?,
99        };
100        let repo_root = repo_root.as_path();
101        let output_format = self.output;
102
103        let context_dir =
104            crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
105        let valid_scopes = crate::claude::context::load_project_scopes(&context_dir, repo_root);
106        let rules = crate::claude::context::load_commit_rules(&context_dir);
107
108        if self.verbose && output_format == OutputFormat::Text {
109            self.show_config_status(repo_root, &context_dir, &valid_scopes, &rules);
110        }
111
112        let report = if self.stdin {
113            let mut message = String::new();
114            std::io::stdin()
115                .read_to_string(&mut message)
116                .context("Failed to read commit message from stdin")?;
117            lint_report_for_message(&message, &rules, &valid_scopes)
118        } else {
119            let range = self.resolve_range(repo_root)?;
120            lint_report_for_range(
121                repo_root,
122                &range,
123                &rules,
124                &valid_scopes,
125                self.suggest || self.fix,
126            )?
127        };
128
129        self.output_report(&report, output_format)?;
130
131        if self.fix {
132            self.apply_fixes(repo_root, &report)?;
133        }
134
135        // Unlike `check`, an empty range is a clean exit (0), not an error —
136        // a deterministic gate with nothing to lint isn't a failure. Note
137        // this reads the pre-`--fix` report (mirroring `check --twiddle`'s
138        // identical ordering): `--fix`'s own success/failure is reported
139        // separately by `apply_fixes` above.
140        let exit_code = report.exit_code(self.strict);
141        if exit_code != 0 {
142            std::process::exit(exit_code);
143        }
144
145        Ok(())
146    }
147
148    /// Applies every commit's deterministic scope-fix suggestion (populated
149    /// by `--suggest`/`--fix` on `report`) directly via the same
150    /// `AmendmentHandler` path `git commit message amend` uses. No prompt —
151    /// safe for CI/hook use, since only a resolvable
152    /// `unknown-scope`/`missing-scope` issue is ever touched.
153    fn apply_fixes(&self, repo_root: &Path, report: &CheckReport) -> Result<()> {
154        use crate::data::amendments::{Amendment, AmendmentFile};
155        use crate::git::AmendmentHandler;
156
157        let amendments: Vec<Amendment> = report
158            .commits
159            .iter()
160            .filter_map(|c| {
161                let suggestion = c.suggestion.as_ref()?;
162                Some(Amendment::new(c.hash.clone(), suggestion.message.clone()))
163            })
164            .collect();
165
166        if amendments.is_empty() {
167            println!("✨ No deterministic scope fixes to apply");
168            return Ok(());
169        }
170
171        let count = amendments.len();
172        let amendment_file = AmendmentFile { amendments };
173        let handler = AmendmentHandler::new(repo_root)
174            .context("Failed to initialize amendment handler")?
175            .with_allow_pushed(self.allow_pushed);
176        handler
177            .apply_amendment_file(&amendment_file)
178            .context("Failed to apply deterministic scope fixes")?;
179
180        println!("✅ Fixed {count} commit message(s)");
181        Ok(())
182    }
183
184    fn resolve_range(&self, repo_root: &Path) -> Result<String> {
185        if let Some(range) = &self.commit_range {
186            return Ok(range.clone());
187        }
188        let repo = crate::git::GitRepository::open_at(repo_root)
189            .context("Failed to open git repository at the given path")?;
190        super::default_commit_range(&repo)
191    }
192
193    fn show_config_status(
194        &self,
195        _repo_root: &Path,
196        context_dir: &Path,
197        valid_scopes: &[crate::data::context::ScopeDefinition],
198        rules: &crate::data::context::CommitRules,
199    ) {
200        use crate::claude::context::{config_source_label, ConfigSourceLabel};
201
202        println!("📋 Lint configuration:");
203        println!("   📂 Config dir: {}", context_dir.display());
204
205        let scopes_source = if valid_scopes.is_empty() {
206            "⚪ None found (any scope accepted)".to_string()
207        } else {
208            match config_source_label(context_dir, "scopes.yaml") {
209                ConfigSourceLabel::NotFound => {
210                    format!(
211                        "✅ (ecosystem defaults only) ({} scopes)",
212                        valid_scopes.len()
213                    )
214                }
215                label => format!("✅ {label} ({} scopes)", valid_scopes.len()),
216            }
217        };
218        println!("   🎯 Valid scopes: {scopes_source}");
219
220        let rules_source = match config_source_label(context_dir, "commit-rules.yaml") {
221            ConfigSourceLabel::NotFound => "⚪ Using built-in defaults".to_string(),
222            label => format!("✅ {label}"),
223        };
224        println!("   📏 Commit rules: {rules_source}");
225        println!(
226            "      subject_max_len={}, require_scope={}, types={}",
227            rules.subject_max_len,
228            rules.require_scope,
229            rules.types.len()
230        );
231        println!();
232    }
233
234    /// Outputs the lint report in the specified format.
235    fn output_report(&self, report: &CheckReport, format: OutputFormat) -> Result<()> {
236        match format {
237            OutputFormat::Text => self.output_text_report(report),
238            OutputFormat::Json => {
239                let json = serde_json::to_string_pretty(report)
240                    .context("Failed to serialize report to JSON")?;
241                println!("{json}");
242                Ok(())
243            }
244            OutputFormat::Yaml => {
245                let yaml =
246                    crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
247                println!("{yaml}");
248                Ok(())
249            }
250        }
251    }
252
253    /// Outputs the text format report.
254    fn output_text_report(&self, report: &CheckReport) -> Result<()> {
255        use crate::data::check::IssueSeverity;
256
257        println!();
258
259        for result in &report.commits {
260            if result.passes && !self.show_passing {
261                continue;
262            }
263
264            if self.quiet && !has_errors_or_warnings(&result.issues) {
265                continue;
266            }
267
268            let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
269            let short_hash = super::formatting::truncate_hash(&result.hash);
270            println!("{icon} {short_hash} - \"{}\"", result.message);
271
272            for issue in &result.issues {
273                if self.quiet && issue.severity == IssueSeverity::Info {
274                    continue;
275                }
276                let severity_str = super::formatting::format_severity_label(issue.severity);
277                println!(
278                    "   {} [{}] {}",
279                    severity_str, issue.section, issue.explanation
280                );
281            }
282
283            if !self.quiet {
284                if let Some(suggestion) = &result.suggestion {
285                    println!();
286                    print!(
287                        "{}",
288                        super::formatting::format_suggestion_text(suggestion, self.verbose)
289                    );
290                }
291            }
292
293            println!();
294        }
295
296        println!(
297            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
298             Summary: {} commits linted\n\
299             \x20 {} errors, {} warnings\n\
300             \x20 {} passed, {} with issues",
301            report.summary.total_commits,
302            report.summary.error_count,
303            report.summary.warning_count,
304            report.summary.passing_commits,
305            report.summary.failing_commits,
306        );
307
308        Ok(())
309    }
310}
311
312/// Returns whether any issues have Error or Warning severity.
313fn has_errors_or_warnings(issues: &[crate::data::check::CommitIssue]) -> bool {
314    use crate::data::check::IssueSeverity;
315    issues
316        .iter()
317        .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning))
318}
319
320/// Builds a single-commit [`CheckReport`] by linting `message` directly —
321/// no git, no repository. Used by `--stdin` and the MCP `message` input.
322fn lint_report_for_message(
323    message: &str,
324    rules: &crate::data::context::CommitRules,
325    valid_scopes: &[crate::data::context::ScopeDefinition],
326) -> CheckReport {
327    let issues = crate::git::lint_message(message, rules, valid_scopes);
328    let passes = crate::git::lint_passes(&issues);
329    let result = CommitCheckResult {
330        hash: "-".to_string(),
331        message: message.lines().next().unwrap_or("").to_string(),
332        issues,
333        suggestion: None,
334        passes,
335        summary: None,
336    };
337    CheckReport::new(vec![result])
338}
339
340/// Builds a [`CheckReport`] by linting every non-merge commit in `range`.
341/// Merge commits are already excluded by
342/// [`crate::git::GitRepository::get_commits_in_range`] — no additional
343/// filtering needed here. An empty range yields an empty (clean) report.
344///
345/// When `compute_suggestions` is set, each commit with an
346/// `unknown-scope`/`missing-scope` issue gets a deterministic
347/// [`crate::git::suggest_scope_fix`] suggestion, using its already-computed
348/// `file_changes` (populated by `get_commits_in_range`, otherwise unused by
349/// lint).
350fn lint_report_for_range(
351    repo_root: &Path,
352    range: &str,
353    rules: &crate::data::context::CommitRules,
354    valid_scopes: &[crate::data::context::ScopeDefinition],
355    compute_suggestions: bool,
356) -> Result<CheckReport> {
357    let repo = crate::git::GitRepository::open_at(repo_root)
358        .context("Failed to open git repository at the given path")?;
359    let commits = repo.get_commits_in_range(range)?;
360
361    let results = commits
362        .iter()
363        .map(|commit| {
364            let issues = crate::git::lint_message(&commit.original_message, rules, valid_scopes);
365            let passes = crate::git::lint_passes(&issues);
366            let suggestion = if compute_suggestions {
367                let files: Vec<&str> = commit
368                    .analysis
369                    .file_changes
370                    .file_list
371                    .iter()
372                    .map(|f| f.file.as_str())
373                    .collect();
374                crate::git::suggest_scope_fix(
375                    &commit.original_message,
376                    &files,
377                    valid_scopes,
378                    &issues,
379                )
380            } else {
381                None
382            };
383            CommitCheckResult {
384                hash: commit.hash.clone(),
385                message: commit
386                    .original_message
387                    .lines()
388                    .next()
389                    .unwrap_or("")
390                    .to_string(),
391                issues,
392                suggestion,
393                passes,
394                summary: None,
395            }
396        })
397        .collect();
398
399    Ok(CheckReport::new(results))
400}
401
402/// Structured output from [`run_lint`] for programmatic consumers (MCP).
403#[derive(Debug, Clone)]
404pub struct LintOutcome {
405    /// YAML serialisation of the full [`CheckReport`].
406    pub report_yaml: String,
407    /// `true` when any commit has an error-severity issue.
408    pub has_errors: bool,
409    /// `true` when any commit has a warning-severity issue.
410    pub has_warnings: bool,
411    /// Total commits linted.
412    pub total_commits: usize,
413    /// Strict mode setting that produced `exit_code`.
414    pub strict: bool,
415    /// Exit code the CLI would use, honouring `strict`.
416    pub exit_code: i32,
417}
418
419/// What to lint.
420///
421/// Either a commit range (resolved the same way as the CLI's positional
422/// argument, defaulting to commits ahead of the base branch when `None`) or
423/// a single literal message (the `--stdin` equivalent).
424pub enum LintInput {
425    /// Lint every non-merge commit in this range.
426    Range(Option<String>),
427    /// Lint this message directly, bypassing git entirely.
428    Message(String),
429}
430
431/// Non-interactive core for `omni-dev git commit message lint`.
432///
433/// Shared by the CLI and the MCP `git_lint_commits` tool. No AI client
434/// involved — deterministic and synchronous under the hood, `async` only
435/// for call-site parity with [`super::run_check`].
436///
437/// `repo_path` selects the repository (`None` defaults to the current
438/// working directory); `context_dir` overrides the `.omni-dev/` resolution
439/// chain for both `scopes.yaml` and `commit-rules.yaml`. `suggest` populates
440/// a deterministic scope-fix suggestion per commit (see
441/// [`crate::git::suggest_scope_fix`]) — report-only, mirroring the CLI's
442/// `--suggest`; it is an error to combine with [`LintInput::Message`], which
443/// has no changed-files list to resolve a scope from. There is no `fix`
444/// equivalent here: applying amendments stays exclusive to the CLI's
445/// `--fix` and the `git_amend_commits` MCP tool.
446pub async fn run_lint(
447    input: LintInput,
448    repo_path: Option<&Path>,
449    context_dir: Option<&Path>,
450    strict: bool,
451    suggest: bool,
452) -> Result<LintOutcome> {
453    if suggest && matches!(input, LintInput::Message(_)) {
454        anyhow::bail!(
455            "suggest requires a commit range — a literal message has no changed-files list to \
456             resolve a scope from"
457        );
458    }
459
460    let repo_root = match repo_path {
461        Some(p) => p.to_path_buf(),
462        None => std::env::current_dir().context("Failed to determine current directory")?,
463    };
464    let repo_root = repo_root.as_path();
465
466    let ctx_dir = crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
467    let valid_scopes = crate::claude::context::load_project_scopes(&ctx_dir, repo_root);
468    let rules = crate::claude::context::load_commit_rules(&ctx_dir);
469
470    let report = match input {
471        LintInput::Message(message) => lint_report_for_message(&message, &rules, &valid_scopes),
472        LintInput::Range(range) => {
473            let range = if let Some(r) = range {
474                r
475            } else {
476                let repo = crate::git::GitRepository::open_at(repo_root)
477                    .context("Failed to open git repository at the given path")?;
478                super::default_commit_range(&repo)?
479            };
480            lint_report_for_range(repo_root, &range, &rules, &valid_scopes, suggest)?
481        }
482    };
483
484    let report_yaml = crate::data::to_yaml(&report).context("Failed to serialise CheckReport")?;
485    let has_errors = report.has_errors();
486    let has_warnings = report.has_warnings();
487    let exit_code = report.exit_code(strict);
488    let total_commits = report.commits.len();
489
490    Ok(LintOutcome {
491        report_yaml,
492        has_errors,
493        has_warnings,
494        total_commits,
495        strict,
496        exit_code,
497    })
498}
499
500#[cfg(test)]
501#[allow(clippy::unwrap_used, clippy::expect_used)]
502mod tests {
503    use super::*;
504
505    fn init_test_repo() -> tempfile::TempDir {
506        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
507        std::fs::create_dir_all(&tmp_root).unwrap();
508        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
509        for args in [
510            vec!["init"],
511            // Persistent *local* config (not just the `-c` overrides on each
512            // call below) — `AmendmentHandler`'s own `git commit --amend`/
513            // `git rebase` subprocesses (exercised by the `apply_fixes_*`
514            // tests) run without those overrides, so they need a real
515            // identity in `.git/config` rather than depending on the
516            // process's ambient global git config, which isn't set on CI
517            // runners (mirrors `twiddle.rs`'s `init_test_repo_with_commit`,
518            // issue #950).
519            vec!["config", "user.email", "test@example.com"],
520            vec!["config", "user.name", "Test"],
521            vec!["config", "commit.gpgsign", "false"],
522            vec!["checkout", "-b", "main"],
523            vec!["commit", "--allow-empty", "-m", "feat(cli): first commit"],
524        ] {
525            let output = std::process::Command::new("git")
526                .current_dir(temp_dir.path())
527                .args([
528                    "-c",
529                    "user.email=test@example.com",
530                    "-c",
531                    "user.name=Test",
532                    "-c",
533                    "commit.gpgsign=false",
534                ])
535                .args(&args)
536                .output()
537                .unwrap();
538            assert!(output.status.success(), "git {args:?} failed");
539        }
540        temp_dir
541    }
542
543    fn commit(dir: &Path, message: &str) {
544        let output = std::process::Command::new("git")
545            .current_dir(dir)
546            .args([
547                "-c",
548                "user.email=test@example.com",
549                "-c",
550                "user.name=Test",
551                "-c",
552                "commit.gpgsign=false",
553                "commit",
554                "--allow-empty",
555                "-m",
556                message,
557            ])
558            .output()
559            .unwrap();
560        assert!(output.status.success(), "commit failed: {message}");
561    }
562
563    fn merge_dummy_branch(dir: &Path) {
564        let sh = |args: &[&str]| {
565            let output = std::process::Command::new("git")
566                .current_dir(dir)
567                .args([
568                    "-c",
569                    "user.email=test@example.com",
570                    "-c",
571                    "user.name=Test",
572                    "-c",
573                    "commit.gpgsign=false",
574                ])
575                .args(args)
576                .output()
577                .unwrap();
578            assert!(output.status.success(), "git {args:?} failed");
579        };
580        sh(&["checkout", "-b", "side"]);
581        sh(&["commit", "--allow-empty", "-m", "feat(cli): side change"]);
582        sh(&["checkout", "main"]);
583        sh(&["commit", "--allow-empty", "-m", "feat(cli): main change"]);
584        sh(&["merge", "side", "--no-ff", "-m", "Merge branch 'side'"]);
585    }
586
587    /// Writes `path` and commits it for real (not `--allow-empty`), so the
588    /// commit has a genuine `file_changes` list for `--suggest`/`--fix` to
589    /// resolve a scope from.
590    fn commit_file(dir: &Path, path: &str, contents: &str, message: &str) {
591        std::fs::write(dir.join(path), contents).unwrap();
592        let add = std::process::Command::new("git")
593            .current_dir(dir)
594            .args(["add", path])
595            .output()
596            .unwrap();
597        assert!(add.status.success(), "git add {path} failed");
598        commit(dir, message);
599    }
600
601    /// Returns HEAD's commit id, in its own function (rather than an inline
602    /// block) so `git2::Reference`/`Commit`'s borrow of `repo` doesn't get
603    /// tangled up with the enclosing test's other locals.
604    fn head_oid(repo_path: &Path) -> git2::Oid {
605        let repo = git2::Repository::open(repo_path).unwrap();
606        let head = repo.head().unwrap();
607        let commit = head.peel_to_commit().unwrap();
608        commit.id()
609    }
610
611    /// Writes a `cargo` scope (matching `Cargo.toml`/`Cargo.lock`) to
612    /// `context_dir/scopes.yaml` — the Dependabot-style fixture from #1564.
613    fn write_cargo_scope(context_dir: &Path) {
614        std::fs::create_dir_all(context_dir).unwrap();
615        std::fs::write(
616            context_dir.join("scopes.yaml"),
617            "scopes:\n  - name: cargo\n    description: Cargo files\n    examples: []\n    file_patterns:\n      - Cargo.toml\n      - Cargo.lock\n",
618        )
619        .unwrap();
620    }
621
622    #[tokio::test]
623    async fn run_lint_message_flags_known_issues() {
624        let outcome = run_lint(
625            LintInput::Message("feature(bogus): Bad Message.".to_string()),
626            None,
627            None,
628            false,
629            false,
630        )
631        .await
632        .unwrap();
633        assert!(outcome.has_errors);
634        assert_eq!(outcome.exit_code, 1);
635        assert_eq!(outcome.total_commits, 1);
636        assert!(outcome.report_yaml.contains("commits:"));
637    }
638
639    #[tokio::test]
640    async fn run_lint_message_clean_passes() {
641        let outcome = run_lint(
642            LintInput::Message("feat(cli): add thing".to_string()),
643            None,
644            None,
645            false,
646            false,
647        )
648        .await
649        .unwrap();
650        assert!(!outcome.has_errors);
651        assert_eq!(outcome.exit_code, 0);
652    }
653
654    #[tokio::test]
655    async fn run_lint_range_merge_commit_excluded() {
656        let temp_dir = init_test_repo();
657        merge_dummy_branch(temp_dir.path());
658
659        let outcome = run_lint(
660            LintInput::Range(Some("HEAD~2..HEAD".to_string())),
661            Some(temp_dir.path()),
662            None,
663            false,
664            false,
665        )
666        .await
667        .unwrap();
668
669        // HEAD~2..HEAD spans the merge commit plus "main change"; the merge
670        // commit itself must not appear.
671        assert!(!outcome.report_yaml.contains("Merge branch"));
672    }
673
674    #[tokio::test]
675    async fn run_lint_range_empty_is_clean_not_an_error() {
676        let temp_dir = init_test_repo();
677        let outcome = run_lint(
678            LintInput::Range(Some("HEAD..HEAD".to_string())),
679            Some(temp_dir.path()),
680            None,
681            false,
682            false,
683        )
684        .await
685        .unwrap();
686        assert_eq!(outcome.total_commits, 0);
687        assert!(!outcome.has_errors);
688        assert_eq!(outcome.exit_code, 0);
689    }
690
691    #[tokio::test]
692    async fn run_lint_range_strict_promotes_warnings() {
693        let temp_dir = init_test_repo();
694        commit(
695            temp_dir.path(),
696            "feat(cli): add thing\n\nCo-Authored-By: Bot <bot@example.com>",
697        );
698        let outcome = run_lint(
699            LintInput::Range(Some("HEAD~1..HEAD".to_string())),
700            Some(temp_dir.path()),
701            None,
702            true,
703            false,
704        )
705        .await
706        .unwrap();
707        assert!(!outcome.has_errors);
708        assert!(outcome.has_warnings);
709        assert_eq!(outcome.exit_code, 2);
710    }
711
712    #[tokio::test]
713    async fn run_lint_range_and_message_agree_on_same_content() {
714        let temp_dir = init_test_repo();
715        commit(temp_dir.path(), "feature(bogus): Bad Message.");
716
717        let range_outcome = run_lint(
718            LintInput::Range(Some("HEAD~1..HEAD".to_string())),
719            Some(temp_dir.path()),
720            None,
721            false,
722            false,
723        )
724        .await
725        .unwrap();
726        let message_outcome = run_lint(
727            LintInput::Message("feature(bogus): Bad Message.".to_string()),
728            None,
729            None,
730            false,
731            false,
732        )
733        .await
734        .unwrap();
735
736        assert_eq!(range_outcome.has_errors, message_outcome.has_errors);
737        assert_eq!(range_outcome.exit_code, message_outcome.exit_code);
738    }
739
740    #[test]
741    fn cli_execute_json_output_matches_check_report_shape() {
742        let temp_dir = init_test_repo();
743        commit(temp_dir.path(), "feat(cli): second commit");
744        let cmd = LintCommand {
745            commit_range: Some("HEAD~1..HEAD".to_string()),
746            context_dir: None,
747            guidelines: None,
748            output: OutputFormat::Json,
749            strict: false,
750            quiet: true,
751            verbose: false,
752            show_passing: true,
753            stdin: false,
754            suggest: false,
755            fix: false,
756            allow_pushed: false,
757        };
758        let rt = tokio::runtime::Runtime::new().unwrap();
759        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
760        assert!(result.is_ok());
761    }
762
763    #[test]
764    fn cli_execute_yaml_output_matches_check_report_shape() {
765        let temp_dir = init_test_repo();
766        commit(temp_dir.path(), "feat(cli): second commit");
767        let cmd = LintCommand {
768            commit_range: Some("HEAD~1..HEAD".to_string()),
769            context_dir: None,
770            guidelines: None,
771            output: OutputFormat::Yaml,
772            strict: false,
773            quiet: true,
774            verbose: false,
775            show_passing: true,
776            stdin: false,
777            suggest: false,
778            fix: false,
779            allow_pushed: false,
780        };
781        let rt = tokio::runtime::Runtime::new().unwrap();
782        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
783        assert!(result.is_ok());
784    }
785
786    /// `commit_range: None` drives `resolve_range` through its
787    /// `default_commit_range` fallback rather than the literal-range
788    /// shortcut every other test in this module uses.
789    #[test]
790    fn cli_execute_range_none_uses_default_commit_range() {
791        let temp_dir = init_test_repo();
792        let cmd = LintCommand {
793            commit_range: None,
794            context_dir: None,
795            guidelines: None,
796            output: OutputFormat::Json,
797            strict: false,
798            quiet: true,
799            verbose: false,
800            show_passing: true,
801            stdin: false,
802            suggest: false,
803            fix: false,
804            allow_pushed: false,
805        };
806        let rt = tokio::runtime::Runtime::new().unwrap();
807        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
808        assert!(result.is_ok(), "expected clean exit, got: {result:?}");
809    }
810
811    /// `LintInput::Range(None)` is `run_lint`'s own default-range fallback —
812    /// a separate code path from `LintCommand::resolve_range` above, since
813    /// `run_lint` is also reachable from the MCP tool with no CLI in front
814    /// of it.
815    #[tokio::test]
816    async fn run_lint_range_none_uses_default_commit_range() {
817        let temp_dir = init_test_repo();
818        let outcome = run_lint(
819            LintInput::Range(None),
820            Some(temp_dir.path()),
821            None,
822            false,
823            false,
824        )
825        .await
826        .unwrap();
827        assert_eq!(outcome.total_commits, 0);
828    }
829
830    #[test]
831    fn cli_execute_verbose_config_status_empty_scopes_rules_not_found() {
832        let temp_dir = init_test_repo();
833        // An explicit (nonexistent) context_dir bypasses walk-up discovery —
834        // without it, resolution would walk up from `temp_dir` (created
835        // under this crate's own `tmp/`) and find *this repo's* real
836        // `.omni-dev/scopes.yaml`, defeating the "nothing configured" case
837        // this test means to exercise.
838        let context_dir = temp_dir.path().join(".omni-dev");
839        let cmd = LintCommand {
840            commit_range: Some("HEAD..HEAD".to_string()),
841            context_dir: Some(context_dir),
842            guidelines: None,
843            output: OutputFormat::Text,
844            strict: false,
845            quiet: false,
846            verbose: true,
847            show_passing: false,
848            stdin: false,
849            suggest: false,
850            fix: false,
851            allow_pushed: false,
852        };
853        let rt = tokio::runtime::Runtime::new().unwrap();
854        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
855        assert!(result.is_ok());
856    }
857
858    #[test]
859    fn cli_execute_verbose_config_status_scopes_and_rules_found() {
860        let temp_dir = init_test_repo();
861        let context_dir = temp_dir.path().join(".omni-dev");
862        std::fs::create_dir_all(&context_dir).unwrap();
863        std::fs::write(
864            context_dir.join("scopes.yaml"),
865            "scopes:\n  - name: custom\n    description: Custom scope\n    examples: []\n    file_patterns: []\n",
866        )
867        .unwrap();
868        std::fs::write(
869            context_dir.join("commit-rules.yaml"),
870            "subject_max_len: 72\ntypes:\n  - feat\nrequire_scope: false\nforbidden_footers: []\n",
871        )
872        .unwrap();
873
874        let cmd = LintCommand {
875            commit_range: Some("HEAD..HEAD".to_string()),
876            context_dir: Some(context_dir),
877            guidelines: None,
878            output: OutputFormat::Text,
879            strict: false,
880            quiet: false,
881            verbose: true,
882            show_passing: false,
883            stdin: false,
884            suggest: false,
885            fix: false,
886            allow_pushed: false,
887        };
888        let rt = tokio::runtime::Runtime::new().unwrap();
889        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
890        assert!(result.is_ok());
891    }
892
893    #[test]
894    fn cli_execute_verbose_config_status_ecosystem_scopes_no_file() {
895        let temp_dir = init_test_repo();
896        std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\n").unwrap();
897        // See the comment in the "empty scopes" test above: an explicit
898        // (nonexistent) context_dir bypasses walk-up discovery, so the only
899        // non-empty scopes are the `Cargo.toml`-derived ecosystem defaults
900        // this test targets.
901        let context_dir = temp_dir.path().join(".omni-dev");
902
903        let cmd = LintCommand {
904            commit_range: Some("HEAD..HEAD".to_string()),
905            context_dir: Some(context_dir),
906            guidelines: None,
907            output: OutputFormat::Text,
908            strict: false,
909            quiet: false,
910            verbose: true,
911            show_passing: false,
912            stdin: false,
913            suggest: false,
914            fix: false,
915            allow_pushed: false,
916        };
917        let rt = tokio::runtime::Runtime::new().unwrap();
918        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
919        assert!(result.is_ok());
920    }
921
922    /// `passes` (and so the `show_passing: false` default's skip-continue at
923    /// the top of the per-commit loop) reflects only `Error`-severity
924    /// issues, matching `exit_code`'s own error-only gate — a
925    /// `Warning`-only commit still counts as passing and is hidden here
926    /// just like a clean one. `output_text_report`'s per-issue print lines
927    /// are covered separately below (`quiet_mode_filters_...`), since a
928    /// commit that fails the `show_passing` check can't reach them without
929    /// an `Error`-severity issue, which would drive `execute()`'s
930    /// `std::process::exit` and abort this whole test binary.
931    #[test]
932    fn output_text_report_show_passing_false_hides_warning_only_commits() {
933        let temp_dir = init_test_repo();
934        commit(temp_dir.path(), "feat(cli): clean second commit");
935        commit(
936            temp_dir.path(),
937            "feat(cli): add thing\n\nCo-Authored-By: Bot <bot@example.com>",
938        );
939        let cmd = LintCommand {
940            commit_range: Some("HEAD~2..HEAD".to_string()),
941            context_dir: None,
942            guidelines: None,
943            output: OutputFormat::Text,
944            strict: false,
945            quiet: false,
946            verbose: false,
947            show_passing: false,
948            stdin: false,
949            suggest: false,
950            fix: false,
951            allow_pushed: false,
952        };
953        let rt = tokio::runtime::Runtime::new().unwrap();
954        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
955        assert!(result.is_ok(), "expected clean exit, got: {result:?}");
956    }
957
958    /// `quiet` filters both a clean commit (`show_passing: true` keeps it
959    /// past the first check, but it has no errors/warnings to show) and,
960    /// within a surviving commit's own issue list, its `Info`-severity
961    /// entries — two distinct skip branches in `output_text_report`.
962    #[test]
963    fn output_text_report_quiet_mode_filters_clean_and_info_issues() {
964        let temp_dir = init_test_repo();
965        commit(temp_dir.path(), "feat(cli): clean thing");
966        commit(
967            temp_dir.path(),
968            "feat(cli): Add thing.\n\nCo-Authored-By: Bot <bot@example.com>",
969        );
970        let cmd = LintCommand {
971            commit_range: Some("HEAD~2..HEAD".to_string()),
972            context_dir: None,
973            guidelines: None,
974            output: OutputFormat::Text,
975            strict: false,
976            quiet: true,
977            verbose: false,
978            show_passing: true,
979            stdin: false,
980            suggest: false,
981            fix: false,
982            allow_pushed: false,
983        };
984        let rt = tokio::runtime::Runtime::new().unwrap();
985        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
986        assert!(result.is_ok(), "expected clean exit, got: {result:?}");
987    }
988
989    // ── --suggest / --fix (#1564) ────────────────────────────────────
990
991    #[tokio::test]
992    async fn run_lint_range_with_suggest_resolves_dependabot_style_scope() {
993        let temp_dir = init_test_repo();
994        let context_dir = temp_dir.path().join(".omni-dev");
995        write_cargo_scope(&context_dir);
996        commit_file(
997            temp_dir.path(),
998            "Cargo.toml",
999            "[package]\n",
1000            "chore(deps): bump foo",
1001        );
1002
1003        let outcome = run_lint(
1004            LintInput::Range(Some("HEAD~1..HEAD".to_string())),
1005            Some(temp_dir.path()),
1006            Some(&context_dir),
1007            false,
1008            true,
1009        )
1010        .await
1011        .unwrap();
1012
1013        assert!(outcome.has_errors, "unknown-scope should still be flagged");
1014        assert!(
1015            outcome.report_yaml.contains("chore(cargo): bump foo"),
1016            "expected a deterministic suggestion in report_yaml: {}",
1017            outcome.report_yaml
1018        );
1019    }
1020
1021    #[tokio::test]
1022    async fn run_lint_range_without_suggest_leaves_suggestion_none() {
1023        let temp_dir = init_test_repo();
1024        let context_dir = temp_dir.path().join(".omni-dev");
1025        write_cargo_scope(&context_dir);
1026        commit_file(
1027            temp_dir.path(),
1028            "Cargo.toml",
1029            "[package]\n",
1030            "chore(deps): bump foo",
1031        );
1032
1033        let outcome = run_lint(
1034            LintInput::Range(Some("HEAD~1..HEAD".to_string())),
1035            Some(temp_dir.path()),
1036            Some(&context_dir),
1037            false,
1038            false,
1039        )
1040        .await
1041        .unwrap();
1042
1043        assert!(
1044            !outcome.report_yaml.contains("suggestion:"),
1045            "no suggestion should be present without --suggest: {}",
1046            outcome.report_yaml
1047        );
1048    }
1049
1050    #[tokio::test]
1051    async fn run_lint_message_with_suggest_errors() {
1052        let err = run_lint(
1053            LintInput::Message("feat(cli): add thing".to_string()),
1054            None,
1055            None,
1056            false,
1057            true,
1058        )
1059        .await
1060        .unwrap_err();
1061        let msg = format!("{err:#}");
1062        assert!(
1063            msg.to_lowercase()
1064                .contains("suggest requires a commit range"),
1065            "expected a clear validation error, got: {msg}"
1066        );
1067    }
1068
1069    #[tokio::test]
1070    async fn cli_execute_stdin_with_suggest_errors() {
1071        let cmd = LintCommand {
1072            commit_range: None,
1073            context_dir: None,
1074            guidelines: None,
1075            output: OutputFormat::Text,
1076            strict: false,
1077            quiet: false,
1078            verbose: false,
1079            show_passing: false,
1080            stdin: true,
1081            suggest: true,
1082            fix: false,
1083            allow_pushed: false,
1084        };
1085        let err = cmd.execute(None).await.unwrap_err();
1086        let msg = format!("{err:#}");
1087        assert!(
1088            msg.contains("--suggest/--fix require a commit range"),
1089            "expected a clear validation error, got: {msg}"
1090        );
1091    }
1092
1093    #[tokio::test]
1094    async fn cli_execute_stdin_with_fix_errors() {
1095        let cmd = LintCommand {
1096            commit_range: None,
1097            context_dir: None,
1098            guidelines: None,
1099            output: OutputFormat::Text,
1100            strict: false,
1101            quiet: false,
1102            verbose: false,
1103            show_passing: false,
1104            stdin: true,
1105            suggest: false,
1106            fix: true,
1107            allow_pushed: false,
1108        };
1109        let err = cmd.execute(None).await.unwrap_err();
1110        let msg = format!("{err:#}");
1111        assert!(msg.contains("--suggest/--fix require a commit range"));
1112    }
1113
1114    /// Exercises `apply_fixes` directly rather than the full `execute()`
1115    /// path — the fixture commit has an `unknown-scope` error, and
1116    /// `execute()`'s exit-code handling would call `std::process::exit` and
1117    /// abort this whole test binary (see the `show_passing` test above for
1118    /// the same caveat).
1119    #[test]
1120    fn apply_fixes_amends_commit_via_amendment_handler() {
1121        let temp_dir = init_test_repo();
1122        // The context dir must live OUTSIDE the repo working tree — inside
1123        // it, an untracked `.omni-dev/scopes.yaml` would make
1124        // `AmendmentHandler`'s working-directory-clean safety check refuse
1125        // to amend anything.
1126        let context_tmp =
1127            tempfile::tempdir_in(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp"))
1128                .unwrap();
1129        let context_dir = context_tmp.path().join(".omni-dev");
1130        write_cargo_scope(&context_dir);
1131        commit_file(
1132            temp_dir.path(),
1133            "Cargo.toml",
1134            "[package]\n",
1135            "chore(deps): bump foo",
1136        );
1137
1138        let ctx_dir =
1139            crate::claude::context::resolve_context_dir_at(Some(&context_dir), temp_dir.path());
1140        let valid_scopes = crate::claude::context::load_project_scopes(&ctx_dir, temp_dir.path());
1141        let rules = crate::claude::context::load_commit_rules(&ctx_dir);
1142        let report =
1143            lint_report_for_range(temp_dir.path(), "HEAD~1..HEAD", &rules, &valid_scopes, true)
1144                .unwrap();
1145
1146        let cmd = LintCommand {
1147            commit_range: None,
1148            context_dir: None,
1149            guidelines: None,
1150            output: OutputFormat::Json,
1151            strict: false,
1152            quiet: true,
1153            verbose: false,
1154            show_passing: true,
1155            stdin: false,
1156            suggest: false,
1157            fix: true,
1158            allow_pushed: false,
1159        };
1160        cmd.apply_fixes(temp_dir.path(), &report).unwrap();
1161
1162        let repo = git2::Repository::open(temp_dir.path()).unwrap();
1163        let head = repo.head().unwrap().peel_to_commit().unwrap();
1164        let msg = head.message().unwrap().to_string();
1165        assert!(
1166            msg.starts_with("chore(cargo): bump foo"),
1167            "expected the amended message at HEAD, got: {msg:?}"
1168        );
1169    }
1170
1171    #[test]
1172    fn apply_fixes_with_no_suggestions_is_a_clean_noop() {
1173        let temp_dir = init_test_repo();
1174        let head_before = head_oid(temp_dir.path());
1175
1176        let report = CheckReport::new(vec![]);
1177        let cmd = LintCommand {
1178            commit_range: None,
1179            context_dir: None,
1180            guidelines: None,
1181            output: OutputFormat::Json,
1182            strict: false,
1183            quiet: true,
1184            verbose: false,
1185            show_passing: true,
1186            stdin: false,
1187            suggest: false,
1188            fix: true,
1189            allow_pushed: false,
1190        };
1191        cmd.apply_fixes(temp_dir.path(), &report).unwrap();
1192
1193        let head_after = head_oid(temp_dir.path());
1194        assert_eq!(head_before, head_after, "HEAD must be unchanged");
1195    }
1196
1197    /// Drives `output_text_report` directly (bypassing `execute()`, which
1198    /// would `std::process::exit` on this issue's Error severity — see the
1199    /// `apply_fixes_amends_commit_via_amendment_handler` caveat above) to
1200    /// cover the suggestion-printing block reached when a commit has a
1201    /// suggestion and `--quiet` is off.
1202    #[test]
1203    fn output_text_report_prints_suggestion_when_present_and_not_quiet() {
1204        let cmd = LintCommand {
1205            commit_range: None,
1206            context_dir: None,
1207            guidelines: None,
1208            output: OutputFormat::Text,
1209            strict: false,
1210            quiet: false,
1211            verbose: true,
1212            show_passing: true,
1213            stdin: false,
1214            suggest: true,
1215            fix: false,
1216            allow_pushed: false,
1217        };
1218        let report = CheckReport::new(vec![CommitCheckResult {
1219            hash: "abcdef1234567890".to_string(),
1220            message: "chore(deps): bump foo".to_string(),
1221            issues: vec![crate::data::check::CommitIssue {
1222                severity: crate::data::check::IssueSeverity::Error,
1223                section: "Scopes".to_string(),
1224                rule: "unknown-scope".to_string(),
1225                explanation: "Scope(s) not in the valid scopes list: deps".to_string(),
1226            }],
1227            suggestion: Some(crate::data::check::CommitSuggestion {
1228                message: "chore(cargo): bump foo".to_string(),
1229                explanation: "Deterministically resolved from the commit's changed files."
1230                    .to_string(),
1231            }),
1232            passes: false,
1233            summary: None,
1234        }]);
1235        assert!(cmd.output_text_report(&report).is_ok());
1236    }
1237}