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