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, repo: Option<&std::path::Path>) -> Result<()> {
77 let repo_root = match repo {
80 Some(p) => p.to_path_buf(),
81 None => std::env::current_dir().context("Failed to determine current directory")?,
82 };
83 let repo_root = repo_root.as_path();
84
85 if let Some(bs) = self.batch_size {
87 eprintln!("warning: --batch-size is deprecated; use --concurrency instead");
88 self.concurrency = bs;
89 }
90 use crate::data::check::OutputFormat;
91
92 let output_format: OutputFormat = self.format.parse().unwrap_or(OutputFormat::Text);
94
95 let ai_info =
97 crate::utils::check_ai_command_prerequisites(self.model.as_deref(), repo_root)?;
98 if !self.quiet && output_format == OutputFormat::Text {
99 println!(
100 "✓ {} credentials verified (model: {})",
101 ai_info.provider, ai_info.model
102 );
103 }
104
105 if !self.quiet && output_format == OutputFormat::Text {
106 println!("🔍 Checking commit messages against guidelines...");
107 }
108
109 let mut repo_view = self.generate_repository_view(repo_root).await?;
111
112 if repo_view.commits.is_empty() {
114 eprintln!("error: no commits found in range");
115 std::process::exit(3);
116 }
117
118 if !self.quiet && output_format == OutputFormat::Text {
119 println!("📊 Found {} commits to check", repo_view.commits.len());
120 }
121
122 let guidelines = self.load_guidelines(repo_root).await?;
124 let valid_scopes = self.load_scopes(repo_root);
125
126 for commit in &mut repo_view.commits {
128 commit.analysis.refine_scope(&valid_scopes);
129 }
130
131 if !self.quiet && output_format == OutputFormat::Text {
132 self.show_guidance_files_status(repo_root, &guidelines, &valid_scopes);
133 }
134
135 let beta = self
137 .beta_header
138 .as_deref()
139 .map(parse_beta_header)
140 .transpose()?;
141 let claude_client =
142 crate::claude::create_default_claude_client(self.model.clone(), beta).await?;
143
144 if self.verbose && output_format == OutputFormat::Text {
145 self.show_model_info(&claude_client)?;
146 }
147
148 let report = if repo_view.commits.len() > 1 {
150 if !self.quiet && output_format == OutputFormat::Text {
151 println!(
152 "🔄 Processing {} commits in parallel (concurrency: {})...",
153 repo_view.commits.len(),
154 self.concurrency
155 );
156 }
157 self.check_with_map_reduce(
158 &claude_client,
159 &repo_view,
160 guidelines.as_deref(),
161 &valid_scopes,
162 )
163 .await?
164 } else {
165 if !self.quiet && output_format == OutputFormat::Text {
167 println!("🤖 Analyzing commits with AI...");
168 }
169 claude_client
170 .check_commits_with_scopes(
171 &repo_view,
172 guidelines.as_deref(),
173 &valid_scopes,
174 !self.no_suggestions,
175 )
176 .await?
177 };
178
179 self.output_report(&report, output_format)?;
181
182 if should_offer_twiddle(self.twiddle, report.has_errors(), output_format) {
184 use std::io::IsTerminal;
185 let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
186 if !amendments.is_empty()
187 && self
188 .prompt_and_apply_suggestions(
189 repo_root,
190 amendments,
191 std::io::stdin().is_terminal(),
192 &mut std::io::BufReader::new(std::io::stdin()),
193 )
194 .await?
195 {
196 return Ok(());
198 }
199 }
200
201 let exit_code = report.exit_code(self.strict);
203 if exit_code != 0 {
204 std::process::exit(exit_code);
205 }
206
207 Ok(())
208 }
209
210 async fn generate_repository_view(
212 &self,
213 repo_root: &std::path::Path,
214 ) -> Result<crate::data::RepositoryView> {
215 use crate::data::{
216 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
217 WorkingDirectoryInfo,
218 };
219 use crate::git::{GitRepository, RemoteInfo};
220 use crate::utils::ai_scratch;
221
222 let repo = GitRepository::open_at(repo_root)
224 .context("Failed to open git repository at the given path")?;
225
226 let current_branch = repo
228 .get_current_branch()
229 .unwrap_or_else(|_| "HEAD".to_string());
230
231 let commit_range = if let Some(range) = &self.commit_range {
233 range.clone()
234 } else {
235 let base = if repo.branch_exists("main")? {
237 "main"
238 } else if repo.branch_exists("master")? {
239 "master"
240 } else {
241 "HEAD~5"
242 };
243 format!("{base}..HEAD")
244 };
245
246 let wd_status = repo.get_working_directory_status()?;
248 let working_directory = WorkingDirectoryInfo {
249 clean: wd_status.clean,
250 untracked_changes: wd_status
251 .untracked_changes
252 .into_iter()
253 .map(|fs| FileStatusInfo {
254 status: fs.status,
255 file: fs.file,
256 })
257 .collect(),
258 };
259
260 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
262
263 let commits = repo.get_commits_in_range(&commit_range)?;
265
266 let versions = Some(VersionInfo {
268 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
269 });
270
271 let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
273 .context("Failed to determine AI scratch directory")?;
274 let ai_info = AiInfo {
275 scratch: ai_scratch_path.to_string_lossy().to_string(),
276 };
277
278 let mut repo_view = RepositoryView {
280 versions,
281 explanation: FieldExplanation::default(),
282 working_directory,
283 remotes,
284 ai: ai_info,
285 branch_info: Some(BranchInfo {
286 branch: current_branch,
287 }),
288 pr_template: None,
289 pr_template_location: None,
290 branch_prs: None,
291 commits,
292 };
293
294 repo_view.update_field_presence();
296
297 Ok(repo_view)
298 }
299
300 async fn load_guidelines(&self, repo_root: &std::path::Path) -> Result<Option<String>> {
302 if let Some(guidelines_path) = &self.guidelines {
304 let content = std::fs::read_to_string(guidelines_path).with_context(|| {
305 format!(
306 "Failed to read guidelines file: {}",
307 guidelines_path.display()
308 )
309 })?;
310 return Ok(Some(content));
311 }
312
313 let context_dir =
315 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
316 crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")
317 }
318
319 fn load_scopes(
321 &self,
322 repo_root: &std::path::Path,
323 ) -> Vec<crate::data::context::ScopeDefinition> {
324 let context_dir =
325 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
326 crate::claude::context::load_project_scopes(&context_dir, repo_root)
327 }
328
329 fn show_guidance_files_status(
331 &self,
332 repo_root: &std::path::Path,
333 guidelines: &Option<String>,
334 valid_scopes: &[crate::data::context::ScopeDefinition],
335 ) {
336 use crate::claude::context::{
337 config_source_label, resolve_context_dir_with_source_at, ConfigSourceLabel,
338 };
339
340 let (context_dir, dir_source) =
341 resolve_context_dir_with_source_at(self.context_dir.as_deref(), repo_root);
342
343 println!("📋 Project guidance files status:");
344 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
345
346 let guidelines_source = if guidelines.is_some() {
348 match config_source_label(&context_dir, "commit-guidelines.md") {
349 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
350 label => format!("✅ {label}"),
351 }
352 } else {
353 "⚪ Using defaults".to_string()
354 };
355 println!(" 📝 Commit guidelines: {guidelines_source}");
356
357 let scopes_count = valid_scopes.len();
359 let scopes_source = if scopes_count > 0 {
360 match config_source_label(&context_dir, "scopes.yaml") {
361 ConfigSourceLabel::NotFound => {
362 format!("✅ (source unknown) ({scopes_count} scopes)")
363 }
364 label => format!("✅ {label} ({scopes_count} scopes)"),
365 }
366 } else {
367 "⚪ None found (any scope accepted)".to_string()
368 };
369 println!(" 🎯 Valid scopes: {scopes_source}");
370
371 println!();
372 }
373
374 async fn check_with_map_reduce(
380 &self,
381 claude_client: &crate::claude::client::ClaudeClient,
382 full_repo_view: &crate::data::RepositoryView,
383 guidelines: Option<&str>,
384 valid_scopes: &[crate::data::context::ScopeDefinition],
385 ) -> Result<crate::data::check::CheckReport> {
386 use std::io::IsTerminal;
387 use std::sync::atomic::{AtomicUsize, Ordering};
388 use std::sync::Arc;
389
390 use crate::claude::batch;
391 use crate::claude::token_budget;
392 use crate::data::check::{CheckReport, CommitCheckResult};
393
394 let total_commits = full_repo_view.commits.len();
395
396 let metadata = claude_client.get_ai_client_metadata();
398 let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
399 guidelines,
400 valid_scopes,
401 );
402 let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
403 let batch_plan =
404 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
405
406 if !self.quiet && batch_plan.batches.len() < total_commits {
407 println!(
408 " 📦 Grouped {} commits into {} batches by token budget",
409 total_commits,
410 batch_plan.batches.len()
411 );
412 }
413
414 let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
415 let completed = Arc::new(AtomicUsize::new(0));
416
417 let futs: Vec<_> = batch_plan
419 .batches
420 .iter()
421 .map(|batch| {
422 let sem = semaphore.clone();
423 let completed = completed.clone();
424 let batch_indices = &batch.commit_indices;
425
426 async move {
427 let _permit = sem
428 .acquire()
429 .await
430 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
431
432 let batch_size = batch_indices.len();
433
434 let batch_view = if batch_size == 1 {
436 full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
437 } else {
438 let commits: Vec<_> = batch_indices
439 .iter()
440 .map(|&i| &full_repo_view.commits[i])
441 .collect();
442 full_repo_view.multi_commit_view(&commits)
443 };
444
445 let result = claude_client
446 .check_commits_with_scopes(
447 &batch_view,
448 guidelines,
449 valid_scopes,
450 !self.no_suggestions,
451 )
452 .await;
453
454 match result {
455 Ok(report) => {
456 let done =
457 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
458 if !self.quiet {
459 println!(" ✅ {done}/{total_commits} commits checked");
460 }
461
462 let items: Vec<_> = report
463 .commits
464 .into_iter()
465 .map(|r| {
466 let summary = r.summary.clone().unwrap_or_default();
467 (r, summary)
468 })
469 .collect();
470 Ok::<_, anyhow::Error>((items, vec![]))
471 }
472 Err(e) if batch_size > 1 => {
473 eprintln!(
475 "warning: batch of {batch_size} failed, retrying individually: {e}"
476 );
477 let mut items = Vec::new();
478 let mut failed_indices = Vec::new();
479 for &idx in batch_indices {
480 let single_view =
481 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
482 let single_result = claude_client
483 .check_commits_with_scopes(
484 &single_view,
485 guidelines,
486 valid_scopes,
487 !self.no_suggestions,
488 )
489 .await;
490 match single_result {
491 Ok(report) => {
492 if let Some(r) = report.commits.into_iter().next() {
493 let summary = r.summary.clone().unwrap_or_default();
494 items.push((r, summary));
495 }
496 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
497 if !self.quiet {
498 println!(
499 " ✅ {done}/{total_commits} commits checked"
500 );
501 }
502 }
503 Err(e) => {
504 eprintln!("warning: failed to check commit: {e}");
505 failed_indices.push(idx);
506 if !self.quiet {
507 println!(" ❌ commit check failed");
508 }
509 }
510 }
511 }
512 Ok((items, failed_indices))
513 }
514 Err(e) => {
515 let idx = batch_indices[0];
517 eprintln!("warning: failed to check commit: {e}");
518 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
519 if !self.quiet {
520 println!(" ❌ {done}/{total_commits} commits checked (failed)");
521 }
522 Ok((vec![], vec![idx]))
523 }
524 }
525 }
526 })
527 .collect();
528
529 let results = futures::future::join_all(futs).await;
530
531 let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
533 let mut failed_indices: Vec<usize> = Vec::new();
534
535 for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
536 match result {
537 Ok((items, failed)) => {
538 successes.extend(items);
539 failed_indices.extend(failed);
540 }
541 Err(e) => {
542 eprintln!("warning: batch processing error: {e}");
543 failed_indices.extend(&batch.commit_indices);
544 }
545 }
546 }
547
548 if !failed_indices.is_empty() && !self.quiet && std::io::stdin().is_terminal() {
550 self.run_interactive_retry_check(
551 &mut failed_indices,
552 full_repo_view,
553 claude_client,
554 guidelines,
555 valid_scopes,
556 &mut successes,
557 &mut std::io::BufReader::new(std::io::stdin()),
558 )
559 .await?;
560 } else if !failed_indices.is_empty() {
561 eprintln!(
562 "warning: {} commit(s) failed to check",
563 failed_indices.len()
564 );
565 }
566
567 if !failed_indices.is_empty() {
568 eprintln!(
569 "warning: {} commit(s) ultimately failed to check",
570 failed_indices.len()
571 );
572 }
573
574 if successes.is_empty() {
575 anyhow::bail!("All commits failed to check");
576 }
577
578 let single_batch = batch_plan.batches.len() <= 1;
581 if !self.no_coherence && !single_batch && successes.len() >= 2 {
582 if !self.quiet {
583 println!("🔗 Running cross-commit coherence pass...");
584 }
585 match claude_client
586 .refine_checks_coherence(&successes, full_repo_view)
587 .await
588 {
589 Ok(refined) => {
590 if !self.quiet {
591 println!("✅ All commits checked!");
592 }
593 return Ok(refined);
594 }
595 Err(e) => {
596 eprintln!("warning: coherence pass failed, using individual results: {e}");
597 }
598 }
599 }
600
601 if !self.quiet {
602 println!("✅ All commits checked!");
603 }
604
605 let all_results: Vec<CommitCheckResult> = successes.into_iter().map(|(r, _)| r).collect();
606
607 Ok(CheckReport::new(all_results))
608 }
609
610 fn output_report(
612 &self,
613 report: &crate::data::check::CheckReport,
614 format: crate::data::check::OutputFormat,
615 ) -> Result<()> {
616 use crate::data::check::OutputFormat;
617
618 match format {
619 OutputFormat::Text => self.output_text_report(report),
620 OutputFormat::Json => {
621 let json = serde_json::to_string_pretty(report)
622 .context("Failed to serialize report to JSON")?;
623 println!("{json}");
624 Ok(())
625 }
626 OutputFormat::Yaml => {
627 let yaml =
628 crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
629 println!("{yaml}");
630 Ok(())
631 }
632 }
633 }
634
635 fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
637 use crate::data::check::IssueSeverity;
638
639 println!();
640
641 for result in &report.commits {
642 if !should_display_commit(result.passes, self.show_passing) {
643 continue;
644 }
645
646 if self.quiet && !has_errors_or_warnings(&result.issues) {
648 continue;
649 }
650
651 let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
652 let short_hash = super::formatting::truncate_hash(&result.hash);
653 println!("{}", format_commit_line(icon, short_hash, &result.message));
654
655 for issue in &result.issues {
657 if self.quiet && issue.severity == IssueSeverity::Info {
659 continue;
660 }
661
662 let severity_str = super::formatting::format_severity_label(issue.severity);
663 println!(
664 " {} [{}] {}",
665 severity_str, issue.section, issue.explanation
666 );
667 }
668
669 if !self.quiet {
671 if let Some(suggestion) = &result.suggestion {
672 println!();
673 print!("{}", format_suggestion_text(suggestion, self.verbose));
674 }
675 }
676
677 println!();
678 }
679
680 println!("{}", format_summary_text(&report.summary));
682
683 Ok(())
684 }
685
686 fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
688 use crate::claude::model_config::get_model_registry;
689
690 println!("🤖 AI Model Configuration:");
691
692 let metadata = client.get_ai_client_metadata();
693 let registry = get_model_registry();
699
700 if let Some(spec) = registry.get_model_spec(&metadata.model) {
701 if metadata.model != spec.api_identifier {
702 println!(
703 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
704 metadata.model, spec.api_identifier
705 );
706 } else {
707 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
708 }
709 println!(" 🏷️ Provider: {}", spec.provider);
710 } else {
711 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
712 println!(" 🏷️ Provider: {}", metadata.provider);
713 }
714
715 println!();
716 Ok(())
717 }
718
719 fn build_amendments_from_suggestions(
721 &self,
722 report: &crate::data::check::CheckReport,
723 repo_view: &crate::data::RepositoryView,
724 ) -> Vec<crate::data::amendments::Amendment> {
725 use crate::data::amendments::Amendment;
726
727 let candidate_hashes: Vec<String> =
728 repo_view.commits.iter().map(|c| c.hash.clone()).collect();
729
730 report
731 .commits
732 .iter()
733 .filter(|r| !r.passes)
734 .filter_map(|r| {
735 let suggestion = r.suggestion.as_ref()?;
736 let full_hash = super::formatting::resolve_short_hash(&r.hash, &candidate_hashes)?;
737 Some(Amendment::new(
738 full_hash.to_string(),
739 suggestion.message.clone(),
740 ))
741 })
742 .collect()
743 }
744
745 async fn prompt_and_apply_suggestions(
751 &self,
752 repo_root: &std::path::Path,
753 amendments: Vec<crate::data::amendments::Amendment>,
754 is_terminal: bool,
755 reader: &mut (dyn std::io::BufRead + Send),
756 ) -> Result<bool> {
757 use crate::data::amendments::AmendmentFile;
758 use crate::git::AmendmentHandler;
759 use std::io::{self, Write};
760
761 println!();
762 println!(
763 "🔧 {} commit(s) have issues with suggested fixes available.",
764 amendments.len()
765 );
766
767 if !is_terminal {
768 eprintln!("warning: stdin is not interactive, cannot prompt to apply suggested fixes");
769 return Ok(false);
770 }
771
772 loop {
773 print!("❓ [A]pply suggested fixes, or [Q]uit? [A/q] ");
774 io::stdout().flush()?;
775
776 let Some(input) = super::read_interactive_line(reader)? else {
777 eprintln!("warning: stdin closed, not applying suggested fixes");
778 return Ok(false);
779 };
780
781 match input.trim().to_lowercase().as_str() {
782 "a" | "apply" | "" => {
783 let amendment_file = AmendmentFile { amendments };
784 let temp_file = tempfile::NamedTempFile::new()
785 .context("Failed to create temp file for amendments")?;
786 amendment_file
787 .save_to_file(temp_file.path())
788 .context("Failed to save amendments")?;
789
790 let handler = AmendmentHandler::new(repo_root)
791 .context("Failed to initialize amendment handler")?;
792 handler
793 .apply_amendments(&temp_file.path().to_string_lossy())
794 .context("Failed to apply amendments")?;
795
796 println!("✅ Suggested fixes applied successfully!");
797 return Ok(true);
798 }
799 "q" | "quit" => return Ok(false),
800 _ => {
801 println!("Invalid choice. Please enter 'a' to apply or 'q' to quit.");
802 }
803 }
804 }
805 }
806}
807
808impl CheckCommand {
811 #[allow(clippy::too_many_arguments)]
815 async fn run_interactive_retry_check(
816 &self,
817 failed_indices: &mut Vec<usize>,
818 full_repo_view: &crate::data::RepositoryView,
819 claude_client: &crate::claude::client::ClaudeClient,
820 guidelines: Option<&str>,
821 valid_scopes: &[crate::data::context::ScopeDefinition],
822 successes: &mut Vec<(crate::data::check::CommitCheckResult, String)>,
823 reader: &mut (dyn std::io::BufRead + Send),
824 ) -> Result<()> {
825 use std::io::Write as _;
826 println!("\n⚠️ {} commit(s) failed to check:", failed_indices.len());
827 for &idx in failed_indices.iter() {
828 let commit = &full_repo_view.commits[idx];
829 let subject = commit
830 .original_message
831 .lines()
832 .next()
833 .unwrap_or("(no message)");
834 println!(" - {}: {}", &commit.hash[..8], subject);
835 }
836 loop {
837 print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
838 std::io::stdout().flush()?;
839 let Some(input) = super::read_interactive_line(reader)? else {
840 eprintln!("warning: stdin closed, skipping failed commit(s)");
841 break;
842 };
843 match input.trim().to_lowercase().as_str() {
844 "r" | "retry" | "" => {
845 let mut still_failed = Vec::new();
846 for &idx in failed_indices.iter() {
847 let single_view =
848 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
849 match claude_client
850 .check_commits_with_scopes(
851 &single_view,
852 guidelines,
853 valid_scopes,
854 !self.no_suggestions,
855 )
856 .await
857 {
858 Ok(report) => {
859 if let Some(r) = report.commits.into_iter().next() {
860 let summary = r.summary.clone().unwrap_or_default();
861 successes.push((r, summary));
862 }
863 }
864 Err(e) => {
865 eprintln!("warning: still failed: {e}");
866 still_failed.push(idx);
867 }
868 }
869 }
870 *failed_indices = still_failed;
871 if failed_indices.is_empty() {
872 println!("✅ All retried commits succeeded.");
873 break;
874 }
875 println!("\n⚠️ {} commit(s) still failed:", failed_indices.len());
876 for &idx in failed_indices.iter() {
877 let commit = &full_repo_view.commits[idx];
878 let subject = commit
879 .original_message
880 .lines()
881 .next()
882 .unwrap_or("(no message)");
883 println!(" - {}: {}", &commit.hash[..8], subject);
884 }
885 }
886 "s" | "skip" => {
887 println!("Skipping {} failed commit(s).", failed_indices.len());
888 break;
889 }
890 _ => println!("Please enter 'r' to retry or 's' to skip."),
891 }
892 }
893 Ok(())
894 }
895}
896
897#[derive(Debug, Clone)]
899pub struct CheckOutcome {
900 pub report_yaml: String,
902 pub has_errors: bool,
904 pub has_warnings: bool,
906 pub total_commits: usize,
908 pub strict: bool,
910 pub exit_code: i32,
912}
913
914pub async fn run_check(
926 range: &str,
927 guidelines_path: Option<&std::path::Path>,
928 repo_path: Option<&std::path::Path>,
929 strict: bool,
930 model: Option<String>,
931) -> Result<CheckOutcome> {
932 let repo_root = match repo_path {
933 Some(p) => p.to_path_buf(),
934 None => std::env::current_dir().context("Failed to determine current directory")?,
935 };
936
937 crate::utils::check_ai_command_prerequisites(model.as_deref(), &repo_root)?;
939
940 let claude_client = crate::claude::create_default_claude_client(model, None).await?;
941 run_check_with_client(range, guidelines_path, strict, &claude_client, &repo_root).await
942}
943
944pub(crate) async fn run_check_with_client(
951 range: &str,
952 guidelines_path: Option<&std::path::Path>,
953 strict: bool,
954 claude_client: &crate::claude::client::ClaudeClient,
955 repo_root: &std::path::Path,
956) -> Result<CheckOutcome> {
957 use crate::data::{
958 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
959 WorkingDirectoryInfo,
960 };
961 use crate::git::{GitRepository, RemoteInfo};
962 use crate::utils::ai_scratch;
963
964 let repo = GitRepository::open_at(repo_root)
965 .context("Failed to open git repository at the given path")?;
966
967 let current_branch = repo
968 .get_current_branch()
969 .unwrap_or_else(|_| "HEAD".to_string());
970
971 let wd_status = repo.get_working_directory_status()?;
972 let working_directory = WorkingDirectoryInfo {
973 clean: wd_status.clean,
974 untracked_changes: wd_status
975 .untracked_changes
976 .into_iter()
977 .map(|fs| FileStatusInfo {
978 status: fs.status,
979 file: fs.file,
980 })
981 .collect(),
982 };
983
984 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
985 let commits = repo.get_commits_in_range(range)?;
986
987 if commits.is_empty() {
988 anyhow::bail!("no commits found in range: {range}");
989 }
990
991 let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
992 .context("Failed to determine AI scratch directory")?;
993 let ai_info = AiInfo {
994 scratch: ai_scratch_path.to_string_lossy().to_string(),
995 };
996
997 let mut repo_view = RepositoryView {
998 versions: Some(VersionInfo {
999 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1000 }),
1001 explanation: FieldExplanation::default(),
1002 working_directory,
1003 remotes,
1004 ai: ai_info,
1005 branch_info: Some(BranchInfo {
1006 branch: current_branch,
1007 }),
1008 pr_template: None,
1009 pr_template_location: None,
1010 branch_prs: None,
1011 commits,
1012 };
1013 repo_view.update_field_presence();
1014
1015 let guidelines = if let Some(path) = guidelines_path {
1016 Some(
1017 std::fs::read_to_string(path)
1018 .with_context(|| format!("Failed to read guidelines file: {}", path.display()))?,
1019 )
1020 } else {
1021 let context_dir = crate::claude::context::resolve_context_dir_at(None, repo_root);
1022 crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")?
1023 };
1024
1025 let context_dir = crate::claude::context::resolve_context_dir_at(None, repo_root);
1026 let valid_scopes = crate::claude::context::load_project_scopes(&context_dir, repo_root);
1027 for commit in &mut repo_view.commits {
1028 commit.analysis.refine_scope(&valid_scopes);
1029 }
1030
1031 let report = claude_client
1032 .check_commits_with_scopes(&repo_view, guidelines.as_deref(), &valid_scopes, true)
1033 .await?;
1034
1035 let report_yaml = crate::data::to_yaml(&report).context("Failed to serialise CheckReport")?;
1036 let has_errors = report.has_errors();
1037 let has_warnings = report.has_warnings();
1038 let exit_code = report.exit_code(strict);
1039 let total_commits = report.commits.len();
1040
1041 Ok(CheckOutcome {
1042 report_yaml,
1043 has_errors,
1044 has_warnings,
1045 total_commits,
1046 strict,
1047 exit_code,
1048 })
1049}
1050
1051#[cfg(test)]
1052#[allow(clippy::unwrap_used, clippy::expect_used)]
1053mod run_check_tests {
1054 use super::*;
1055 use crate::claude::client::ClaudeClient;
1056 use crate::claude::test_utils::ConfigurableMockAiClient;
1057 use git2::{Repository, Signature};
1058
1059 #[tokio::test]
1063 async fn run_check_with_client_invalid_repo_path_errors() {
1064 let mock = ConfigurableMockAiClient::new(vec![]);
1065 let client = ClaudeClient::new(Box::new(mock));
1066 let err = run_check_with_client(
1067 "HEAD",
1068 None,
1069 false,
1070 &client,
1071 std::path::Path::new("/no/such/path/exists"),
1072 )
1073 .await
1074 .unwrap_err();
1075 let msg = format!("{err:#}");
1076 assert!(
1077 msg.to_lowercase().contains("git") || msg.to_lowercase().contains("repository"),
1078 "expected git/repository error, got: {msg}"
1079 );
1080 }
1081
1082 fn init_test_repo() -> tempfile::TempDir {
1083 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1084 std::fs::create_dir_all(&tmp_root).unwrap();
1085 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1086 let repo = Repository::init(temp_dir.path()).unwrap();
1087 {
1088 let mut cfg = repo.config().unwrap();
1089 cfg.set_str("user.name", "Test").unwrap();
1090 cfg.set_str("user.email", "test@example.com").unwrap();
1091 }
1092 let signature = Signature::now("Test", "test@example.com").unwrap();
1093 std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
1094 let mut idx = repo.index().unwrap();
1095 idx.add_path(std::path::Path::new("f.txt")).unwrap();
1096 idx.write().unwrap();
1097 let tree_id = idx.write_tree().unwrap();
1098 let tree = repo.find_tree(tree_id).unwrap();
1099 repo.commit(
1100 Some("HEAD"),
1101 &signature,
1102 &signature,
1103 "feat(cli): only",
1104 &tree,
1105 &[],
1106 )
1107 .unwrap();
1108 temp_dir
1109 }
1110
1111 fn passing_check_yaml(hash_prefix: &str) -> String {
1112 format!("checks:\n - commit: {hash_prefix}\n passes: true\n issues: []\n")
1113 }
1114
1115 fn failing_check_yaml(hash_prefix: &str) -> String {
1116 format!(
1117 "checks:\n - commit: {hash_prefix}\n passes: false\n issues:\n - severity: error\n section: subject\n rule: format\n explanation: bad\n"
1118 )
1119 }
1120
1121 #[tokio::test]
1122 async fn run_check_with_client_happy_path_passing() {
1123 let temp_dir = init_test_repo();
1124
1125 let mock = ConfigurableMockAiClient::new(vec![Ok(passing_check_yaml("00000000"))]);
1127 let client = ClaudeClient::new(Box::new(mock));
1128
1129 let outcome = run_check_with_client("HEAD", None, false, &client, temp_dir.path())
1130 .await
1131 .unwrap();
1132 assert!(!outcome.has_errors);
1133 assert!(!outcome.has_warnings);
1134 assert_eq!(outcome.exit_code, 0);
1135 assert_eq!(outcome.total_commits, 1);
1136 assert!(outcome.report_yaml.contains("commits:"));
1137 assert!(!outcome.strict);
1138 }
1139
1140 #[tokio::test]
1141 async fn run_check_with_client_failing_commit_sets_error_exit_code() {
1142 let temp_dir = init_test_repo();
1143
1144 let mock = ConfigurableMockAiClient::new(vec![Ok(failing_check_yaml("00000000"))]);
1145 let client = ClaudeClient::new(Box::new(mock));
1146
1147 let outcome = run_check_with_client("HEAD", None, false, &client, temp_dir.path())
1148 .await
1149 .unwrap();
1150 assert!(outcome.has_errors);
1151 assert_eq!(outcome.exit_code, 1);
1152 }
1153
1154 #[tokio::test]
1155 async fn run_check_with_client_strict_does_not_affect_no_issues() {
1156 let temp_dir = init_test_repo();
1157
1158 let mock = ConfigurableMockAiClient::new(vec![Ok(passing_check_yaml("00000000"))]);
1159 let client = ClaudeClient::new(Box::new(mock));
1160
1161 let outcome = run_check_with_client("HEAD", None, true, &client, temp_dir.path())
1162 .await
1163 .unwrap();
1164 assert_eq!(outcome.exit_code, 0);
1165 assert!(outcome.strict);
1166 }
1167
1168 #[tokio::test]
1169 async fn run_check_with_client_explicit_guidelines_path() {
1170 let temp_dir = init_test_repo();
1171 let guidelines_path = temp_dir.path().join("guidelines.md");
1172 std::fs::write(&guidelines_path, "guideline body").unwrap();
1173
1174 let mock = ConfigurableMockAiClient::new(vec![Ok(passing_check_yaml("00000000"))]);
1175 let client = ClaudeClient::new(Box::new(mock));
1176
1177 let outcome = run_check_with_client(
1178 "HEAD",
1179 Some(&guidelines_path),
1180 false,
1181 &client,
1182 temp_dir.path(),
1183 )
1184 .await
1185 .unwrap();
1186 assert_eq!(outcome.exit_code, 0);
1187 }
1188
1189 #[tokio::test]
1190 async fn run_check_with_client_guidelines_path_missing_errors() {
1191 let temp_dir = init_test_repo();
1192 let missing = temp_dir.path().join("no-such.md");
1193
1194 let mock = ConfigurableMockAiClient::new(vec![Ok(passing_check_yaml("00000000"))]);
1195 let client = ClaudeClient::new(Box::new(mock));
1196 let err = run_check_with_client("HEAD", Some(&missing), false, &client, temp_dir.path())
1197 .await
1198 .unwrap_err();
1199 assert!(
1200 format!("{err:#}").contains("guidelines"),
1201 "expected guidelines read error"
1202 );
1203 }
1204
1205 #[tokio::test]
1206 async fn run_check_with_client_empty_range_bails() {
1207 let temp_dir = init_test_repo();
1208
1209 let mock = ConfigurableMockAiClient::new(vec![]);
1210 let client = ClaudeClient::new(Box::new(mock));
1211 let err = run_check_with_client("HEAD..HEAD", None, false, &client, temp_dir.path())
1213 .await
1214 .unwrap_err();
1215 assert!(format!("{err:#}").contains("no commits"));
1216 }
1217
1218 #[tokio::test]
1219 async fn run_check_with_client_ai_failure_propagates() {
1220 let temp_dir = init_test_repo();
1221
1222 let mock = ConfigurableMockAiClient::new(vec![]);
1225 let client = ClaudeClient::new(Box::new(mock));
1226 let err = run_check_with_client("HEAD", None, false, &client, temp_dir.path())
1227 .await
1228 .unwrap_err();
1229 let _ = err; }
1231
1232 #[test]
1233 fn check_outcome_clone_and_debug() {
1234 let outcome = CheckOutcome {
1236 report_yaml: "x".to_string(),
1237 has_errors: false,
1238 has_warnings: true,
1239 total_commits: 1,
1240 strict: true,
1241 exit_code: 2,
1242 };
1243 let cloned = outcome.clone();
1244 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
1245 }
1246
1247 #[tokio::test]
1252 async fn run_check_with_client_loads_guidelines_from_injected_repo() {
1253 let temp_dir = init_test_repo();
1254 let omni_dir = temp_dir.path().join(".omni-dev");
1255 std::fs::create_dir_all(&omni_dir).unwrap();
1256 std::fs::write(
1257 omni_dir.join("commit-guidelines.md"),
1258 "# Project rules\n\nDISTINCTIVE_GUIDELINE_MARKER: always do the thing.\n",
1259 )
1260 .unwrap();
1261
1262 let mock = ConfigurableMockAiClient::new(vec![Ok(passing_check_yaml("00000000"))]);
1263 let prompts = mock.prompt_handle();
1264 let client = ClaudeClient::new(Box::new(mock));
1265
1266 let _ = run_check_with_client("HEAD", None, false, &client, temp_dir.path())
1267 .await
1268 .unwrap();
1269
1270 let recorded = prompts.prompts();
1271 assert!(!recorded.is_empty(), "expected at least one AI call");
1272 assert!(
1273 recorded.iter().any(|(s, u)| {
1274 s.contains("DISTINCTIVE_GUIDELINE_MARKER")
1275 || u.contains("DISTINCTIVE_GUIDELINE_MARKER")
1276 }),
1277 "guidelines from the injected repo must reach the prompt: {recorded:?}"
1278 );
1279 }
1280}
1281
1282fn should_display_commit(passes: bool, show_passing: bool) -> bool {
1286 !passes || show_passing
1287}
1288
1289fn has_errors_or_warnings(issues: &[crate::data::check::CommitIssue]) -> bool {
1291 use crate::data::check::IssueSeverity;
1292 issues
1293 .iter()
1294 .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning))
1295}
1296
1297fn should_offer_twiddle(
1299 twiddle_flag: bool,
1300 has_errors: bool,
1301 format: crate::data::check::OutputFormat,
1302) -> bool {
1303 twiddle_flag && has_errors && format == crate::data::check::OutputFormat::Text
1304}
1305
1306fn format_suggestion_text(
1308 suggestion: &crate::data::check::CommitSuggestion,
1309 verbose: bool,
1310) -> String {
1311 let mut output = String::new();
1312 output.push_str(" Suggested message:\n");
1313 for line in suggestion.message.lines() {
1314 output.push_str(&format!(" {line}\n"));
1315 }
1316 if verbose {
1317 output.push('\n');
1318 output.push_str(" Why this is better:\n");
1319 for line in suggestion.explanation.lines() {
1320 output.push_str(&format!(" {line}\n"));
1321 }
1322 }
1323 output
1324}
1325
1326fn format_summary_text(summary: &crate::data::check::CheckSummary) -> String {
1328 format!(
1329 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
1330 Summary: {} commits checked\n\
1331 \x20 {} errors, {} warnings\n\
1332 \x20 {} passed, {} with issues",
1333 summary.total_commits,
1334 summary.error_count,
1335 summary.warning_count,
1336 summary.passing_commits,
1337 summary.failing_commits,
1338 )
1339}
1340
1341fn format_commit_line(icon: &str, short_hash: &str, message: &str) -> String {
1343 format!("{icon} {short_hash} - \"{message}\"")
1344}
1345
1346#[cfg(test)]
1347#[allow(clippy::unwrap_used, clippy::expect_used)]
1348mod tests {
1349 use super::*;
1350 use crate::data::check::{
1351 CheckSummary, CommitIssue, CommitSuggestion, IssueSeverity, OutputFormat,
1352 };
1353
1354 #[test]
1357 fn display_commit_passing_hidden() {
1358 assert!(!should_display_commit(true, false));
1359 }
1360
1361 #[test]
1362 fn display_commit_passing_shown() {
1363 assert!(should_display_commit(true, true));
1364 }
1365
1366 #[test]
1367 fn display_commit_failing() {
1368 assert!(should_display_commit(false, false));
1369 assert!(should_display_commit(false, true));
1370 }
1371
1372 #[test]
1375 fn errors_or_warnings_with_error() {
1376 let issues = vec![CommitIssue {
1377 severity: IssueSeverity::Error,
1378 section: "subject".to_string(),
1379 rule: "length".to_string(),
1380 explanation: "too long".to_string(),
1381 }];
1382 assert!(has_errors_or_warnings(&issues));
1383 }
1384
1385 #[test]
1386 fn errors_or_warnings_with_warning() {
1387 let issues = vec![CommitIssue {
1388 severity: IssueSeverity::Warning,
1389 section: "body".to_string(),
1390 rule: "style".to_string(),
1391 explanation: "minor issue".to_string(),
1392 }];
1393 assert!(has_errors_or_warnings(&issues));
1394 }
1395
1396 #[test]
1397 fn errors_or_warnings_info_only() {
1398 let issues = vec![CommitIssue {
1399 severity: IssueSeverity::Info,
1400 section: "body".to_string(),
1401 rule: "suggestion".to_string(),
1402 explanation: "consider adding more detail".to_string(),
1403 }];
1404 assert!(!has_errors_or_warnings(&issues));
1405 }
1406
1407 #[test]
1408 fn errors_or_warnings_empty() {
1409 assert!(!has_errors_or_warnings(&[]));
1410 }
1411
1412 #[test]
1415 fn offer_twiddle_all_conditions_met() {
1416 assert!(should_offer_twiddle(true, true, OutputFormat::Text));
1417 }
1418
1419 #[test]
1420 fn offer_twiddle_flag_off() {
1421 assert!(!should_offer_twiddle(false, true, OutputFormat::Text));
1422 }
1423
1424 #[test]
1425 fn offer_twiddle_no_errors() {
1426 assert!(!should_offer_twiddle(true, false, OutputFormat::Text));
1427 }
1428
1429 #[test]
1430 fn offer_twiddle_json_format() {
1431 assert!(!should_offer_twiddle(true, true, OutputFormat::Json));
1432 }
1433
1434 #[test]
1437 fn suggestion_text_basic() {
1438 let suggestion = CommitSuggestion {
1439 message: "feat(cli): add new flag".to_string(),
1440 explanation: "uses conventional format".to_string(),
1441 };
1442 let result = format_suggestion_text(&suggestion, false);
1443 assert!(result.contains("Suggested message:"));
1444 assert!(result.contains("feat(cli): add new flag"));
1445 assert!(!result.contains("Why this is better"));
1446 }
1447
1448 #[test]
1449 fn suggestion_text_verbose() {
1450 let suggestion = CommitSuggestion {
1451 message: "fix: resolve crash".to_string(),
1452 explanation: "clear description of fix".to_string(),
1453 };
1454 let result = format_suggestion_text(&suggestion, true);
1455 assert!(result.contains("Suggested message:"));
1456 assert!(result.contains("fix: resolve crash"));
1457 assert!(result.contains("Why this is better:"));
1458 assert!(result.contains("clear description of fix"));
1459 }
1460
1461 #[test]
1464 fn summary_text_formatting() {
1465 let summary = CheckSummary {
1466 total_commits: 5,
1467 passing_commits: 3,
1468 failing_commits: 2,
1469 error_count: 1,
1470 warning_count: 4,
1471 info_count: 0,
1472 };
1473 let result = format_summary_text(&summary);
1474 assert!(result.contains("5 commits checked"));
1475 assert!(result.contains("1 errors, 4 warnings"));
1476 assert!(result.contains("3 passed, 2 with issues"));
1477 }
1478
1479 #[test]
1482 fn commit_line_formatting() {
1483 let line = format_commit_line("✅", "abc1234", "feat: add feature");
1484 assert_eq!(line, "✅ abc1234 - \"feat: add feature\"");
1485 }
1486
1487 fn make_check_cmd(quiet: bool) -> CheckCommand {
1490 CheckCommand {
1491 commit_range: None,
1492 model: None,
1493 beta_header: None,
1494 context_dir: None,
1495 guidelines: None,
1496 format: "text".to_string(),
1497 strict: false,
1498 quiet,
1499 verbose: false,
1500 show_passing: false,
1501 concurrency: 4,
1502 batch_size: None,
1503 no_coherence: true,
1504 no_suggestions: false,
1505 twiddle: false,
1506 }
1507 }
1508
1509 fn make_check_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
1510 use crate::git::commit::FileChanges;
1511 use crate::git::{CommitAnalysis, CommitInfo};
1512 let tmp = tempfile::NamedTempFile::new().unwrap();
1513 let commit = CommitInfo {
1514 hash: hash.to_string(),
1515 author: "Test <test@test.com>".to_string(),
1516 date: chrono::Utc::now().fixed_offset(),
1517 original_message: format!("feat: commit {hash}"),
1518 in_main_branches: vec![],
1519 analysis: CommitAnalysis {
1520 detected_type: "feat".to_string(),
1521 detected_scope: String::new(),
1522 proposed_message: format!("feat: commit {hash}"),
1523 file_changes: FileChanges {
1524 total_files: 0,
1525 files_added: 0,
1526 files_deleted: 0,
1527 file_list: vec![],
1528 },
1529 diff_summary: String::new(),
1530 diff_file: tmp.path().to_string_lossy().to_string(),
1531 file_diffs: Vec::new(),
1532 },
1533 };
1534 (commit, tmp)
1535 }
1536
1537 fn make_check_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
1538 use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
1539 RepositoryView {
1540 versions: None,
1541 explanation: FieldExplanation::default(),
1542 working_directory: WorkingDirectoryInfo {
1543 clean: true,
1544 untracked_changes: vec![],
1545 },
1546 remotes: vec![],
1547 ai: AiInfo {
1548 scratch: String::new(),
1549 },
1550 branch_info: None,
1551 pr_template: None,
1552 pr_template_location: None,
1553 branch_prs: None,
1554 commits,
1555 }
1556 }
1557
1558 fn check_yaml(hash: &str) -> String {
1559 format!("checks:\n - commit: {hash}\n passes: true\n issues: []\n")
1560 }
1561
1562 fn make_client(responses: Vec<anyhow::Result<String>>) -> crate::claude::client::ClaudeClient {
1563 crate::claude::client::ClaudeClient::new(Box::new(
1564 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
1565 ))
1566 }
1567
1568 fn errs(n: usize) -> Vec<anyhow::Result<String>> {
1571 (0..n)
1572 .map(|_| Err(anyhow::anyhow!("mock failure")))
1573 .collect()
1574 }
1575
1576 #[tokio::test]
1577 async fn check_with_map_reduce_single_commit_fails_returns_err() {
1578 let (commit, _tmp) = make_check_commit("abc00000");
1582 let cmd = make_check_cmd(true);
1583 let repo_view = make_check_repo_view(vec![commit]);
1584 let client = make_client(errs(3));
1585 let result = cmd
1586 .check_with_map_reduce(&client, &repo_view, None, &[])
1587 .await;
1588 assert!(result.is_err(), "empty successes should bail");
1589 }
1590
1591 #[tokio::test]
1592 async fn check_with_map_reduce_single_commit_succeeds() {
1593 let (commit, _tmp) = make_check_commit("abc00000");
1595 let cmd = make_check_cmd(true);
1596 let repo_view = make_check_repo_view(vec![commit]);
1597 let client = make_client(vec![Ok(check_yaml("abc00000"))]);
1598 let result = cmd
1599 .check_with_map_reduce(&client, &repo_view, None, &[])
1600 .await;
1601 assert!(result.is_ok());
1602 assert_eq!(result.unwrap().commits.len(), 1);
1603 }
1604
1605 #[tokio::test]
1606 async fn check_with_map_reduce_batch_fails_split_retry_both_succeed() {
1607 let (c1, _t1) = make_check_commit("abc00000");
1610 let (c2, _t2) = make_check_commit("def00000");
1611 let cmd = make_check_cmd(true);
1612 let repo_view = make_check_repo_view(vec![c1, c2]);
1613 let mut responses = errs(3); responses.push(Ok(check_yaml("abc00000"))); responses.push(Ok(check_yaml("def00000"))); let client = make_client(responses);
1617 let result = cmd
1618 .check_with_map_reduce(&client, &repo_view, None, &[])
1619 .await;
1620 assert!(result.is_ok());
1621 assert_eq!(result.unwrap().commits.len(), 2);
1622 }
1623
1624 #[tokio::test]
1625 async fn check_with_map_reduce_batch_fails_split_one_individual_fails_quiet() {
1626 let (c1, _t1) = make_check_commit("abc00000");
1630 let (c2, _t2) = make_check_commit("def00000");
1631 let cmd = make_check_cmd(true);
1632 let repo_view = make_check_repo_view(vec![c1, c2]);
1633 let mut responses = errs(3); responses.push(Ok(check_yaml("abc00000"))); responses.extend(errs(3)); let client = make_client(responses);
1637 let result = cmd
1638 .check_with_map_reduce(&client, &repo_view, None, &[])
1639 .await;
1640 assert!(result.is_ok());
1642 assert_eq!(result.unwrap().commits.len(), 1);
1643 }
1644
1645 #[tokio::test]
1646 async fn check_with_map_reduce_all_fail_in_split_retry_returns_err() {
1647 let (c1, _t1) = make_check_commit("abc00000");
1650 let (c2, _t2) = make_check_commit("def00000");
1651 let cmd = make_check_cmd(true);
1652 let repo_view = make_check_repo_view(vec![c1, c2]);
1653 let mut responses = errs(3); responses.extend(errs(3)); responses.extend(errs(3)); let client = make_client(responses);
1657 let result = cmd
1658 .check_with_map_reduce(&client, &repo_view, None, &[])
1659 .await;
1660 assert!(result.is_err(), "no successes should bail");
1661 }
1662
1663 #[tokio::test]
1669 async fn check_with_map_reduce_non_quiet_single_commit_succeeds() {
1670 let (c1, _t1) = make_check_commit("abc00000");
1674 let (c2, _t2) = make_check_commit("def00000");
1675 let cmd = make_check_cmd(false);
1676 let repo_view = make_check_repo_view(vec![c1, c2]);
1677 let mut responses = errs(3); responses.push(Ok(check_yaml("abc00000")));
1679 responses.push(Ok(check_yaml("def00000")));
1680 let client = make_client(responses);
1681 let result = cmd
1682 .check_with_map_reduce(&client, &repo_view, None, &[])
1683 .await;
1684 assert!(result.is_ok());
1685 assert_eq!(result.unwrap().commits.len(), 2);
1686 }
1687
1688 #[tokio::test]
1691 async fn interactive_retry_skip_immediately() {
1692 let (commit, _tmp) = make_check_commit("abc00000");
1694 let cmd = make_check_cmd(false);
1695 let repo_view = make_check_repo_view(vec![commit]);
1696 let client = make_client(vec![]); let mut failed = vec![0usize];
1698 let mut successes = vec![];
1699 let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
1700 cmd.run_interactive_retry_check(
1701 &mut failed,
1702 &repo_view,
1703 &client,
1704 None,
1705 &[],
1706 &mut successes,
1707 &mut stdin,
1708 )
1709 .await
1710 .unwrap();
1711 assert_eq!(
1712 failed,
1713 vec![0],
1714 "skip should leave failed_indices unchanged"
1715 );
1716 assert!(successes.is_empty());
1717 }
1718
1719 #[tokio::test]
1720 async fn interactive_retry_retry_succeeds() {
1721 let (commit, _tmp) = make_check_commit("abc00000");
1723 let cmd = make_check_cmd(false);
1724 let repo_view = make_check_repo_view(vec![commit]);
1725 let client = make_client(vec![Ok(check_yaml("abc00000"))]);
1726 let mut failed = vec![0usize];
1727 let mut successes = vec![];
1728 let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
1729 cmd.run_interactive_retry_check(
1730 &mut failed,
1731 &repo_view,
1732 &client,
1733 None,
1734 &[],
1735 &mut successes,
1736 &mut stdin,
1737 )
1738 .await
1739 .unwrap();
1740 assert!(
1741 failed.is_empty(),
1742 "retry succeeded → failed_indices cleared"
1743 );
1744 assert_eq!(successes.len(), 1);
1745 }
1746
1747 #[tokio::test]
1748 async fn interactive_retry_default_input_retries() {
1749 let (commit, _tmp) = make_check_commit("abc00000");
1751 let cmd = make_check_cmd(false);
1752 let repo_view = make_check_repo_view(vec![commit]);
1753 let client = make_client(vec![Ok(check_yaml("abc00000"))]);
1754 let mut failed = vec![0usize];
1755 let mut successes = vec![];
1756 let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
1757 cmd.run_interactive_retry_check(
1758 &mut failed,
1759 &repo_view,
1760 &client,
1761 None,
1762 &[],
1763 &mut successes,
1764 &mut stdin,
1765 )
1766 .await
1767 .unwrap();
1768 assert!(failed.is_empty());
1769 assert_eq!(successes.len(), 1);
1770 }
1771
1772 #[tokio::test]
1773 async fn interactive_retry_still_fails_then_skip() {
1774 let (commit, _tmp) = make_check_commit("abc00000");
1776 let cmd = make_check_cmd(false);
1777 let repo_view = make_check_repo_view(vec![commit]);
1778 let responses = errs(3);
1780 let client = make_client(responses);
1781 let mut failed = vec![0usize];
1782 let mut successes = vec![];
1783 let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
1784 cmd.run_interactive_retry_check(
1785 &mut failed,
1786 &repo_view,
1787 &client,
1788 None,
1789 &[],
1790 &mut successes,
1791 &mut stdin,
1792 )
1793 .await
1794 .unwrap();
1795 assert_eq!(failed, vec![0], "commit still failed after retry");
1796 assert!(successes.is_empty());
1797 }
1798
1799 #[tokio::test]
1800 async fn interactive_retry_invalid_input_then_skip() {
1801 let (commit, _tmp) = make_check_commit("abc00000");
1803 let cmd = make_check_cmd(false);
1804 let repo_view = make_check_repo_view(vec![commit]);
1805 let client = make_client(vec![]);
1806 let mut failed = vec![0usize];
1807 let mut successes = vec![];
1808 let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
1809 cmd.run_interactive_retry_check(
1810 &mut failed,
1811 &repo_view,
1812 &client,
1813 None,
1814 &[],
1815 &mut successes,
1816 &mut stdin,
1817 )
1818 .await
1819 .unwrap();
1820 assert_eq!(failed, vec![0]);
1821 assert!(successes.is_empty());
1822 }
1823
1824 #[tokio::test]
1825 async fn interactive_retry_eof_breaks_immediately() {
1826 let (commit, _tmp) = make_check_commit("abc00000");
1829 let cmd = make_check_cmd(false);
1830 let repo_view = make_check_repo_view(vec![commit]);
1831 let client = make_client(vec![]); let mut failed = vec![0usize];
1833 let mut successes = vec![];
1834 let mut stdin = std::io::Cursor::new(b"" as &[u8]);
1835 cmd.run_interactive_retry_check(
1836 &mut failed,
1837 &repo_view,
1838 &client,
1839 None,
1840 &[],
1841 &mut successes,
1842 &mut stdin,
1843 )
1844 .await
1845 .unwrap();
1846 assert_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
1847 assert!(successes.is_empty());
1848 }
1849
1850 fn make_amendment() -> crate::data::amendments::Amendment {
1853 crate::data::amendments::Amendment {
1854 commit: "abc0000000000000000000000000000000000001".to_string(),
1855 message: "feat: improved commit message".to_string(),
1856 summary: String::new(),
1857 }
1858 }
1859
1860 #[tokio::test]
1861 async fn prompt_and_apply_suggestions_non_terminal_returns_false() {
1862 let cmd = make_check_cmd(false);
1864 let mut reader = std::io::Cursor::new(b"" as &[u8]);
1865 let result = cmd
1866 .prompt_and_apply_suggestions(
1867 std::path::Path::new("."),
1868 vec![make_amendment()],
1869 false,
1870 &mut reader,
1871 )
1872 .await
1873 .unwrap();
1874 assert!(!result, "non-terminal should return false");
1875 }
1876
1877 #[tokio::test]
1878 async fn prompt_and_apply_suggestions_eof_returns_false() {
1879 let cmd = make_check_cmd(false);
1881 let mut reader = std::io::Cursor::new(b"" as &[u8]);
1882 let result = cmd
1883 .prompt_and_apply_suggestions(
1884 std::path::Path::new("."),
1885 vec![make_amendment()],
1886 true,
1887 &mut reader,
1888 )
1889 .await
1890 .unwrap();
1891 assert!(!result, "EOF should return false");
1892 }
1893
1894 #[tokio::test]
1895 async fn prompt_and_apply_suggestions_quit_returns_false() {
1896 let cmd = make_check_cmd(false);
1898 let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
1899 let result = cmd
1900 .prompt_and_apply_suggestions(
1901 std::path::Path::new("."),
1902 vec![make_amendment()],
1903 true,
1904 &mut reader,
1905 )
1906 .await
1907 .unwrap();
1908 assert!(!result, "quit should return false");
1909 }
1910
1911 #[tokio::test]
1912 async fn prompt_and_apply_suggestions_invalid_then_quit_returns_false() {
1913 let cmd = make_check_cmd(false);
1915 let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
1916 let result = cmd
1917 .prompt_and_apply_suggestions(
1918 std::path::Path::new("."),
1919 vec![make_amendment()],
1920 true,
1921 &mut reader,
1922 )
1923 .await
1924 .unwrap();
1925 assert!(!result, "invalid then quit should return false");
1926 }
1927}