1use 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#[derive(Parser)]
17pub struct LintCommand {
18 #[arg(value_name = "COMMIT_RANGE")]
22 pub commit_range: Option<String>,
23
24 #[arg(long)]
26 pub context_dir: Option<std::path::PathBuf>,
27
28 #[arg(long)]
32 pub guidelines: Option<std::path::PathBuf>,
33
34 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
36 pub output: OutputFormat,
37
38 #[arg(long)]
40 pub strict: bool,
41
42 #[arg(long)]
44 pub quiet: bool,
45
46 #[arg(long)]
48 pub verbose: bool,
49
50 #[arg(long)]
52 pub show_passing: bool,
53
54 #[arg(long)]
57 pub stdin: bool,
58
59 #[arg(long)]
66 pub suggest: bool,
67
68 #[arg(long)]
75 pub fix: bool,
76
77 #[arg(long)]
82 pub allow_pushed: bool,
83}
84
85impl LintCommand {
86 pub 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 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 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 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 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
312fn 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
320fn 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
340fn 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#[derive(Debug, Clone)]
404pub struct LintOutcome {
405 pub report_yaml: String,
407 pub has_errors: bool,
409 pub has_warnings: bool,
411 pub total_commits: usize,
413 pub strict: bool,
415 pub exit_code: i32,
417}
418
419pub enum LintInput {
425 Range(Option<String>),
427 Message(String),
429}
430
431pub 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 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 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 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 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 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 result = cmd.execute(Some(temp_dir.path()));
759 assert!(result.is_ok());
760 }
761
762 #[test]
763 fn cli_execute_yaml_output_matches_check_report_shape() {
764 let temp_dir = init_test_repo();
765 commit(temp_dir.path(), "feat(cli): second commit");
766 let cmd = LintCommand {
767 commit_range: Some("HEAD~1..HEAD".to_string()),
768 context_dir: None,
769 guidelines: None,
770 output: OutputFormat::Yaml,
771 strict: false,
772 quiet: true,
773 verbose: false,
774 show_passing: true,
775 stdin: false,
776 suggest: false,
777 fix: false,
778 allow_pushed: false,
779 };
780 let result = cmd.execute(Some(temp_dir.path()));
781 assert!(result.is_ok());
782 }
783
784 #[test]
788 fn cli_execute_range_none_uses_default_commit_range() {
789 let temp_dir = init_test_repo();
790 let cmd = LintCommand {
791 commit_range: None,
792 context_dir: None,
793 guidelines: None,
794 output: OutputFormat::Json,
795 strict: false,
796 quiet: true,
797 verbose: false,
798 show_passing: true,
799 stdin: false,
800 suggest: false,
801 fix: false,
802 allow_pushed: false,
803 };
804 let result = cmd.execute(Some(temp_dir.path()));
805 assert!(result.is_ok(), "expected clean exit, got: {result:?}");
806 }
807
808 #[tokio::test]
813 async fn run_lint_range_none_uses_default_commit_range() {
814 let temp_dir = init_test_repo();
815 let outcome = run_lint(
816 LintInput::Range(None),
817 Some(temp_dir.path()),
818 None,
819 false,
820 false,
821 )
822 .await
823 .unwrap();
824 assert_eq!(outcome.total_commits, 0);
825 }
826
827 #[test]
828 fn cli_execute_verbose_config_status_empty_scopes_rules_not_found() {
829 let temp_dir = init_test_repo();
830 let context_dir = temp_dir.path().join(".omni-dev");
836 let cmd = LintCommand {
837 commit_range: Some("HEAD..HEAD".to_string()),
838 context_dir: Some(context_dir),
839 guidelines: None,
840 output: OutputFormat::Text,
841 strict: false,
842 quiet: false,
843 verbose: true,
844 show_passing: false,
845 stdin: false,
846 suggest: false,
847 fix: false,
848 allow_pushed: false,
849 };
850 let result = cmd.execute(Some(temp_dir.path()));
851 assert!(result.is_ok());
852 }
853
854 #[test]
855 fn cli_execute_verbose_config_status_scopes_and_rules_found() {
856 let temp_dir = init_test_repo();
857 let context_dir = temp_dir.path().join(".omni-dev");
858 std::fs::create_dir_all(&context_dir).unwrap();
859 std::fs::write(
860 context_dir.join("scopes.yaml"),
861 "scopes:\n - name: custom\n description: Custom scope\n examples: []\n file_patterns: []\n",
862 )
863 .unwrap();
864 std::fs::write(
865 context_dir.join("commit-rules.yaml"),
866 "subject_max_len: 72\ntypes:\n - feat\nrequire_scope: false\nforbidden_footers: []\n",
867 )
868 .unwrap();
869
870 let cmd = LintCommand {
871 commit_range: Some("HEAD..HEAD".to_string()),
872 context_dir: Some(context_dir),
873 guidelines: None,
874 output: OutputFormat::Text,
875 strict: false,
876 quiet: false,
877 verbose: true,
878 show_passing: false,
879 stdin: false,
880 suggest: false,
881 fix: false,
882 allow_pushed: false,
883 };
884 let result = cmd.execute(Some(temp_dir.path()));
885 assert!(result.is_ok());
886 }
887
888 #[test]
889 fn cli_execute_verbose_config_status_ecosystem_scopes_no_file() {
890 let temp_dir = init_test_repo();
891 std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\n").unwrap();
892 let context_dir = temp_dir.path().join(".omni-dev");
897
898 let cmd = LintCommand {
899 commit_range: Some("HEAD..HEAD".to_string()),
900 context_dir: Some(context_dir),
901 guidelines: None,
902 output: OutputFormat::Text,
903 strict: false,
904 quiet: false,
905 verbose: true,
906 show_passing: false,
907 stdin: false,
908 suggest: false,
909 fix: false,
910 allow_pushed: false,
911 };
912 let result = cmd.execute(Some(temp_dir.path()));
913 assert!(result.is_ok());
914 }
915
916 #[test]
926 fn output_text_report_show_passing_false_hides_warning_only_commits() {
927 let temp_dir = init_test_repo();
928 commit(temp_dir.path(), "feat(cli): clean second commit");
929 commit(
930 temp_dir.path(),
931 "feat(cli): add thing\n\nCo-Authored-By: Bot <bot@example.com>",
932 );
933 let cmd = LintCommand {
934 commit_range: Some("HEAD~2..HEAD".to_string()),
935 context_dir: None,
936 guidelines: None,
937 output: OutputFormat::Text,
938 strict: false,
939 quiet: false,
940 verbose: false,
941 show_passing: false,
942 stdin: false,
943 suggest: false,
944 fix: false,
945 allow_pushed: false,
946 };
947 let result = cmd.execute(Some(temp_dir.path()));
948 assert!(result.is_ok(), "expected clean exit, got: {result:?}");
949 }
950
951 #[test]
956 fn output_text_report_quiet_mode_filters_clean_and_info_issues() {
957 let temp_dir = init_test_repo();
958 commit(temp_dir.path(), "feat(cli): clean thing");
959 commit(
960 temp_dir.path(),
961 "feat(cli): Add thing.\n\nCo-Authored-By: Bot <bot@example.com>",
962 );
963 let cmd = LintCommand {
964 commit_range: Some("HEAD~2..HEAD".to_string()),
965 context_dir: None,
966 guidelines: None,
967 output: OutputFormat::Text,
968 strict: false,
969 quiet: true,
970 verbose: false,
971 show_passing: true,
972 stdin: false,
973 suggest: false,
974 fix: false,
975 allow_pushed: false,
976 };
977 let result = cmd.execute(Some(temp_dir.path()));
978 assert!(result.is_ok(), "expected clean exit, got: {result:?}");
979 }
980
981 #[tokio::test]
984 async fn run_lint_range_with_suggest_resolves_dependabot_style_scope() {
985 let temp_dir = init_test_repo();
986 let context_dir = temp_dir.path().join(".omni-dev");
987 write_cargo_scope(&context_dir);
988 commit_file(
989 temp_dir.path(),
990 "Cargo.toml",
991 "[package]\n",
992 "chore(deps): bump foo",
993 );
994
995 let outcome = run_lint(
996 LintInput::Range(Some("HEAD~1..HEAD".to_string())),
997 Some(temp_dir.path()),
998 Some(&context_dir),
999 false,
1000 true,
1001 )
1002 .await
1003 .unwrap();
1004
1005 assert!(outcome.has_errors, "unknown-scope should still be flagged");
1006 assert!(
1007 outcome.report_yaml.contains("chore(cargo): bump foo"),
1008 "expected a deterministic suggestion in report_yaml: {}",
1009 outcome.report_yaml
1010 );
1011 }
1012
1013 #[tokio::test]
1014 async fn run_lint_range_without_suggest_leaves_suggestion_none() {
1015 let temp_dir = init_test_repo();
1016 let context_dir = temp_dir.path().join(".omni-dev");
1017 write_cargo_scope(&context_dir);
1018 commit_file(
1019 temp_dir.path(),
1020 "Cargo.toml",
1021 "[package]\n",
1022 "chore(deps): bump foo",
1023 );
1024
1025 let outcome = run_lint(
1026 LintInput::Range(Some("HEAD~1..HEAD".to_string())),
1027 Some(temp_dir.path()),
1028 Some(&context_dir),
1029 false,
1030 false,
1031 )
1032 .await
1033 .unwrap();
1034
1035 assert!(
1036 !outcome.report_yaml.contains("suggestion:"),
1037 "no suggestion should be present without --suggest: {}",
1038 outcome.report_yaml
1039 );
1040 }
1041
1042 #[tokio::test]
1043 async fn run_lint_message_with_suggest_errors() {
1044 let err = run_lint(
1045 LintInput::Message("feat(cli): add thing".to_string()),
1046 None,
1047 None,
1048 false,
1049 true,
1050 )
1051 .await
1052 .unwrap_err();
1053 let msg = format!("{err:#}");
1054 assert!(
1055 msg.to_lowercase()
1056 .contains("suggest requires a commit range"),
1057 "expected a clear validation error, got: {msg}"
1058 );
1059 }
1060
1061 #[test]
1062 fn cli_execute_stdin_with_suggest_errors() {
1063 let cmd = LintCommand {
1064 commit_range: None,
1065 context_dir: None,
1066 guidelines: None,
1067 output: OutputFormat::Text,
1068 strict: false,
1069 quiet: false,
1070 verbose: false,
1071 show_passing: false,
1072 stdin: true,
1073 suggest: true,
1074 fix: false,
1075 allow_pushed: false,
1076 };
1077 let err = cmd.execute(None).unwrap_err();
1078 let msg = format!("{err:#}");
1079 assert!(
1080 msg.contains("--suggest/--fix require a commit range"),
1081 "expected a clear validation error, got: {msg}"
1082 );
1083 }
1084
1085 #[test]
1086 fn cli_execute_stdin_with_fix_errors() {
1087 let cmd = LintCommand {
1088 commit_range: None,
1089 context_dir: None,
1090 guidelines: None,
1091 output: OutputFormat::Text,
1092 strict: false,
1093 quiet: false,
1094 verbose: false,
1095 show_passing: false,
1096 stdin: true,
1097 suggest: false,
1098 fix: true,
1099 allow_pushed: false,
1100 };
1101 let err = cmd.execute(None).unwrap_err();
1102 let msg = format!("{err:#}");
1103 assert!(msg.contains("--suggest/--fix require a commit range"));
1104 }
1105
1106 #[test]
1112 fn apply_fixes_amends_commit_via_amendment_handler() {
1113 let temp_dir = init_test_repo();
1114 let context_tmp =
1119 tempfile::tempdir_in(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp"))
1120 .unwrap();
1121 let context_dir = context_tmp.path().join(".omni-dev");
1122 write_cargo_scope(&context_dir);
1123 commit_file(
1124 temp_dir.path(),
1125 "Cargo.toml",
1126 "[package]\n",
1127 "chore(deps): bump foo",
1128 );
1129
1130 let ctx_dir =
1131 crate::claude::context::resolve_context_dir_at(Some(&context_dir), temp_dir.path());
1132 let valid_scopes = crate::claude::context::load_project_scopes(&ctx_dir, temp_dir.path());
1133 let rules = crate::claude::context::load_commit_rules(&ctx_dir);
1134 let report =
1135 lint_report_for_range(temp_dir.path(), "HEAD~1..HEAD", &rules, &valid_scopes, true)
1136 .unwrap();
1137
1138 let cmd = LintCommand {
1139 commit_range: None,
1140 context_dir: None,
1141 guidelines: None,
1142 output: OutputFormat::Json,
1143 strict: false,
1144 quiet: true,
1145 verbose: false,
1146 show_passing: true,
1147 stdin: false,
1148 suggest: false,
1149 fix: true,
1150 allow_pushed: false,
1151 };
1152 cmd.apply_fixes(temp_dir.path(), &report).unwrap();
1153
1154 let repo = git2::Repository::open(temp_dir.path()).unwrap();
1155 let head = repo.head().unwrap().peel_to_commit().unwrap();
1156 let msg = head.message().unwrap().to_string();
1157 assert!(
1158 msg.starts_with("chore(cargo): bump foo"),
1159 "expected the amended message at HEAD, got: {msg:?}"
1160 );
1161 }
1162
1163 #[test]
1164 fn apply_fixes_with_no_suggestions_is_a_clean_noop() {
1165 let temp_dir = init_test_repo();
1166 let head_before = head_oid(temp_dir.path());
1167
1168 let report = CheckReport::new(vec![]);
1169 let cmd = LintCommand {
1170 commit_range: None,
1171 context_dir: None,
1172 guidelines: None,
1173 output: OutputFormat::Json,
1174 strict: false,
1175 quiet: true,
1176 verbose: false,
1177 show_passing: true,
1178 stdin: false,
1179 suggest: false,
1180 fix: true,
1181 allow_pushed: false,
1182 };
1183 cmd.apply_fixes(temp_dir.path(), &report).unwrap();
1184
1185 let head_after = head_oid(temp_dir.path());
1186 assert_eq!(head_before, head_after, "HEAD must be unchanged");
1187 }
1188
1189 #[test]
1195 fn output_text_report_prints_suggestion_when_present_and_not_quiet() {
1196 let cmd = LintCommand {
1197 commit_range: None,
1198 context_dir: None,
1199 guidelines: None,
1200 output: OutputFormat::Text,
1201 strict: false,
1202 quiet: false,
1203 verbose: true,
1204 show_passing: true,
1205 stdin: false,
1206 suggest: true,
1207 fix: false,
1208 allow_pushed: false,
1209 };
1210 let report = CheckReport::new(vec![CommitCheckResult {
1211 hash: "abcdef1234567890".to_string(),
1212 message: "chore(deps): bump foo".to_string(),
1213 issues: vec![crate::data::check::CommitIssue {
1214 severity: crate::data::check::IssueSeverity::Error,
1215 section: "Scopes".to_string(),
1216 rule: "unknown-scope".to_string(),
1217 explanation: "Scope(s) not in the valid scopes list: deps".to_string(),
1218 }],
1219 suggestion: Some(crate::data::check::CommitSuggestion {
1220 message: "chore(cargo): bump foo".to_string(),
1221 explanation: "Deterministically resolved from the commit's changed files."
1222 .to_string(),
1223 }),
1224 passes: false,
1225 summary: None,
1226 }]);
1227 assert!(cmd.output_text_report(&report).is_ok());
1228 }
1229}