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
60impl LintCommand {
61 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
64 let repo_root = match repo {
65 Some(p) => p.to_path_buf(),
66 None => std::env::current_dir().context("Failed to determine current directory")?,
67 };
68 let repo_root = repo_root.as_path();
69 let output_format = self.output;
70
71 let context_dir =
72 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
73 let valid_scopes = crate::claude::context::load_project_scopes(&context_dir, repo_root);
74 let rules = crate::claude::context::load_commit_rules(&context_dir);
75
76 if self.verbose && output_format == OutputFormat::Text {
77 self.show_config_status(repo_root, &context_dir, &valid_scopes, &rules);
78 }
79
80 let report = if self.stdin {
81 let mut message = String::new();
82 std::io::stdin()
83 .read_to_string(&mut message)
84 .context("Failed to read commit message from stdin")?;
85 lint_report_for_message(&message, &rules, &valid_scopes)
86 } else {
87 let range = self.resolve_range(repo_root)?;
88 lint_report_for_range(repo_root, &range, &rules, &valid_scopes)?
89 };
90
91 self.output_report(&report, output_format)?;
92
93 let exit_code = report.exit_code(self.strict);
96 if exit_code != 0 {
97 std::process::exit(exit_code);
98 }
99
100 Ok(())
101 }
102
103 fn resolve_range(&self, repo_root: &Path) -> Result<String> {
104 if let Some(range) = &self.commit_range {
105 return Ok(range.clone());
106 }
107 let repo = crate::git::GitRepository::open_at(repo_root)
108 .context("Failed to open git repository at the given path")?;
109 super::default_commit_range(&repo)
110 }
111
112 fn show_config_status(
113 &self,
114 _repo_root: &Path,
115 context_dir: &Path,
116 valid_scopes: &[crate::data::context::ScopeDefinition],
117 rules: &crate::data::context::CommitRules,
118 ) {
119 use crate::claude::context::{config_source_label, ConfigSourceLabel};
120
121 println!("📋 Lint configuration:");
122 println!(" 📂 Config dir: {}", context_dir.display());
123
124 let scopes_source = if valid_scopes.is_empty() {
125 "⚪ None found (any scope accepted)".to_string()
126 } else {
127 match config_source_label(context_dir, "scopes.yaml") {
128 ConfigSourceLabel::NotFound => {
129 format!(
130 "✅ (ecosystem defaults only) ({} scopes)",
131 valid_scopes.len()
132 )
133 }
134 label => format!("✅ {label} ({} scopes)", valid_scopes.len()),
135 }
136 };
137 println!(" 🎯 Valid scopes: {scopes_source}");
138
139 let rules_source = match config_source_label(context_dir, "commit-rules.yaml") {
140 ConfigSourceLabel::NotFound => "⚪ Using built-in defaults".to_string(),
141 label => format!("✅ {label}"),
142 };
143 println!(" 📏 Commit rules: {rules_source}");
144 println!(
145 " subject_max_len={}, require_scope={}, types={}",
146 rules.subject_max_len,
147 rules.require_scope,
148 rules.types.len()
149 );
150 println!();
151 }
152
153 fn output_report(&self, report: &CheckReport, format: OutputFormat) -> Result<()> {
155 match format {
156 OutputFormat::Text => self.output_text_report(report),
157 OutputFormat::Json => {
158 let json = serde_json::to_string_pretty(report)
159 .context("Failed to serialize report to JSON")?;
160 println!("{json}");
161 Ok(())
162 }
163 OutputFormat::Yaml => {
164 let yaml =
165 crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
166 println!("{yaml}");
167 Ok(())
168 }
169 }
170 }
171
172 fn output_text_report(&self, report: &CheckReport) -> Result<()> {
174 use crate::data::check::IssueSeverity;
175
176 println!();
177
178 for result in &report.commits {
179 if result.passes && !self.show_passing {
180 continue;
181 }
182
183 if self.quiet && !has_errors_or_warnings(&result.issues) {
184 continue;
185 }
186
187 let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
188 let short_hash = super::formatting::truncate_hash(&result.hash);
189 println!("{icon} {short_hash} - \"{}\"", result.message);
190
191 for issue in &result.issues {
192 if self.quiet && issue.severity == IssueSeverity::Info {
193 continue;
194 }
195 let severity_str = super::formatting::format_severity_label(issue.severity);
196 println!(
197 " {} [{}] {}",
198 severity_str, issue.section, issue.explanation
199 );
200 }
201
202 println!();
203 }
204
205 println!(
206 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
207 Summary: {} commits linted\n\
208 \x20 {} errors, {} warnings\n\
209 \x20 {} passed, {} with issues",
210 report.summary.total_commits,
211 report.summary.error_count,
212 report.summary.warning_count,
213 report.summary.passing_commits,
214 report.summary.failing_commits,
215 );
216
217 Ok(())
218 }
219}
220
221fn has_errors_or_warnings(issues: &[crate::data::check::CommitIssue]) -> bool {
223 use crate::data::check::IssueSeverity;
224 issues
225 .iter()
226 .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning))
227}
228
229fn lint_report_for_message(
232 message: &str,
233 rules: &crate::data::context::CommitRules,
234 valid_scopes: &[crate::data::context::ScopeDefinition],
235) -> CheckReport {
236 let issues = crate::git::lint_message(message, rules, valid_scopes);
237 let passes = crate::git::lint_passes(&issues);
238 let result = CommitCheckResult {
239 hash: "-".to_string(),
240 message: message.lines().next().unwrap_or("").to_string(),
241 issues,
242 suggestion: None,
243 passes,
244 summary: None,
245 };
246 CheckReport::new(vec![result])
247}
248
249fn lint_report_for_range(
254 repo_root: &Path,
255 range: &str,
256 rules: &crate::data::context::CommitRules,
257 valid_scopes: &[crate::data::context::ScopeDefinition],
258) -> Result<CheckReport> {
259 let repo = crate::git::GitRepository::open_at(repo_root)
260 .context("Failed to open git repository at the given path")?;
261 let commits = repo.get_commits_in_range(range)?;
262
263 let results = commits
264 .iter()
265 .map(|commit| {
266 let issues = crate::git::lint_message(&commit.original_message, rules, valid_scopes);
267 let passes = crate::git::lint_passes(&issues);
268 CommitCheckResult {
269 hash: commit.hash.clone(),
270 message: commit
271 .original_message
272 .lines()
273 .next()
274 .unwrap_or("")
275 .to_string(),
276 issues,
277 suggestion: None,
278 passes,
279 summary: None,
280 }
281 })
282 .collect();
283
284 Ok(CheckReport::new(results))
285}
286
287#[derive(Debug, Clone)]
289pub struct LintOutcome {
290 pub report_yaml: String,
292 pub has_errors: bool,
294 pub has_warnings: bool,
296 pub total_commits: usize,
298 pub strict: bool,
300 pub exit_code: i32,
302}
303
304pub enum LintInput {
310 Range(Option<String>),
312 Message(String),
314}
315
316pub async fn run_lint(
326 input: LintInput,
327 repo_path: Option<&Path>,
328 context_dir: Option<&Path>,
329 strict: bool,
330) -> Result<LintOutcome> {
331 let repo_root = match repo_path {
332 Some(p) => p.to_path_buf(),
333 None => std::env::current_dir().context("Failed to determine current directory")?,
334 };
335 let repo_root = repo_root.as_path();
336
337 let ctx_dir = crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
338 let valid_scopes = crate::claude::context::load_project_scopes(&ctx_dir, repo_root);
339 let rules = crate::claude::context::load_commit_rules(&ctx_dir);
340
341 let report = match input {
342 LintInput::Message(message) => lint_report_for_message(&message, &rules, &valid_scopes),
343 LintInput::Range(range) => {
344 let range = if let Some(r) = range {
345 r
346 } else {
347 let repo = crate::git::GitRepository::open_at(repo_root)
348 .context("Failed to open git repository at the given path")?;
349 super::default_commit_range(&repo)?
350 };
351 lint_report_for_range(repo_root, &range, &rules, &valid_scopes)?
352 }
353 };
354
355 let report_yaml = crate::data::to_yaml(&report).context("Failed to serialise CheckReport")?;
356 let has_errors = report.has_errors();
357 let has_warnings = report.has_warnings();
358 let exit_code = report.exit_code(strict);
359 let total_commits = report.commits.len();
360
361 Ok(LintOutcome {
362 report_yaml,
363 has_errors,
364 has_warnings,
365 total_commits,
366 strict,
367 exit_code,
368 })
369}
370
371#[cfg(test)]
372#[allow(clippy::unwrap_used, clippy::expect_used)]
373mod tests {
374 use super::*;
375
376 fn init_test_repo() -> tempfile::TempDir {
377 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
378 std::fs::create_dir_all(&tmp_root).unwrap();
379 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
380 for args in [
381 vec!["init"],
382 vec!["checkout", "-b", "main"],
383 vec!["commit", "--allow-empty", "-m", "feat(cli): first commit"],
384 ] {
385 let output = std::process::Command::new("git")
386 .current_dir(temp_dir.path())
387 .args([
388 "-c",
389 "user.email=test@example.com",
390 "-c",
391 "user.name=Test",
392 "-c",
393 "commit.gpgsign=false",
394 ])
395 .args(&args)
396 .output()
397 .unwrap();
398 assert!(output.status.success(), "git {args:?} failed");
399 }
400 temp_dir
401 }
402
403 fn commit(dir: &Path, message: &str) {
404 let output = std::process::Command::new("git")
405 .current_dir(dir)
406 .args([
407 "-c",
408 "user.email=test@example.com",
409 "-c",
410 "user.name=Test",
411 "-c",
412 "commit.gpgsign=false",
413 "commit",
414 "--allow-empty",
415 "-m",
416 message,
417 ])
418 .output()
419 .unwrap();
420 assert!(output.status.success(), "commit failed: {message}");
421 }
422
423 fn merge_dummy_branch(dir: &Path) {
424 let sh = |args: &[&str]| {
425 let output = std::process::Command::new("git")
426 .current_dir(dir)
427 .args([
428 "-c",
429 "user.email=test@example.com",
430 "-c",
431 "user.name=Test",
432 "-c",
433 "commit.gpgsign=false",
434 ])
435 .args(args)
436 .output()
437 .unwrap();
438 assert!(output.status.success(), "git {args:?} failed");
439 };
440 sh(&["checkout", "-b", "side"]);
441 sh(&["commit", "--allow-empty", "-m", "feat(cli): side change"]);
442 sh(&["checkout", "main"]);
443 sh(&["commit", "--allow-empty", "-m", "feat(cli): main change"]);
444 sh(&["merge", "side", "--no-ff", "-m", "Merge branch 'side'"]);
445 }
446
447 #[tokio::test]
448 async fn run_lint_message_flags_known_issues() {
449 let outcome = run_lint(
450 LintInput::Message("feature(bogus): Bad Message.".to_string()),
451 None,
452 None,
453 false,
454 )
455 .await
456 .unwrap();
457 assert!(outcome.has_errors);
458 assert_eq!(outcome.exit_code, 1);
459 assert_eq!(outcome.total_commits, 1);
460 assert!(outcome.report_yaml.contains("commits:"));
461 }
462
463 #[tokio::test]
464 async fn run_lint_message_clean_passes() {
465 let outcome = run_lint(
466 LintInput::Message("feat(cli): add thing".to_string()),
467 None,
468 None,
469 false,
470 )
471 .await
472 .unwrap();
473 assert!(!outcome.has_errors);
474 assert_eq!(outcome.exit_code, 0);
475 }
476
477 #[tokio::test]
478 async fn run_lint_range_merge_commit_excluded() {
479 let temp_dir = init_test_repo();
480 merge_dummy_branch(temp_dir.path());
481
482 let outcome = run_lint(
483 LintInput::Range(Some("HEAD~2..HEAD".to_string())),
484 Some(temp_dir.path()),
485 None,
486 false,
487 )
488 .await
489 .unwrap();
490
491 assert!(!outcome.report_yaml.contains("Merge branch"));
494 }
495
496 #[tokio::test]
497 async fn run_lint_range_empty_is_clean_not_an_error() {
498 let temp_dir = init_test_repo();
499 let outcome = run_lint(
500 LintInput::Range(Some("HEAD..HEAD".to_string())),
501 Some(temp_dir.path()),
502 None,
503 false,
504 )
505 .await
506 .unwrap();
507 assert_eq!(outcome.total_commits, 0);
508 assert!(!outcome.has_errors);
509 assert_eq!(outcome.exit_code, 0);
510 }
511
512 #[tokio::test]
513 async fn run_lint_range_strict_promotes_warnings() {
514 let temp_dir = init_test_repo();
515 commit(
516 temp_dir.path(),
517 "feat(cli): add thing\n\nCo-Authored-By: Bot <bot@example.com>",
518 );
519 let outcome = run_lint(
520 LintInput::Range(Some("HEAD~1..HEAD".to_string())),
521 Some(temp_dir.path()),
522 None,
523 true,
524 )
525 .await
526 .unwrap();
527 assert!(!outcome.has_errors);
528 assert!(outcome.has_warnings);
529 assert_eq!(outcome.exit_code, 2);
530 }
531
532 #[tokio::test]
533 async fn run_lint_range_and_message_agree_on_same_content() {
534 let temp_dir = init_test_repo();
535 commit(temp_dir.path(), "feature(bogus): Bad Message.");
536
537 let range_outcome = run_lint(
538 LintInput::Range(Some("HEAD~1..HEAD".to_string())),
539 Some(temp_dir.path()),
540 None,
541 false,
542 )
543 .await
544 .unwrap();
545 let message_outcome = run_lint(
546 LintInput::Message("feature(bogus): Bad Message.".to_string()),
547 None,
548 None,
549 false,
550 )
551 .await
552 .unwrap();
553
554 assert_eq!(range_outcome.has_errors, message_outcome.has_errors);
555 assert_eq!(range_outcome.exit_code, message_outcome.exit_code);
556 }
557
558 #[test]
559 fn cli_execute_json_output_matches_check_report_shape() {
560 let temp_dir = init_test_repo();
561 commit(temp_dir.path(), "feat(cli): second commit");
562 let cmd = LintCommand {
563 commit_range: Some("HEAD~1..HEAD".to_string()),
564 context_dir: None,
565 guidelines: None,
566 output: OutputFormat::Json,
567 strict: false,
568 quiet: true,
569 verbose: false,
570 show_passing: true,
571 stdin: false,
572 };
573 let rt = tokio::runtime::Runtime::new().unwrap();
574 let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
575 assert!(result.is_ok());
576 }
577
578 #[test]
579 fn cli_execute_yaml_output_matches_check_report_shape() {
580 let temp_dir = init_test_repo();
581 commit(temp_dir.path(), "feat(cli): second commit");
582 let cmd = LintCommand {
583 commit_range: Some("HEAD~1..HEAD".to_string()),
584 context_dir: None,
585 guidelines: None,
586 output: OutputFormat::Yaml,
587 strict: false,
588 quiet: true,
589 verbose: false,
590 show_passing: true,
591 stdin: false,
592 };
593 let rt = tokio::runtime::Runtime::new().unwrap();
594 let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
595 assert!(result.is_ok());
596 }
597
598 #[test]
602 fn cli_execute_range_none_uses_default_commit_range() {
603 let temp_dir = init_test_repo();
604 let cmd = LintCommand {
605 commit_range: None,
606 context_dir: None,
607 guidelines: None,
608 output: OutputFormat::Json,
609 strict: false,
610 quiet: true,
611 verbose: false,
612 show_passing: true,
613 stdin: false,
614 };
615 let rt = tokio::runtime::Runtime::new().unwrap();
616 let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
617 assert!(result.is_ok(), "expected clean exit, got: {result:?}");
618 }
619
620 #[tokio::test]
625 async fn run_lint_range_none_uses_default_commit_range() {
626 let temp_dir = init_test_repo();
627 let outcome = run_lint(LintInput::Range(None), Some(temp_dir.path()), None, false)
628 .await
629 .unwrap();
630 assert_eq!(outcome.total_commits, 0);
631 }
632
633 #[test]
634 fn cli_execute_verbose_config_status_empty_scopes_rules_not_found() {
635 let temp_dir = init_test_repo();
636 let context_dir = temp_dir.path().join(".omni-dev");
642 let cmd = LintCommand {
643 commit_range: Some("HEAD..HEAD".to_string()),
644 context_dir: Some(context_dir),
645 guidelines: None,
646 output: OutputFormat::Text,
647 strict: false,
648 quiet: false,
649 verbose: true,
650 show_passing: false,
651 stdin: false,
652 };
653 let rt = tokio::runtime::Runtime::new().unwrap();
654 let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
655 assert!(result.is_ok());
656 }
657
658 #[test]
659 fn cli_execute_verbose_config_status_scopes_and_rules_found() {
660 let temp_dir = init_test_repo();
661 let context_dir = temp_dir.path().join(".omni-dev");
662 std::fs::create_dir_all(&context_dir).unwrap();
663 std::fs::write(
664 context_dir.join("scopes.yaml"),
665 "scopes:\n - name: custom\n description: Custom scope\n examples: []\n file_patterns: []\n",
666 )
667 .unwrap();
668 std::fs::write(
669 context_dir.join("commit-rules.yaml"),
670 "subject_max_len: 72\ntypes:\n - feat\nrequire_scope: false\nforbidden_footers: []\n",
671 )
672 .unwrap();
673
674 let cmd = LintCommand {
675 commit_range: Some("HEAD..HEAD".to_string()),
676 context_dir: Some(context_dir),
677 guidelines: None,
678 output: OutputFormat::Text,
679 strict: false,
680 quiet: false,
681 verbose: true,
682 show_passing: false,
683 stdin: false,
684 };
685 let rt = tokio::runtime::Runtime::new().unwrap();
686 let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
687 assert!(result.is_ok());
688 }
689
690 #[test]
691 fn cli_execute_verbose_config_status_ecosystem_scopes_no_file() {
692 let temp_dir = init_test_repo();
693 std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\n").unwrap();
694 let context_dir = temp_dir.path().join(".omni-dev");
699
700 let cmd = LintCommand {
701 commit_range: Some("HEAD..HEAD".to_string()),
702 context_dir: Some(context_dir),
703 guidelines: None,
704 output: OutputFormat::Text,
705 strict: false,
706 quiet: false,
707 verbose: true,
708 show_passing: false,
709 stdin: false,
710 };
711 let rt = tokio::runtime::Runtime::new().unwrap();
712 let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
713 assert!(result.is_ok());
714 }
715
716 #[test]
726 fn output_text_report_show_passing_false_hides_warning_only_commits() {
727 let temp_dir = init_test_repo();
728 commit(temp_dir.path(), "feat(cli): clean second commit");
729 commit(
730 temp_dir.path(),
731 "feat(cli): add thing\n\nCo-Authored-By: Bot <bot@example.com>",
732 );
733 let cmd = LintCommand {
734 commit_range: Some("HEAD~2..HEAD".to_string()),
735 context_dir: None,
736 guidelines: None,
737 output: OutputFormat::Text,
738 strict: false,
739 quiet: false,
740 verbose: false,
741 show_passing: false,
742 stdin: false,
743 };
744 let rt = tokio::runtime::Runtime::new().unwrap();
745 let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
746 assert!(result.is_ok(), "expected clean exit, got: {result:?}");
747 }
748
749 #[test]
754 fn output_text_report_quiet_mode_filters_clean_and_info_issues() {
755 let temp_dir = init_test_repo();
756 commit(temp_dir.path(), "feat(cli): clean thing");
757 commit(
758 temp_dir.path(),
759 "feat(cli): Add thing.\n\nCo-Authored-By: Bot <bot@example.com>",
760 );
761 let cmd = LintCommand {
762 commit_range: Some("HEAD~2..HEAD".to_string()),
763 context_dir: None,
764 guidelines: None,
765 output: OutputFormat::Text,
766 strict: false,
767 quiet: true,
768 verbose: false,
769 show_passing: true,
770 stdin: false,
771 };
772 let rt = tokio::runtime::Runtime::new().unwrap();
773 let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
774 assert!(result.is_ok(), "expected clean exit, got: {result:?}");
775 }
776}