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