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 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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[test]
1120 fn apply_fixes_amends_commit_via_amendment_handler() {
1121 let temp_dir = init_test_repo();
1122 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 #[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}