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