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