1use anyhow::{Context, Result};
4use clap::Parser;
5
6use super::parse_beta_header;
7
8#[derive(Parser)]
10pub struct CheckCommand {
11 #[arg(value_name = "COMMIT_RANGE")]
14 pub commit_range: Option<String>,
15
16 #[arg(long)]
18 pub model: Option<String>,
19
20 #[arg(long, value_name = "KEY:VALUE")]
23 pub beta_header: Option<String>,
24
25 #[arg(long)]
27 pub context_dir: Option<std::path::PathBuf>,
28
29 #[arg(long)]
31 pub guidelines: Option<std::path::PathBuf>,
32
33 #[arg(long, default_value = "text")]
35 pub format: String,
36
37 #[arg(long)]
39 pub strict: bool,
40
41 #[arg(long)]
43 pub quiet: bool,
44
45 #[arg(long)]
47 pub verbose: bool,
48
49 #[arg(long)]
51 pub show_passing: bool,
52
53 #[arg(long, default_value = "4")]
55 pub concurrency: usize,
56
57 #[arg(long, hide = true)]
59 pub batch_size: Option<usize>,
60
61 #[arg(long)]
63 pub no_coherence: bool,
64
65 #[arg(long)]
67 pub no_suggestions: bool,
68
69 #[arg(long)]
71 pub twiddle: bool,
72}
73
74impl CheckCommand {
75 pub async fn execute(mut self) -> Result<()> {
77 if let Some(bs) = self.batch_size {
79 eprintln!("warning: --batch-size is deprecated; use --concurrency instead");
80 self.concurrency = bs;
81 }
82 use crate::data::check::OutputFormat;
83
84 let output_format: OutputFormat = self.format.parse().unwrap_or(OutputFormat::Text);
86
87 let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
89 if !self.quiet && output_format == OutputFormat::Text {
90 println!(
91 "✓ {} credentials verified (model: {})",
92 ai_info.provider, ai_info.model
93 );
94 }
95
96 if !self.quiet && output_format == OutputFormat::Text {
97 println!("🔍 Checking commit messages against guidelines...");
98 }
99
100 let mut repo_view = self.generate_repository_view().await?;
102
103 if repo_view.commits.is_empty() {
105 eprintln!("error: no commits found in range");
106 std::process::exit(3);
107 }
108
109 if !self.quiet && output_format == OutputFormat::Text {
110 println!("📊 Found {} commits to check", repo_view.commits.len());
111 }
112
113 let guidelines = self.load_guidelines().await?;
115 let valid_scopes = self.load_scopes();
116
117 for commit in &mut repo_view.commits {
119 commit.analysis.refine_scope(&valid_scopes);
120 }
121
122 if !self.quiet && output_format == OutputFormat::Text {
123 self.show_guidance_files_status(&guidelines, &valid_scopes);
124 }
125
126 let beta = self
128 .beta_header
129 .as_deref()
130 .map(parse_beta_header)
131 .transpose()?;
132 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
133
134 if self.verbose && output_format == OutputFormat::Text {
135 self.show_model_info(&claude_client)?;
136 }
137
138 let report = if repo_view.commits.len() > 1 {
140 if !self.quiet && output_format == OutputFormat::Text {
141 println!(
142 "🔄 Processing {} commits in parallel (concurrency: {})...",
143 repo_view.commits.len(),
144 self.concurrency
145 );
146 }
147 self.check_with_map_reduce(
148 &claude_client,
149 &repo_view,
150 guidelines.as_deref(),
151 &valid_scopes,
152 )
153 .await?
154 } else {
155 if !self.quiet && output_format == OutputFormat::Text {
157 println!("🤖 Analyzing commits with AI...");
158 }
159 claude_client
160 .check_commits_with_scopes(
161 &repo_view,
162 guidelines.as_deref(),
163 &valid_scopes,
164 !self.no_suggestions,
165 )
166 .await?
167 };
168
169 self.output_report(&report, output_format)?;
171
172 if should_offer_twiddle(self.twiddle, report.has_errors(), output_format) {
174 use std::io::IsTerminal;
175 let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
176 if !amendments.is_empty()
177 && self
178 .prompt_and_apply_suggestions(
179 amendments,
180 std::io::stdin().is_terminal(),
181 &mut std::io::BufReader::new(std::io::stdin()),
182 )
183 .await?
184 {
185 return Ok(());
187 }
188 }
189
190 let exit_code = report.exit_code(self.strict);
192 if exit_code != 0 {
193 std::process::exit(exit_code);
194 }
195
196 Ok(())
197 }
198
199 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
201 use crate::data::{
202 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
203 WorkingDirectoryInfo,
204 };
205 use crate::git::{GitRepository, RemoteInfo};
206 use crate::utils::ai_scratch;
207
208 let repo = GitRepository::open()
210 .context("Failed to open git repository. Make sure you're in a git repository.")?;
211
212 let current_branch = repo
214 .get_current_branch()
215 .unwrap_or_else(|_| "HEAD".to_string());
216
217 let commit_range = if let Some(range) = &self.commit_range {
219 range.clone()
220 } else {
221 let base = if repo.branch_exists("main")? {
223 "main"
224 } else if repo.branch_exists("master")? {
225 "master"
226 } else {
227 "HEAD~5"
228 };
229 format!("{base}..HEAD")
230 };
231
232 let wd_status = repo.get_working_directory_status()?;
234 let working_directory = WorkingDirectoryInfo {
235 clean: wd_status.clean,
236 untracked_changes: wd_status
237 .untracked_changes
238 .into_iter()
239 .map(|fs| FileStatusInfo {
240 status: fs.status,
241 file: fs.file,
242 })
243 .collect(),
244 };
245
246 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
248
249 let commits = repo.get_commits_in_range(&commit_range)?;
251
252 let versions = Some(VersionInfo {
254 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
255 });
256
257 let ai_scratch_path =
259 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
260 let ai_info = AiInfo {
261 scratch: ai_scratch_path.to_string_lossy().to_string(),
262 };
263
264 let mut repo_view = RepositoryView {
266 versions,
267 explanation: FieldExplanation::default(),
268 working_directory,
269 remotes,
270 ai: ai_info,
271 branch_info: Some(BranchInfo {
272 branch: current_branch,
273 }),
274 pr_template: None,
275 pr_template_location: None,
276 branch_prs: None,
277 commits,
278 };
279
280 repo_view.update_field_presence();
282
283 Ok(repo_view)
284 }
285
286 async fn load_guidelines(&self) -> Result<Option<String>> {
288 if let Some(guidelines_path) = &self.guidelines {
290 let content = std::fs::read_to_string(guidelines_path).with_context(|| {
291 format!(
292 "Failed to read guidelines file: {}",
293 guidelines_path.display()
294 )
295 })?;
296 return Ok(Some(content));
297 }
298
299 let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
301 crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")
302 }
303
304 fn load_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
306 let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
307 crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."))
308 }
309
310 fn show_guidance_files_status(
312 &self,
313 guidelines: &Option<String>,
314 valid_scopes: &[crate::data::context::ScopeDefinition],
315 ) {
316 use crate::claude::context::{
317 config_source_label, resolve_context_dir_with_source, ConfigSourceLabel,
318 };
319
320 let (context_dir, dir_source) =
321 resolve_context_dir_with_source(self.context_dir.as_deref());
322
323 println!("📋 Project guidance files status:");
324 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
325
326 let guidelines_source = if guidelines.is_some() {
328 match config_source_label(&context_dir, "commit-guidelines.md") {
329 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
330 label => format!("✅ {label}"),
331 }
332 } else {
333 "⚪ Using defaults".to_string()
334 };
335 println!(" 📝 Commit guidelines: {guidelines_source}");
336
337 let scopes_count = valid_scopes.len();
339 let scopes_source = if scopes_count > 0 {
340 match config_source_label(&context_dir, "scopes.yaml") {
341 ConfigSourceLabel::NotFound => {
342 format!("✅ (source unknown) ({scopes_count} scopes)")
343 }
344 label => format!("✅ {label} ({scopes_count} scopes)"),
345 }
346 } else {
347 "⚪ None found (any scope accepted)".to_string()
348 };
349 println!(" 🎯 Valid scopes: {scopes_source}");
350
351 println!();
352 }
353
354 async fn check_with_map_reduce(
360 &self,
361 claude_client: &crate::claude::client::ClaudeClient,
362 full_repo_view: &crate::data::RepositoryView,
363 guidelines: Option<&str>,
364 valid_scopes: &[crate::data::context::ScopeDefinition],
365 ) -> Result<crate::data::check::CheckReport> {
366 use std::io::IsTerminal;
367 use std::sync::atomic::{AtomicUsize, Ordering};
368 use std::sync::Arc;
369
370 use crate::claude::batch;
371 use crate::claude::token_budget;
372 use crate::data::check::{CheckReport, CommitCheckResult};
373
374 let total_commits = full_repo_view.commits.len();
375
376 let metadata = claude_client.get_ai_client_metadata();
378 let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
379 guidelines,
380 valid_scopes,
381 );
382 let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
383 let batch_plan =
384 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
385
386 if !self.quiet && batch_plan.batches.len() < total_commits {
387 println!(
388 " 📦 Grouped {} commits into {} batches by token budget",
389 total_commits,
390 batch_plan.batches.len()
391 );
392 }
393
394 let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
395 let completed = Arc::new(AtomicUsize::new(0));
396
397 let futs: Vec<_> = batch_plan
399 .batches
400 .iter()
401 .map(|batch| {
402 let sem = semaphore.clone();
403 let completed = completed.clone();
404 let batch_indices = &batch.commit_indices;
405
406 async move {
407 let _permit = sem
408 .acquire()
409 .await
410 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
411
412 let batch_size = batch_indices.len();
413
414 let batch_view = if batch_size == 1 {
416 full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
417 } else {
418 let commits: Vec<_> = batch_indices
419 .iter()
420 .map(|&i| &full_repo_view.commits[i])
421 .collect();
422 full_repo_view.multi_commit_view(&commits)
423 };
424
425 let result = claude_client
426 .check_commits_with_scopes(
427 &batch_view,
428 guidelines,
429 valid_scopes,
430 !self.no_suggestions,
431 )
432 .await;
433
434 match result {
435 Ok(report) => {
436 let done =
437 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
438 if !self.quiet {
439 println!(" ✅ {done}/{total_commits} commits checked");
440 }
441
442 let items: Vec<_> = report
443 .commits
444 .into_iter()
445 .map(|r| {
446 let summary = r.summary.clone().unwrap_or_default();
447 (r, summary)
448 })
449 .collect();
450 Ok::<_, anyhow::Error>((items, vec![]))
451 }
452 Err(e) if batch_size > 1 => {
453 eprintln!(
455 "warning: batch of {batch_size} failed, retrying individually: {e}"
456 );
457 let mut items = Vec::new();
458 let mut failed_indices = Vec::new();
459 for &idx in batch_indices {
460 let single_view =
461 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
462 let single_result = claude_client
463 .check_commits_with_scopes(
464 &single_view,
465 guidelines,
466 valid_scopes,
467 !self.no_suggestions,
468 )
469 .await;
470 match single_result {
471 Ok(report) => {
472 if let Some(r) = report.commits.into_iter().next() {
473 let summary = r.summary.clone().unwrap_or_default();
474 items.push((r, summary));
475 }
476 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
477 if !self.quiet {
478 println!(
479 " ✅ {done}/{total_commits} commits checked"
480 );
481 }
482 }
483 Err(e) => {
484 eprintln!("warning: failed to check commit: {e}");
485 failed_indices.push(idx);
486 if !self.quiet {
487 println!(" ❌ commit check failed");
488 }
489 }
490 }
491 }
492 Ok((items, failed_indices))
493 }
494 Err(e) => {
495 let idx = batch_indices[0];
497 eprintln!("warning: failed to check commit: {e}");
498 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
499 if !self.quiet {
500 println!(" ❌ {done}/{total_commits} commits checked (failed)");
501 }
502 Ok((vec![], vec![idx]))
503 }
504 }
505 }
506 })
507 .collect();
508
509 let results = futures::future::join_all(futs).await;
510
511 let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
513 let mut failed_indices: Vec<usize> = Vec::new();
514
515 for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
516 match result {
517 Ok((items, failed)) => {
518 successes.extend(items);
519 failed_indices.extend(failed);
520 }
521 Err(e) => {
522 eprintln!("warning: batch processing error: {e}");
523 failed_indices.extend(&batch.commit_indices);
524 }
525 }
526 }
527
528 if !failed_indices.is_empty() && !self.quiet && std::io::stdin().is_terminal() {
530 self.run_interactive_retry_check(
531 &mut failed_indices,
532 full_repo_view,
533 claude_client,
534 guidelines,
535 valid_scopes,
536 &mut successes,
537 &mut std::io::BufReader::new(std::io::stdin()),
538 )
539 .await?;
540 } else if !failed_indices.is_empty() {
541 eprintln!(
542 "warning: {} commit(s) failed to check",
543 failed_indices.len()
544 );
545 }
546
547 if !failed_indices.is_empty() {
548 eprintln!(
549 "warning: {} commit(s) ultimately failed to check",
550 failed_indices.len()
551 );
552 }
553
554 if successes.is_empty() {
555 anyhow::bail!("All commits failed to check");
556 }
557
558 let single_batch = batch_plan.batches.len() <= 1;
561 if !self.no_coherence && !single_batch && successes.len() >= 2 {
562 if !self.quiet {
563 println!("🔗 Running cross-commit coherence pass...");
564 }
565 match claude_client
566 .refine_checks_coherence(&successes, full_repo_view)
567 .await
568 {
569 Ok(refined) => {
570 if !self.quiet {
571 println!("✅ All commits checked!");
572 }
573 return Ok(refined);
574 }
575 Err(e) => {
576 eprintln!("warning: coherence pass failed, using individual results: {e}");
577 }
578 }
579 }
580
581 if !self.quiet {
582 println!("✅ All commits checked!");
583 }
584
585 let all_results: Vec<CommitCheckResult> = successes.into_iter().map(|(r, _)| r).collect();
586
587 Ok(CheckReport::new(all_results))
588 }
589
590 fn output_report(
592 &self,
593 report: &crate::data::check::CheckReport,
594 format: crate::data::check::OutputFormat,
595 ) -> Result<()> {
596 use crate::data::check::OutputFormat;
597
598 match format {
599 OutputFormat::Text => self.output_text_report(report),
600 OutputFormat::Json => {
601 let json = serde_json::to_string_pretty(report)
602 .context("Failed to serialize report to JSON")?;
603 println!("{json}");
604 Ok(())
605 }
606 OutputFormat::Yaml => {
607 let yaml =
608 crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
609 println!("{yaml}");
610 Ok(())
611 }
612 }
613 }
614
615 fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
617 use crate::data::check::IssueSeverity;
618
619 println!();
620
621 for result in &report.commits {
622 if !should_display_commit(result.passes, self.show_passing) {
623 continue;
624 }
625
626 if self.quiet && !has_errors_or_warnings(&result.issues) {
628 continue;
629 }
630
631 let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
632 let short_hash = super::formatting::truncate_hash(&result.hash);
633 println!("{}", format_commit_line(icon, short_hash, &result.message));
634
635 for issue in &result.issues {
637 if self.quiet && issue.severity == IssueSeverity::Info {
639 continue;
640 }
641
642 let severity_str = super::formatting::format_severity_label(issue.severity);
643 println!(
644 " {} [{}] {}",
645 severity_str, issue.section, issue.explanation
646 );
647 }
648
649 if !self.quiet {
651 if let Some(suggestion) = &result.suggestion {
652 println!();
653 print!("{}", format_suggestion_text(suggestion, self.verbose));
654 }
655 }
656
657 println!();
658 }
659
660 println!("{}", format_summary_text(&report.summary));
662
663 Ok(())
664 }
665
666 fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
668 use crate::claude::model_config::get_model_registry;
669
670 println!("🤖 AI Model Configuration:");
671
672 let metadata = client.get_ai_client_metadata();
673 let registry = get_model_registry();
674
675 if let Some(spec) = registry.get_model_spec(&metadata.model) {
676 if metadata.model != spec.api_identifier {
677 println!(
678 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
679 metadata.model, spec.api_identifier
680 );
681 } else {
682 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
683 }
684 println!(" 🏷️ Provider: {}", spec.provider);
685 } else {
686 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
687 println!(" 🏷️ Provider: {}", metadata.provider);
688 }
689
690 println!();
691 Ok(())
692 }
693
694 fn build_amendments_from_suggestions(
696 &self,
697 report: &crate::data::check::CheckReport,
698 repo_view: &crate::data::RepositoryView,
699 ) -> Vec<crate::data::amendments::Amendment> {
700 use crate::data::amendments::Amendment;
701
702 let candidate_hashes: Vec<String> =
703 repo_view.commits.iter().map(|c| c.hash.clone()).collect();
704
705 report
706 .commits
707 .iter()
708 .filter(|r| !r.passes)
709 .filter_map(|r| {
710 let suggestion = r.suggestion.as_ref()?;
711 let full_hash = super::formatting::resolve_short_hash(&r.hash, &candidate_hashes)?;
712 Some(Amendment::new(
713 full_hash.to_string(),
714 suggestion.message.clone(),
715 ))
716 })
717 .collect()
718 }
719
720 async fn prompt_and_apply_suggestions(
726 &self,
727 amendments: Vec<crate::data::amendments::Amendment>,
728 is_terminal: bool,
729 reader: &mut (dyn std::io::BufRead + Send),
730 ) -> Result<bool> {
731 use crate::data::amendments::AmendmentFile;
732 use crate::git::AmendmentHandler;
733 use std::io::{self, Write};
734
735 println!();
736 println!(
737 "🔧 {} commit(s) have issues with suggested fixes available.",
738 amendments.len()
739 );
740
741 if !is_terminal {
742 eprintln!("warning: stdin is not interactive, cannot prompt to apply suggested fixes");
743 return Ok(false);
744 }
745
746 loop {
747 print!("❓ [A]pply suggested fixes, or [Q]uit? [A/q] ");
748 io::stdout().flush()?;
749
750 let Some(input) = super::read_interactive_line(reader)? else {
751 eprintln!("warning: stdin closed, not applying suggested fixes");
752 return Ok(false);
753 };
754
755 match input.trim().to_lowercase().as_str() {
756 "a" | "apply" | "" => {
757 let amendment_file = AmendmentFile { amendments };
758 let temp_file = tempfile::NamedTempFile::new()
759 .context("Failed to create temp file for amendments")?;
760 amendment_file
761 .save_to_file(temp_file.path())
762 .context("Failed to save amendments")?;
763
764 let handler = AmendmentHandler::new()
765 .context("Failed to initialize amendment handler")?;
766 handler
767 .apply_amendments(&temp_file.path().to_string_lossy())
768 .context("Failed to apply amendments")?;
769
770 println!("✅ Suggested fixes applied successfully!");
771 return Ok(true);
772 }
773 "q" | "quit" => return Ok(false),
774 _ => {
775 println!("Invalid choice. Please enter 'a' to apply or 'q' to quit.");
776 }
777 }
778 }
779 }
780}
781
782impl CheckCommand {
785 #[allow(clippy::too_many_arguments)]
789 async fn run_interactive_retry_check(
790 &self,
791 failed_indices: &mut Vec<usize>,
792 full_repo_view: &crate::data::RepositoryView,
793 claude_client: &crate::claude::client::ClaudeClient,
794 guidelines: Option<&str>,
795 valid_scopes: &[crate::data::context::ScopeDefinition],
796 successes: &mut Vec<(crate::data::check::CommitCheckResult, String)>,
797 reader: &mut (dyn std::io::BufRead + Send),
798 ) -> Result<()> {
799 use std::io::Write as _;
800 println!("\n⚠️ {} commit(s) failed to check:", failed_indices.len());
801 for &idx in failed_indices.iter() {
802 let commit = &full_repo_view.commits[idx];
803 let subject = commit
804 .original_message
805 .lines()
806 .next()
807 .unwrap_or("(no message)");
808 println!(" - {}: {}", &commit.hash[..8], subject);
809 }
810 loop {
811 print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
812 std::io::stdout().flush()?;
813 let Some(input) = super::read_interactive_line(reader)? else {
814 eprintln!("warning: stdin closed, skipping failed commit(s)");
815 break;
816 };
817 match input.trim().to_lowercase().as_str() {
818 "r" | "retry" | "" => {
819 let mut still_failed = Vec::new();
820 for &idx in failed_indices.iter() {
821 let single_view =
822 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
823 match claude_client
824 .check_commits_with_scopes(
825 &single_view,
826 guidelines,
827 valid_scopes,
828 !self.no_suggestions,
829 )
830 .await
831 {
832 Ok(report) => {
833 if let Some(r) = report.commits.into_iter().next() {
834 let summary = r.summary.clone().unwrap_or_default();
835 successes.push((r, summary));
836 }
837 }
838 Err(e) => {
839 eprintln!("warning: still failed: {e}");
840 still_failed.push(idx);
841 }
842 }
843 }
844 *failed_indices = still_failed;
845 if failed_indices.is_empty() {
846 println!("✅ All retried commits succeeded.");
847 break;
848 }
849 println!("\n⚠️ {} commit(s) still failed:", failed_indices.len());
850 for &idx in failed_indices.iter() {
851 let commit = &full_repo_view.commits[idx];
852 let subject = commit
853 .original_message
854 .lines()
855 .next()
856 .unwrap_or("(no message)");
857 println!(" - {}: {}", &commit.hash[..8], subject);
858 }
859 }
860 "s" | "skip" => {
861 println!("Skipping {} failed commit(s).", failed_indices.len());
862 break;
863 }
864 _ => println!("Please enter 'r' to retry or 's' to skip."),
865 }
866 }
867 Ok(())
868 }
869}
870
871fn should_display_commit(passes: bool, show_passing: bool) -> bool {
875 !passes || show_passing
876}
877
878fn has_errors_or_warnings(issues: &[crate::data::check::CommitIssue]) -> bool {
880 use crate::data::check::IssueSeverity;
881 issues
882 .iter()
883 .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning))
884}
885
886fn should_offer_twiddle(
888 twiddle_flag: bool,
889 has_errors: bool,
890 format: crate::data::check::OutputFormat,
891) -> bool {
892 twiddle_flag && has_errors && format == crate::data::check::OutputFormat::Text
893}
894
895fn format_suggestion_text(
897 suggestion: &crate::data::check::CommitSuggestion,
898 verbose: bool,
899) -> String {
900 let mut output = String::new();
901 output.push_str(" Suggested message:\n");
902 for line in suggestion.message.lines() {
903 output.push_str(&format!(" {line}\n"));
904 }
905 if verbose {
906 output.push('\n');
907 output.push_str(" Why this is better:\n");
908 for line in suggestion.explanation.lines() {
909 output.push_str(&format!(" {line}\n"));
910 }
911 }
912 output
913}
914
915fn format_summary_text(summary: &crate::data::check::CheckSummary) -> String {
917 format!(
918 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
919 Summary: {} commits checked\n\
920 \x20 {} errors, {} warnings\n\
921 \x20 {} passed, {} with issues",
922 summary.total_commits,
923 summary.error_count,
924 summary.warning_count,
925 summary.passing_commits,
926 summary.failing_commits,
927 )
928}
929
930fn format_commit_line(icon: &str, short_hash: &str, message: &str) -> String {
932 format!("{icon} {short_hash} - \"{message}\"")
933}
934
935#[cfg(test)]
936mod tests {
937 use super::*;
938 use crate::data::check::{
939 CheckSummary, CommitIssue, CommitSuggestion, IssueSeverity, OutputFormat,
940 };
941
942 #[test]
945 fn display_commit_passing_hidden() {
946 assert!(!should_display_commit(true, false));
947 }
948
949 #[test]
950 fn display_commit_passing_shown() {
951 assert!(should_display_commit(true, true));
952 }
953
954 #[test]
955 fn display_commit_failing() {
956 assert!(should_display_commit(false, false));
957 assert!(should_display_commit(false, true));
958 }
959
960 #[test]
963 fn errors_or_warnings_with_error() {
964 let issues = vec![CommitIssue {
965 severity: IssueSeverity::Error,
966 section: "subject".to_string(),
967 rule: "length".to_string(),
968 explanation: "too long".to_string(),
969 }];
970 assert!(has_errors_or_warnings(&issues));
971 }
972
973 #[test]
974 fn errors_or_warnings_with_warning() {
975 let issues = vec![CommitIssue {
976 severity: IssueSeverity::Warning,
977 section: "body".to_string(),
978 rule: "style".to_string(),
979 explanation: "minor issue".to_string(),
980 }];
981 assert!(has_errors_or_warnings(&issues));
982 }
983
984 #[test]
985 fn errors_or_warnings_info_only() {
986 let issues = vec![CommitIssue {
987 severity: IssueSeverity::Info,
988 section: "body".to_string(),
989 rule: "suggestion".to_string(),
990 explanation: "consider adding more detail".to_string(),
991 }];
992 assert!(!has_errors_or_warnings(&issues));
993 }
994
995 #[test]
996 fn errors_or_warnings_empty() {
997 assert!(!has_errors_or_warnings(&[]));
998 }
999
1000 #[test]
1003 fn offer_twiddle_all_conditions_met() {
1004 assert!(should_offer_twiddle(true, true, OutputFormat::Text));
1005 }
1006
1007 #[test]
1008 fn offer_twiddle_flag_off() {
1009 assert!(!should_offer_twiddle(false, true, OutputFormat::Text));
1010 }
1011
1012 #[test]
1013 fn offer_twiddle_no_errors() {
1014 assert!(!should_offer_twiddle(true, false, OutputFormat::Text));
1015 }
1016
1017 #[test]
1018 fn offer_twiddle_json_format() {
1019 assert!(!should_offer_twiddle(true, true, OutputFormat::Json));
1020 }
1021
1022 #[test]
1025 fn suggestion_text_basic() {
1026 let suggestion = CommitSuggestion {
1027 message: "feat(cli): add new flag".to_string(),
1028 explanation: "uses conventional format".to_string(),
1029 };
1030 let result = format_suggestion_text(&suggestion, false);
1031 assert!(result.contains("Suggested message:"));
1032 assert!(result.contains("feat(cli): add new flag"));
1033 assert!(!result.contains("Why this is better"));
1034 }
1035
1036 #[test]
1037 fn suggestion_text_verbose() {
1038 let suggestion = CommitSuggestion {
1039 message: "fix: resolve crash".to_string(),
1040 explanation: "clear description of fix".to_string(),
1041 };
1042 let result = format_suggestion_text(&suggestion, true);
1043 assert!(result.contains("Suggested message:"));
1044 assert!(result.contains("fix: resolve crash"));
1045 assert!(result.contains("Why this is better:"));
1046 assert!(result.contains("clear description of fix"));
1047 }
1048
1049 #[test]
1052 fn summary_text_formatting() {
1053 let summary = CheckSummary {
1054 total_commits: 5,
1055 passing_commits: 3,
1056 failing_commits: 2,
1057 error_count: 1,
1058 warning_count: 4,
1059 info_count: 0,
1060 };
1061 let result = format_summary_text(&summary);
1062 assert!(result.contains("5 commits checked"));
1063 assert!(result.contains("1 errors, 4 warnings"));
1064 assert!(result.contains("3 passed, 2 with issues"));
1065 }
1066
1067 #[test]
1070 fn commit_line_formatting() {
1071 let line = format_commit_line("✅", "abc1234", "feat: add feature");
1072 assert_eq!(line, "✅ abc1234 - \"feat: add feature\"");
1073 }
1074
1075 fn make_check_cmd(quiet: bool) -> CheckCommand {
1078 CheckCommand {
1079 commit_range: None,
1080 model: None,
1081 beta_header: None,
1082 context_dir: None,
1083 guidelines: None,
1084 format: "text".to_string(),
1085 strict: false,
1086 quiet,
1087 verbose: false,
1088 show_passing: false,
1089 concurrency: 4,
1090 batch_size: None,
1091 no_coherence: true,
1092 no_suggestions: false,
1093 twiddle: false,
1094 }
1095 }
1096
1097 fn make_check_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
1098 use crate::git::commit::FileChanges;
1099 use crate::git::{CommitAnalysis, CommitInfo};
1100 let tmp = tempfile::NamedTempFile::new().unwrap();
1101 let commit = CommitInfo {
1102 hash: hash.to_string(),
1103 author: "Test <test@test.com>".to_string(),
1104 date: chrono::Utc::now().fixed_offset(),
1105 original_message: format!("feat: commit {hash}"),
1106 in_main_branches: vec![],
1107 analysis: CommitAnalysis {
1108 detected_type: "feat".to_string(),
1109 detected_scope: String::new(),
1110 proposed_message: format!("feat: commit {hash}"),
1111 file_changes: FileChanges {
1112 total_files: 0,
1113 files_added: 0,
1114 files_deleted: 0,
1115 file_list: vec![],
1116 },
1117 diff_summary: String::new(),
1118 diff_file: tmp.path().to_string_lossy().to_string(),
1119 file_diffs: Vec::new(),
1120 },
1121 };
1122 (commit, tmp)
1123 }
1124
1125 fn make_check_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
1126 use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
1127 RepositoryView {
1128 versions: None,
1129 explanation: FieldExplanation::default(),
1130 working_directory: WorkingDirectoryInfo {
1131 clean: true,
1132 untracked_changes: vec![],
1133 },
1134 remotes: vec![],
1135 ai: AiInfo {
1136 scratch: String::new(),
1137 },
1138 branch_info: None,
1139 pr_template: None,
1140 pr_template_location: None,
1141 branch_prs: None,
1142 commits,
1143 }
1144 }
1145
1146 fn check_yaml(hash: &str) -> String {
1147 format!("checks:\n - commit: {hash}\n passes: true\n issues: []\n")
1148 }
1149
1150 fn make_client(responses: Vec<anyhow::Result<String>>) -> crate::claude::client::ClaudeClient {
1151 crate::claude::client::ClaudeClient::new(Box::new(
1152 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
1153 ))
1154 }
1155
1156 fn errs(n: usize) -> Vec<anyhow::Result<String>> {
1159 (0..n)
1160 .map(|_| Err(anyhow::anyhow!("mock failure")))
1161 .collect()
1162 }
1163
1164 #[tokio::test]
1165 async fn check_with_map_reduce_single_commit_fails_returns_err() {
1166 let (commit, _tmp) = make_check_commit("abc00000");
1170 let cmd = make_check_cmd(true);
1171 let repo_view = make_check_repo_view(vec![commit]);
1172 let client = make_client(errs(3));
1173 let result = cmd
1174 .check_with_map_reduce(&client, &repo_view, None, &[])
1175 .await;
1176 assert!(result.is_err(), "empty successes should bail");
1177 }
1178
1179 #[tokio::test]
1180 async fn check_with_map_reduce_single_commit_succeeds() {
1181 let (commit, _tmp) = make_check_commit("abc00000");
1183 let cmd = make_check_cmd(true);
1184 let repo_view = make_check_repo_view(vec![commit]);
1185 let client = make_client(vec![Ok(check_yaml("abc00000"))]);
1186 let result = cmd
1187 .check_with_map_reduce(&client, &repo_view, None, &[])
1188 .await;
1189 assert!(result.is_ok());
1190 assert_eq!(result.unwrap().commits.len(), 1);
1191 }
1192
1193 #[tokio::test]
1194 async fn check_with_map_reduce_batch_fails_split_retry_both_succeed() {
1195 let (c1, _t1) = make_check_commit("abc00000");
1198 let (c2, _t2) = make_check_commit("def00000");
1199 let cmd = make_check_cmd(true);
1200 let repo_view = make_check_repo_view(vec![c1, c2]);
1201 let mut responses = errs(3); responses.push(Ok(check_yaml("abc00000"))); responses.push(Ok(check_yaml("def00000"))); let client = make_client(responses);
1205 let result = cmd
1206 .check_with_map_reduce(&client, &repo_view, None, &[])
1207 .await;
1208 assert!(result.is_ok());
1209 assert_eq!(result.unwrap().commits.len(), 2);
1210 }
1211
1212 #[tokio::test]
1213 async fn check_with_map_reduce_batch_fails_split_one_individual_fails_quiet() {
1214 let (c1, _t1) = make_check_commit("abc00000");
1218 let (c2, _t2) = make_check_commit("def00000");
1219 let cmd = make_check_cmd(true);
1220 let repo_view = make_check_repo_view(vec![c1, c2]);
1221 let mut responses = errs(3); responses.push(Ok(check_yaml("abc00000"))); responses.extend(errs(3)); let client = make_client(responses);
1225 let result = cmd
1226 .check_with_map_reduce(&client, &repo_view, None, &[])
1227 .await;
1228 assert!(result.is_ok());
1230 assert_eq!(result.unwrap().commits.len(), 1);
1231 }
1232
1233 #[tokio::test]
1234 async fn check_with_map_reduce_all_fail_in_split_retry_returns_err() {
1235 let (c1, _t1) = make_check_commit("abc00000");
1238 let (c2, _t2) = make_check_commit("def00000");
1239 let cmd = make_check_cmd(true);
1240 let repo_view = make_check_repo_view(vec![c1, c2]);
1241 let mut responses = errs(3); responses.extend(errs(3)); responses.extend(errs(3)); let client = make_client(responses);
1245 let result = cmd
1246 .check_with_map_reduce(&client, &repo_view, None, &[])
1247 .await;
1248 assert!(result.is_err(), "no successes should bail");
1249 }
1250
1251 #[tokio::test]
1257 async fn check_with_map_reduce_non_quiet_single_commit_succeeds() {
1258 let (c1, _t1) = make_check_commit("abc00000");
1262 let (c2, _t2) = make_check_commit("def00000");
1263 let cmd = make_check_cmd(false);
1264 let repo_view = make_check_repo_view(vec![c1, c2]);
1265 let mut responses = errs(3); responses.push(Ok(check_yaml("abc00000")));
1267 responses.push(Ok(check_yaml("def00000")));
1268 let client = make_client(responses);
1269 let result = cmd
1270 .check_with_map_reduce(&client, &repo_view, None, &[])
1271 .await;
1272 assert!(result.is_ok());
1273 assert_eq!(result.unwrap().commits.len(), 2);
1274 }
1275
1276 #[tokio::test]
1279 async fn interactive_retry_skip_immediately() {
1280 let (commit, _tmp) = make_check_commit("abc00000");
1282 let cmd = make_check_cmd(false);
1283 let repo_view = make_check_repo_view(vec![commit]);
1284 let client = make_client(vec![]); let mut failed = vec![0usize];
1286 let mut successes = vec![];
1287 let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
1288 cmd.run_interactive_retry_check(
1289 &mut failed,
1290 &repo_view,
1291 &client,
1292 None,
1293 &[],
1294 &mut successes,
1295 &mut stdin,
1296 )
1297 .await
1298 .unwrap();
1299 assert_eq!(
1300 failed,
1301 vec![0],
1302 "skip should leave failed_indices unchanged"
1303 );
1304 assert!(successes.is_empty());
1305 }
1306
1307 #[tokio::test]
1308 async fn interactive_retry_retry_succeeds() {
1309 let (commit, _tmp) = make_check_commit("abc00000");
1311 let cmd = make_check_cmd(false);
1312 let repo_view = make_check_repo_view(vec![commit]);
1313 let client = make_client(vec![Ok(check_yaml("abc00000"))]);
1314 let mut failed = vec![0usize];
1315 let mut successes = vec![];
1316 let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
1317 cmd.run_interactive_retry_check(
1318 &mut failed,
1319 &repo_view,
1320 &client,
1321 None,
1322 &[],
1323 &mut successes,
1324 &mut stdin,
1325 )
1326 .await
1327 .unwrap();
1328 assert!(
1329 failed.is_empty(),
1330 "retry succeeded → failed_indices cleared"
1331 );
1332 assert_eq!(successes.len(), 1);
1333 }
1334
1335 #[tokio::test]
1336 async fn interactive_retry_default_input_retries() {
1337 let (commit, _tmp) = make_check_commit("abc00000");
1339 let cmd = make_check_cmd(false);
1340 let repo_view = make_check_repo_view(vec![commit]);
1341 let client = make_client(vec![Ok(check_yaml("abc00000"))]);
1342 let mut failed = vec![0usize];
1343 let mut successes = vec![];
1344 let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
1345 cmd.run_interactive_retry_check(
1346 &mut failed,
1347 &repo_view,
1348 &client,
1349 None,
1350 &[],
1351 &mut successes,
1352 &mut stdin,
1353 )
1354 .await
1355 .unwrap();
1356 assert!(failed.is_empty());
1357 assert_eq!(successes.len(), 1);
1358 }
1359
1360 #[tokio::test]
1361 async fn interactive_retry_still_fails_then_skip() {
1362 let (commit, _tmp) = make_check_commit("abc00000");
1364 let cmd = make_check_cmd(false);
1365 let repo_view = make_check_repo_view(vec![commit]);
1366 let responses = errs(3);
1368 let client = make_client(responses);
1369 let mut failed = vec![0usize];
1370 let mut successes = vec![];
1371 let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
1372 cmd.run_interactive_retry_check(
1373 &mut failed,
1374 &repo_view,
1375 &client,
1376 None,
1377 &[],
1378 &mut successes,
1379 &mut stdin,
1380 )
1381 .await
1382 .unwrap();
1383 assert_eq!(failed, vec![0], "commit still failed after retry");
1384 assert!(successes.is_empty());
1385 }
1386
1387 #[tokio::test]
1388 async fn interactive_retry_invalid_input_then_skip() {
1389 let (commit, _tmp) = make_check_commit("abc00000");
1391 let cmd = make_check_cmd(false);
1392 let repo_view = make_check_repo_view(vec![commit]);
1393 let client = make_client(vec![]);
1394 let mut failed = vec![0usize];
1395 let mut successes = vec![];
1396 let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
1397 cmd.run_interactive_retry_check(
1398 &mut failed,
1399 &repo_view,
1400 &client,
1401 None,
1402 &[],
1403 &mut successes,
1404 &mut stdin,
1405 )
1406 .await
1407 .unwrap();
1408 assert_eq!(failed, vec![0]);
1409 assert!(successes.is_empty());
1410 }
1411
1412 #[tokio::test]
1413 async fn interactive_retry_eof_breaks_immediately() {
1414 let (commit, _tmp) = make_check_commit("abc00000");
1417 let cmd = make_check_cmd(false);
1418 let repo_view = make_check_repo_view(vec![commit]);
1419 let client = make_client(vec![]); let mut failed = vec![0usize];
1421 let mut successes = vec![];
1422 let mut stdin = std::io::Cursor::new(b"" as &[u8]);
1423 cmd.run_interactive_retry_check(
1424 &mut failed,
1425 &repo_view,
1426 &client,
1427 None,
1428 &[],
1429 &mut successes,
1430 &mut stdin,
1431 )
1432 .await
1433 .unwrap();
1434 assert_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
1435 assert!(successes.is_empty());
1436 }
1437
1438 fn make_amendment() -> crate::data::amendments::Amendment {
1441 crate::data::amendments::Amendment {
1442 commit: "abc0000000000000000000000000000000000001".to_string(),
1443 message: "feat: improved commit message".to_string(),
1444 summary: None,
1445 }
1446 }
1447
1448 #[tokio::test]
1449 async fn prompt_and_apply_suggestions_non_terminal_returns_false() {
1450 let cmd = make_check_cmd(false);
1452 let mut reader = std::io::Cursor::new(b"" as &[u8]);
1453 let result = cmd
1454 .prompt_and_apply_suggestions(vec![make_amendment()], false, &mut reader)
1455 .await
1456 .unwrap();
1457 assert!(!result, "non-terminal should return false");
1458 }
1459
1460 #[tokio::test]
1461 async fn prompt_and_apply_suggestions_eof_returns_false() {
1462 let cmd = make_check_cmd(false);
1464 let mut reader = std::io::Cursor::new(b"" as &[u8]);
1465 let result = cmd
1466 .prompt_and_apply_suggestions(vec![make_amendment()], true, &mut reader)
1467 .await
1468 .unwrap();
1469 assert!(!result, "EOF should return false");
1470 }
1471
1472 #[tokio::test]
1473 async fn prompt_and_apply_suggestions_quit_returns_false() {
1474 let cmd = make_check_cmd(false);
1476 let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
1477 let result = cmd
1478 .prompt_and_apply_suggestions(vec![make_amendment()], true, &mut reader)
1479 .await
1480 .unwrap();
1481 assert!(!result, "quit should return false");
1482 }
1483
1484 #[tokio::test]
1485 async fn prompt_and_apply_suggestions_invalid_then_quit_returns_false() {
1486 let cmd = make_check_cmd(false);
1488 let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
1489 let result = cmd
1490 .prompt_and_apply_suggestions(vec![make_amendment()], true, &mut reader)
1491 .await
1492 .unwrap();
1493 assert!(!result, "invalid then quit should return false");
1494 }
1495}