Skip to main content

omni_dev/cli/git/
check.rs

1//! Check command — validates commit messages against guidelines.
2
3use anyhow::{Context, Result};
4use clap::Parser;
5
6use super::parse_beta_header;
7
8/// Check command options - validates commit messages against guidelines.
9#[derive(Parser)]
10pub struct CheckCommand {
11    /// Commit range to check (e.g., HEAD~3..HEAD, abc123..def456).
12    /// Defaults to commits ahead of main branch.
13    #[arg(value_name = "COMMIT_RANGE")]
14    pub commit_range: Option<String>,
15
16    /// Claude API model to use (if not specified, uses settings or default).
17    #[arg(long)]
18    pub model: Option<String>,
19
20    /// Beta header to send with API requests (format: key:value).
21    /// Only sent if the model supports it in the registry.
22    #[arg(long, value_name = "KEY:VALUE")]
23    pub beta_header: Option<String>,
24
25    /// Path to custom context directory (defaults to .omni-dev/).
26    #[arg(long)]
27    pub context_dir: Option<std::path::PathBuf>,
28
29    /// Explicit path to guidelines file.
30    #[arg(long)]
31    pub guidelines: Option<std::path::PathBuf>,
32
33    /// Output format: text (default), json, yaml.
34    #[arg(long, default_value = "text")]
35    pub format: String,
36
37    /// Exits with error code if any issues found (including warnings).
38    #[arg(long)]
39    pub strict: bool,
40
41    /// Only shows errors/warnings, suppresses info-level output.
42    #[arg(long)]
43    pub quiet: bool,
44
45    /// Shows detailed analysis including passing commits.
46    #[arg(long)]
47    pub verbose: bool,
48
49    /// Includes passing commits in output (hidden by default).
50    #[arg(long)]
51    pub show_passing: bool,
52
53    /// Maximum number of concurrent AI requests (default: 4).
54    #[arg(long, default_value = "4")]
55    pub concurrency: usize,
56
57    /// Deprecated: use --concurrency instead.
58    #[arg(long, hide = true)]
59    pub batch_size: Option<usize>,
60
61    /// Disables the cross-commit coherence pass.
62    #[arg(long)]
63    pub no_coherence: bool,
64
65    /// Skips generating corrected message suggestions.
66    #[arg(long)]
67    pub no_suggestions: bool,
68
69    /// Offers to apply suggested messages when issues are found.
70    #[arg(long)]
71    pub twiddle: bool,
72}
73
74impl CheckCommand {
75    /// Executes the check command, validating commit messages against guidelines.
76    pub async fn execute(mut self) -> Result<()> {
77        // Resolve deprecated --batch-size into --concurrency
78        if let Some(bs) = self.batch_size {
79            eprintln!("warning: --batch-size is deprecated; use --concurrency instead");
80            self.concurrency = bs;
81        }
82        use crate::data::check::OutputFormat;
83
84        // Parse output format
85        let output_format: OutputFormat = self.format.parse().unwrap_or(OutputFormat::Text);
86
87        // Preflight check: validate AI credentials before any processing
88        let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
89        if !self.quiet && output_format == OutputFormat::Text {
90            println!(
91                "✓ {} credentials verified (model: {})",
92                ai_info.provider, ai_info.model
93            );
94        }
95
96        if !self.quiet && output_format == OutputFormat::Text {
97            println!("🔍 Checking commit messages against guidelines...");
98        }
99
100        // 1. Generate repository view to get all commits
101        let mut repo_view = self.generate_repository_view().await?;
102
103        // 2. Check for empty commit range (exit code 3)
104        if repo_view.commits.is_empty() {
105            eprintln!("error: no commits found in range");
106            std::process::exit(3);
107        }
108
109        if !self.quiet && output_format == OutputFormat::Text {
110            println!("📊 Found {} commits to check", repo_view.commits.len());
111        }
112
113        // 3. Load commit guidelines and scopes
114        let guidelines = self.load_guidelines().await?;
115        let valid_scopes = self.load_scopes();
116
117        // Refine detected scopes using file_patterns from scope definitions
118        for commit in &mut repo_view.commits {
119            commit.analysis.refine_scope(&valid_scopes);
120        }
121
122        if !self.quiet && output_format == OutputFormat::Text {
123            self.show_guidance_files_status(&guidelines, &valid_scopes);
124        }
125
126        // 4. Initialize Claude client
127        let beta = self
128            .beta_header
129            .as_deref()
130            .map(parse_beta_header)
131            .transpose()?;
132        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
133
134        if self.verbose && output_format == OutputFormat::Text {
135            self.show_model_info(&claude_client)?;
136        }
137
138        // 5. Use parallel map-reduce for multiple commits, direct call for single
139        let report = if repo_view.commits.len() > 1 {
140            if !self.quiet && output_format == OutputFormat::Text {
141                println!(
142                    "🔄 Processing {} commits in parallel (concurrency: {})...",
143                    repo_view.commits.len(),
144                    self.concurrency
145                );
146            }
147            self.check_with_map_reduce(
148                &claude_client,
149                &repo_view,
150                guidelines.as_deref(),
151                &valid_scopes,
152            )
153            .await?
154        } else {
155            // Single commit — direct call
156            if !self.quiet && output_format == OutputFormat::Text {
157                println!("🤖 Analyzing commits with AI...");
158            }
159            claude_client
160                .check_commits_with_scopes(
161                    &repo_view,
162                    guidelines.as_deref(),
163                    &valid_scopes,
164                    !self.no_suggestions,
165                )
166                .await?
167        };
168
169        // 7. Output results
170        self.output_report(&report, output_format)?;
171
172        // 8. If --twiddle and there are errors with suggestions, offer to apply them
173        if should_offer_twiddle(self.twiddle, report.has_errors(), output_format) {
174            use std::io::IsTerminal;
175            let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
176            if !amendments.is_empty()
177                && self
178                    .prompt_and_apply_suggestions(
179                        amendments,
180                        std::io::stdin().is_terminal(),
181                        &mut std::io::BufReader::new(std::io::stdin()),
182                    )
183                    .await?
184            {
185                // Amendments applied — exit successfully
186                return Ok(());
187            }
188        }
189
190        // 9. Determine exit code
191        let exit_code = report.exit_code(self.strict);
192        if exit_code != 0 {
193            std::process::exit(exit_code);
194        }
195
196        Ok(())
197    }
198
199    /// Generates the repository view (reuses logic from TwiddleCommand).
200    async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
201        use crate::data::{
202            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
203            WorkingDirectoryInfo,
204        };
205        use crate::git::{GitRepository, RemoteInfo};
206        use crate::utils::ai_scratch;
207
208        // Open git repository
209        let repo = GitRepository::open()
210            .context("Failed to open git repository. Make sure you're in a git repository.")?;
211
212        // Get current branch name
213        let current_branch = repo
214            .get_current_branch()
215            .unwrap_or_else(|_| "HEAD".to_string());
216
217        // Determine commit range
218        let commit_range = if let Some(range) = &self.commit_range {
219            range.clone()
220        } else {
221            // Default to commits ahead of main branch
222            let base = if repo.branch_exists("main")? {
223                "main"
224            } else if repo.branch_exists("master")? {
225                "master"
226            } else {
227                "HEAD~5"
228            };
229            format!("{base}..HEAD")
230        };
231
232        // Get working directory status
233        let wd_status = repo.get_working_directory_status()?;
234        let working_directory = WorkingDirectoryInfo {
235            clean: wd_status.clean,
236            untracked_changes: wd_status
237                .untracked_changes
238                .into_iter()
239                .map(|fs| FileStatusInfo {
240                    status: fs.status,
241                    file: fs.file,
242                })
243                .collect(),
244        };
245
246        // Get remote information
247        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
248
249        // Parse commit range and get commits
250        let commits = repo.get_commits_in_range(&commit_range)?;
251
252        // Create version information
253        let versions = Some(VersionInfo {
254            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
255        });
256
257        // Get AI scratch directory
258        let ai_scratch_path =
259            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
260        let ai_info = AiInfo {
261            scratch: ai_scratch_path.to_string_lossy().to_string(),
262        };
263
264        // Build repository view with branch info
265        let mut repo_view = RepositoryView {
266            versions,
267            explanation: FieldExplanation::default(),
268            working_directory,
269            remotes,
270            ai: ai_info,
271            branch_info: Some(BranchInfo {
272                branch: current_branch,
273            }),
274            pr_template: None,
275            pr_template_location: None,
276            branch_prs: None,
277            commits,
278        };
279
280        // Update field presence based on actual data
281        repo_view.update_field_presence();
282
283        Ok(repo_view)
284    }
285
286    /// Loads commit guidelines from file or context directory.
287    async fn load_guidelines(&self) -> Result<Option<String>> {
288        // If explicit guidelines path is provided, use it
289        if let Some(guidelines_path) = &self.guidelines {
290            let content = std::fs::read_to_string(guidelines_path).with_context(|| {
291                format!(
292                    "Failed to read guidelines file: {}",
293                    guidelines_path.display()
294                )
295            })?;
296            return Ok(Some(content));
297        }
298
299        // Otherwise, use standard resolution chain
300        let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
301        crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")
302    }
303
304    /// Loads valid scopes from context directory with ecosystem defaults.
305    fn load_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
306        let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
307        crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."))
308    }
309
310    /// Shows diagnostic information about loaded guidance files.
311    fn show_guidance_files_status(
312        &self,
313        guidelines: &Option<String>,
314        valid_scopes: &[crate::data::context::ScopeDefinition],
315    ) {
316        use crate::claude::context::{
317            config_source_label, resolve_context_dir_with_source, ConfigSourceLabel,
318        };
319
320        let (context_dir, dir_source) =
321            resolve_context_dir_with_source(self.context_dir.as_deref());
322
323        println!("📋 Project guidance files status:");
324        println!("   📂 Config dir: {} ({dir_source})", context_dir.display());
325
326        // Check commit guidelines
327        let guidelines_source = if guidelines.is_some() {
328            match config_source_label(&context_dir, "commit-guidelines.md") {
329                ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
330                label => format!("✅ {label}"),
331            }
332        } else {
333            "⚪ Using defaults".to_string()
334        };
335        println!("   📝 Commit guidelines: {guidelines_source}");
336
337        // Check scopes
338        let scopes_count = valid_scopes.len();
339        let scopes_source = if scopes_count > 0 {
340            match config_source_label(&context_dir, "scopes.yaml") {
341                ConfigSourceLabel::NotFound => {
342                    format!("✅ (source unknown) ({scopes_count} scopes)")
343                }
344                label => format!("✅ {label} ({scopes_count} scopes)"),
345            }
346        } else {
347            "⚪ None found (any scope accepted)".to_string()
348        };
349        println!("   🎯 Valid scopes: {scopes_source}");
350
351        println!();
352    }
353
354    /// Checks commits in parallel using batched map-reduce pattern.
355    ///
356    /// Groups commits into token-budget-aware batches, processes batches
357    /// in parallel, then runs an optional coherence pass (skipped when
358    /// all commits fit in a single batch).
359    async fn check_with_map_reduce(
360        &self,
361        claude_client: &crate::claude::client::ClaudeClient,
362        full_repo_view: &crate::data::RepositoryView,
363        guidelines: Option<&str>,
364        valid_scopes: &[crate::data::context::ScopeDefinition],
365    ) -> Result<crate::data::check::CheckReport> {
366        use std::io::IsTerminal;
367        use std::sync::atomic::{AtomicUsize, Ordering};
368        use std::sync::Arc;
369
370        use crate::claude::batch;
371        use crate::claude::token_budget;
372        use crate::data::check::{CheckReport, CommitCheckResult};
373
374        let total_commits = full_repo_view.commits.len();
375
376        // Plan batches based on token budget
377        let metadata = claude_client.get_ai_client_metadata();
378        let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
379            guidelines,
380            valid_scopes,
381        );
382        let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
383        let batch_plan =
384            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
385
386        if !self.quiet && batch_plan.batches.len() < total_commits {
387            println!(
388                "   📦 Grouped {} commits into {} batches by token budget",
389                total_commits,
390                batch_plan.batches.len()
391            );
392        }
393
394        let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
395        let completed = Arc::new(AtomicUsize::new(0));
396
397        // Map phase: check batches in parallel
398        let futs: Vec<_> = batch_plan
399            .batches
400            .iter()
401            .map(|batch| {
402                let sem = semaphore.clone();
403                let completed = completed.clone();
404                let batch_indices = &batch.commit_indices;
405
406                async move {
407                    let _permit = sem
408                        .acquire()
409                        .await
410                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
411
412                    let batch_size = batch_indices.len();
413
414                    // Create view for this batch
415                    let batch_view = if batch_size == 1 {
416                        full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
417                    } else {
418                        let commits: Vec<_> = batch_indices
419                            .iter()
420                            .map(|&i| &full_repo_view.commits[i])
421                            .collect();
422                        full_repo_view.multi_commit_view(&commits)
423                    };
424
425                    let result = claude_client
426                        .check_commits_with_scopes(
427                            &batch_view,
428                            guidelines,
429                            valid_scopes,
430                            !self.no_suggestions,
431                        )
432                        .await;
433
434                    match result {
435                        Ok(report) => {
436                            let done =
437                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
438                            if !self.quiet {
439                                println!("   ✅ {done}/{total_commits} commits checked");
440                            }
441
442                            let items: Vec<_> = report
443                                .commits
444                                .into_iter()
445                                .map(|r| {
446                                    let summary = r.summary.clone().unwrap_or_default();
447                                    (r, summary)
448                                })
449                                .collect();
450                            Ok::<_, anyhow::Error>((items, vec![]))
451                        }
452                        Err(e) if batch_size > 1 => {
453                            // Split-and-retry: fall back to individual commits
454                            eprintln!(
455                                "warning: batch of {batch_size} failed, retrying individually: {e}"
456                            );
457                            let mut items = Vec::new();
458                            let mut failed_indices = Vec::new();
459                            for &idx in batch_indices {
460                                let single_view =
461                                    full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
462                                let single_result = claude_client
463                                    .check_commits_with_scopes(
464                                        &single_view,
465                                        guidelines,
466                                        valid_scopes,
467                                        !self.no_suggestions,
468                                    )
469                                    .await;
470                                match single_result {
471                                    Ok(report) => {
472                                        if let Some(r) = report.commits.into_iter().next() {
473                                            let summary = r.summary.clone().unwrap_or_default();
474                                            items.push((r, summary));
475                                        }
476                                        let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
477                                        if !self.quiet {
478                                            println!(
479                                                "   ✅ {done}/{total_commits} commits checked"
480                                            );
481                                        }
482                                    }
483                                    Err(e) => {
484                                        eprintln!("warning: failed to check commit: {e}");
485                                        failed_indices.push(idx);
486                                        if !self.quiet {
487                                            println!("   ❌ commit check failed");
488                                        }
489                                    }
490                                }
491                            }
492                            Ok((items, failed_indices))
493                        }
494                        Err(e) => {
495                            // Single-commit batch failed; record the index so the user can retry
496                            let idx = batch_indices[0];
497                            eprintln!("warning: failed to check commit: {e}");
498                            let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
499                            if !self.quiet {
500                                println!("   ❌ {done}/{total_commits} commits checked (failed)");
501                            }
502                            Ok((vec![], vec![idx]))
503                        }
504                    }
505                }
506            })
507            .collect();
508
509        let results = futures::future::join_all(futs).await;
510
511        // Flatten batch results
512        let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
513        let mut failed_indices: Vec<usize> = Vec::new();
514
515        for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
516            match result {
517                Ok((items, failed)) => {
518                    successes.extend(items);
519                    failed_indices.extend(failed);
520                }
521                Err(e) => {
522                    eprintln!("warning: batch processing error: {e}");
523                    failed_indices.extend(&batch.commit_indices);
524                }
525            }
526        }
527
528        // Offer interactive retry for commits that failed
529        if !failed_indices.is_empty() && !self.quiet && std::io::stdin().is_terminal() {
530            self.run_interactive_retry_check(
531                &mut failed_indices,
532                full_repo_view,
533                claude_client,
534                guidelines,
535                valid_scopes,
536                &mut successes,
537                &mut std::io::BufReader::new(std::io::stdin()),
538            )
539            .await?;
540        } else if !failed_indices.is_empty() {
541            eprintln!(
542                "warning: {} commit(s) failed to check",
543                failed_indices.len()
544            );
545        }
546
547        if !failed_indices.is_empty() {
548            eprintln!(
549                "warning: {} commit(s) ultimately failed to check",
550                failed_indices.len()
551            );
552        }
553
554        if successes.is_empty() {
555            anyhow::bail!("All commits failed to check");
556        }
557
558        // Reduce phase: optional coherence pass
559        // Skip when all commits were in a single batch (AI already saw them together)
560        let single_batch = batch_plan.batches.len() <= 1;
561        if !self.no_coherence && !single_batch && successes.len() >= 2 {
562            if !self.quiet {
563                println!("🔗 Running cross-commit coherence pass...");
564            }
565            match claude_client
566                .refine_checks_coherence(&successes, full_repo_view)
567                .await
568            {
569                Ok(refined) => {
570                    if !self.quiet {
571                        println!("✅ All commits checked!");
572                    }
573                    return Ok(refined);
574                }
575                Err(e) => {
576                    eprintln!("warning: coherence pass failed, using individual results: {e}");
577                }
578            }
579        }
580
581        if !self.quiet {
582            println!("✅ All commits checked!");
583        }
584
585        let all_results: Vec<CommitCheckResult> = successes.into_iter().map(|(r, _)| r).collect();
586
587        Ok(CheckReport::new(all_results))
588    }
589
590    /// Outputs the check report in the specified format.
591    fn output_report(
592        &self,
593        report: &crate::data::check::CheckReport,
594        format: crate::data::check::OutputFormat,
595    ) -> Result<()> {
596        use crate::data::check::OutputFormat;
597
598        match format {
599            OutputFormat::Text => self.output_text_report(report),
600            OutputFormat::Json => {
601                let json = serde_json::to_string_pretty(report)
602                    .context("Failed to serialize report to JSON")?;
603                println!("{json}");
604                Ok(())
605            }
606            OutputFormat::Yaml => {
607                let yaml =
608                    crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
609                println!("{yaml}");
610                Ok(())
611            }
612        }
613    }
614
615    /// Outputs the text format report.
616    fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
617        use crate::data::check::IssueSeverity;
618
619        println!();
620
621        for result in &report.commits {
622            if !should_display_commit(result.passes, self.show_passing) {
623                continue;
624            }
625
626            // Skip info-only commits in quiet mode
627            if self.quiet && !has_errors_or_warnings(&result.issues) {
628                continue;
629            }
630
631            let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
632            let short_hash = super::formatting::truncate_hash(&result.hash);
633            println!("{}", format_commit_line(icon, short_hash, &result.message));
634
635            // Print issues
636            for issue in &result.issues {
637                // Skip info issues in quiet mode
638                if self.quiet && issue.severity == IssueSeverity::Info {
639                    continue;
640                }
641
642                let severity_str = super::formatting::format_severity_label(issue.severity);
643                println!(
644                    "   {} [{}] {}",
645                    severity_str, issue.section, issue.explanation
646                );
647            }
648
649            // Print suggestion if available and not in quiet mode
650            if !self.quiet {
651                if let Some(suggestion) = &result.suggestion {
652                    println!();
653                    print!("{}", format_suggestion_text(suggestion, self.verbose));
654                }
655            }
656
657            println!();
658        }
659
660        // Print summary
661        println!("{}", format_summary_text(&report.summary));
662
663        Ok(())
664    }
665
666    /// Shows model information.
667    fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
668        use crate::claude::model_config::get_model_registry;
669
670        println!("🤖 AI Model Configuration:");
671
672        let metadata = client.get_ai_client_metadata();
673        let registry = get_model_registry();
674
675        if let Some(spec) = registry.get_model_spec(&metadata.model) {
676            if metadata.model != spec.api_identifier {
677                println!(
678                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
679                    metadata.model, spec.api_identifier
680                );
681            } else {
682                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
683            }
684            println!("   🏷️  Provider: {}", spec.provider);
685        } else {
686            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
687            println!("   🏷️  Provider: {}", metadata.provider);
688        }
689
690        println!();
691        Ok(())
692    }
693
694    /// Builds amendments from check report suggestions for failing commits.
695    fn build_amendments_from_suggestions(
696        &self,
697        report: &crate::data::check::CheckReport,
698        repo_view: &crate::data::RepositoryView,
699    ) -> Vec<crate::data::amendments::Amendment> {
700        use crate::data::amendments::Amendment;
701
702        let candidate_hashes: Vec<String> =
703            repo_view.commits.iter().map(|c| c.hash.clone()).collect();
704
705        report
706            .commits
707            .iter()
708            .filter(|r| !r.passes)
709            .filter_map(|r| {
710                let suggestion = r.suggestion.as_ref()?;
711                let full_hash = super::formatting::resolve_short_hash(&r.hash, &candidate_hashes)?;
712                Some(Amendment::new(
713                    full_hash.to_string(),
714                    suggestion.message.clone(),
715                ))
716            })
717            .collect()
718    }
719
720    /// Prompts the user to apply suggested amendments and applies them if accepted.
721    /// Returns true if amendments were applied, false if user declined.
722    ///
723    /// `is_terminal` and `reader` are injected so tests can drive the function
724    /// without blocking on real stdin.
725    async fn prompt_and_apply_suggestions(
726        &self,
727        amendments: Vec<crate::data::amendments::Amendment>,
728        is_terminal: bool,
729        reader: &mut (dyn std::io::BufRead + Send),
730    ) -> Result<bool> {
731        use crate::data::amendments::AmendmentFile;
732        use crate::git::AmendmentHandler;
733        use std::io::{self, Write};
734
735        println!();
736        println!(
737            "🔧 {} commit(s) have issues with suggested fixes available.",
738            amendments.len()
739        );
740
741        if !is_terminal {
742            eprintln!("warning: stdin is not interactive, cannot prompt to apply suggested fixes");
743            return Ok(false);
744        }
745
746        loop {
747            print!("❓ [A]pply suggested fixes, or [Q]uit? [A/q] ");
748            io::stdout().flush()?;
749
750            let Some(input) = super::read_interactive_line(reader)? else {
751                eprintln!("warning: stdin closed, not applying suggested fixes");
752                return Ok(false);
753            };
754
755            match input.trim().to_lowercase().as_str() {
756                "a" | "apply" | "" => {
757                    let amendment_file = AmendmentFile { amendments };
758                    let temp_file = tempfile::NamedTempFile::new()
759                        .context("Failed to create temp file for amendments")?;
760                    amendment_file
761                        .save_to_file(temp_file.path())
762                        .context("Failed to save amendments")?;
763
764                    let handler = AmendmentHandler::new()
765                        .context("Failed to initialize amendment handler")?;
766                    handler
767                        .apply_amendments(&temp_file.path().to_string_lossy())
768                        .context("Failed to apply amendments")?;
769
770                    println!("✅ Suggested fixes applied successfully!");
771                    return Ok(true);
772                }
773                "q" | "quit" => return Ok(false),
774                _ => {
775                    println!("Invalid choice. Please enter 'a' to apply or 'q' to quit.");
776                }
777            }
778        }
779    }
780}
781
782// --- Interactive retry helper ---
783
784impl CheckCommand {
785    /// Prompts the user to retry or skip failed commits, reading responses
786    /// from `reader` so tests can inject a [`std::io::Cursor`] instead of
787    /// blocking on stdin.
788    #[allow(clippy::too_many_arguments)]
789    async fn run_interactive_retry_check(
790        &self,
791        failed_indices: &mut Vec<usize>,
792        full_repo_view: &crate::data::RepositoryView,
793        claude_client: &crate::claude::client::ClaudeClient,
794        guidelines: Option<&str>,
795        valid_scopes: &[crate::data::context::ScopeDefinition],
796        successes: &mut Vec<(crate::data::check::CommitCheckResult, String)>,
797        reader: &mut (dyn std::io::BufRead + Send),
798    ) -> Result<()> {
799        use std::io::Write as _;
800        println!("\n⚠️  {} commit(s) failed to check:", failed_indices.len());
801        for &idx in failed_indices.iter() {
802            let commit = &full_repo_view.commits[idx];
803            let subject = commit
804                .original_message
805                .lines()
806                .next()
807                .unwrap_or("(no message)");
808            println!("  - {}: {}", &commit.hash[..8], subject);
809        }
810        loop {
811            print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
812            std::io::stdout().flush()?;
813            let Some(input) = super::read_interactive_line(reader)? else {
814                eprintln!("warning: stdin closed, skipping failed commit(s)");
815                break;
816            };
817            match input.trim().to_lowercase().as_str() {
818                "r" | "retry" | "" => {
819                    let mut still_failed = Vec::new();
820                    for &idx in failed_indices.iter() {
821                        let single_view =
822                            full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
823                        match claude_client
824                            .check_commits_with_scopes(
825                                &single_view,
826                                guidelines,
827                                valid_scopes,
828                                !self.no_suggestions,
829                            )
830                            .await
831                        {
832                            Ok(report) => {
833                                if let Some(r) = report.commits.into_iter().next() {
834                                    let summary = r.summary.clone().unwrap_or_default();
835                                    successes.push((r, summary));
836                                }
837                            }
838                            Err(e) => {
839                                eprintln!("warning: still failed: {e}");
840                                still_failed.push(idx);
841                            }
842                        }
843                    }
844                    *failed_indices = still_failed;
845                    if failed_indices.is_empty() {
846                        println!("✅ All retried commits succeeded.");
847                        break;
848                    }
849                    println!("\n⚠️  {} commit(s) still failed:", failed_indices.len());
850                    for &idx in failed_indices.iter() {
851                        let commit = &full_repo_view.commits[idx];
852                        let subject = commit
853                            .original_message
854                            .lines()
855                            .next()
856                            .unwrap_or("(no message)");
857                        println!("  - {}: {}", &commit.hash[..8], subject);
858                    }
859                }
860                "s" | "skip" => {
861                    println!("Skipping {} failed commit(s).", failed_indices.len());
862                    break;
863                }
864                _ => println!("Please enter 'r' to retry or 's' to skip."),
865            }
866        }
867        Ok(())
868    }
869}
870
871/// Structured output from [`run_check`] for programmatic consumers (MCP).
872#[derive(Debug, Clone)]
873pub struct CheckOutcome {
874    /// YAML serialisation of the full [`crate::data::check::CheckReport`].
875    pub report_yaml: String,
876    /// `true` when any commit has an error-severity issue.
877    pub has_errors: bool,
878    /// `true` when any commit has a warning-severity issue.
879    pub has_warnings: bool,
880    /// Total commits in the range that were checked.
881    pub total_commits: usize,
882    /// Strict mode setting that produced `exit_code`.
883    pub strict: bool,
884    /// Exit code the CLI would use, honouring `strict`.
885    pub exit_code: i32,
886}
887
888/// Non-interactive core for `omni-dev git commit message check`.
889///
890/// Shared by the CLI (which prints the report and uses the exit code) and the
891/// MCP server (which returns the structured outcome to the caller). Always
892/// runs a single direct AI call — the MCP tool boundary never needs the
893/// map-reduce/interactive-retry flow from [`CheckCommand::execute`].
894///
895/// When `repo_path` is provided the current working directory is changed for
896/// the duration of the call (serialised by a global mutex) so CWD-dependent
897/// context-discovery and AI-scratch paths resolve relative to the target repo.
898pub async fn run_check(
899    range: &str,
900    guidelines_path: Option<&std::path::Path>,
901    repo_path: Option<&std::path::Path>,
902    strict: bool,
903    model: Option<String>,
904) -> Result<CheckOutcome> {
905    let _cwd_guard = match repo_path {
906        Some(p) => Some(super::CwdGuard::enter(p).await?),
907        None => None,
908    };
909
910    // Preflight: validate AI credentials.
911    crate::utils::check_ai_command_prerequisites(model.as_deref())?;
912
913    let claude_client = crate::claude::create_default_claude_client(model, None)?;
914    run_check_with_client(range, guidelines_path, strict, &claude_client).await
915}
916
917/// Non-credential-gated inner core of [`run_check`] for unit tests.
918///
919/// Extracted so tests can inject a [`crate::claude::client::ClaudeClient`]
920/// backed by the in-crate mock AI client and exercise the full happy path
921/// without real credentials. Callers are responsible for holding any
922/// [`super::CwdGuard`] they need and for running preflight themselves.
923pub(crate) async fn run_check_with_client(
924    range: &str,
925    guidelines_path: Option<&std::path::Path>,
926    strict: bool,
927    claude_client: &crate::claude::client::ClaudeClient,
928) -> Result<CheckOutcome> {
929    use crate::data::{
930        AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
931        WorkingDirectoryInfo,
932    };
933    use crate::git::{GitRepository, RemoteInfo};
934    use crate::utils::ai_scratch;
935
936    let repo = GitRepository::open()
937        .context("Failed to open git repository. Make sure you're in a git repository.")?;
938
939    let current_branch = repo
940        .get_current_branch()
941        .unwrap_or_else(|_| "HEAD".to_string());
942
943    let wd_status = repo.get_working_directory_status()?;
944    let working_directory = WorkingDirectoryInfo {
945        clean: wd_status.clean,
946        untracked_changes: wd_status
947            .untracked_changes
948            .into_iter()
949            .map(|fs| FileStatusInfo {
950                status: fs.status,
951                file: fs.file,
952            })
953            .collect(),
954    };
955
956    let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
957    let commits = repo.get_commits_in_range(range)?;
958
959    if commits.is_empty() {
960        anyhow::bail!("no commits found in range: {range}");
961    }
962
963    let ai_scratch_path =
964        ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
965    let ai_info = AiInfo {
966        scratch: ai_scratch_path.to_string_lossy().to_string(),
967    };
968
969    let mut repo_view = RepositoryView {
970        versions: Some(VersionInfo {
971            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
972        }),
973        explanation: FieldExplanation::default(),
974        working_directory,
975        remotes,
976        ai: ai_info,
977        branch_info: Some(BranchInfo {
978            branch: current_branch,
979        }),
980        pr_template: None,
981        pr_template_location: None,
982        branch_prs: None,
983        commits,
984    };
985    repo_view.update_field_presence();
986
987    let guidelines = if let Some(path) = guidelines_path {
988        Some(
989            std::fs::read_to_string(path)
990                .with_context(|| format!("Failed to read guidelines file: {}", path.display()))?,
991        )
992    } else {
993        let context_dir = crate::claude::context::resolve_context_dir(None);
994        crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")?
995    };
996
997    let context_dir = crate::claude::context::resolve_context_dir(None);
998    let valid_scopes =
999        crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."));
1000    for commit in &mut repo_view.commits {
1001        commit.analysis.refine_scope(&valid_scopes);
1002    }
1003
1004    let report = claude_client
1005        .check_commits_with_scopes(&repo_view, guidelines.as_deref(), &valid_scopes, true)
1006        .await?;
1007
1008    let report_yaml = crate::data::to_yaml(&report).context("Failed to serialise CheckReport")?;
1009    let has_errors = report.has_errors();
1010    let has_warnings = report.has_warnings();
1011    let exit_code = report.exit_code(strict);
1012    let total_commits = report.commits.len();
1013
1014    Ok(CheckOutcome {
1015        report_yaml,
1016        has_errors,
1017        has_warnings,
1018        total_commits,
1019        strict,
1020        exit_code,
1021    })
1022}
1023
1024#[cfg(test)]
1025#[allow(clippy::unwrap_used, clippy::expect_used)]
1026mod run_check_tests {
1027    use super::*;
1028    use crate::claude::client::ClaudeClient;
1029    use crate::claude::test_utils::ConfigurableMockAiClient;
1030    use git2::{Repository, Signature};
1031
1032    /// With `/no/such/path` as repo_path, `CwdGuard::enter` fails before any AI
1033    /// call is attempted — no credentials needed.
1034    #[tokio::test]
1035    async fn run_check_invalid_repo_path_errors_before_ai() {
1036        let err = run_check(
1037            "HEAD",
1038            None,
1039            Some(std::path::Path::new("/no/such/path/exists")),
1040            false,
1041            None,
1042        )
1043        .await
1044        .unwrap_err();
1045        let msg = format!("{err:#}");
1046        assert!(
1047            msg.to_lowercase().contains("set_current_dir")
1048                || msg.to_lowercase().contains("no such")
1049                || msg.to_lowercase().contains("directory"),
1050            "expected cwd-related error, got: {msg}"
1051        );
1052    }
1053
1054    fn init_test_repo() -> tempfile::TempDir {
1055        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1056        std::fs::create_dir_all(&tmp_root).unwrap();
1057        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1058        let repo = Repository::init(temp_dir.path()).unwrap();
1059        {
1060            let mut cfg = repo.config().unwrap();
1061            cfg.set_str("user.name", "Test").unwrap();
1062            cfg.set_str("user.email", "test@example.com").unwrap();
1063        }
1064        let signature = Signature::now("Test", "test@example.com").unwrap();
1065        std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
1066        let mut idx = repo.index().unwrap();
1067        idx.add_path(std::path::Path::new("f.txt")).unwrap();
1068        idx.write().unwrap();
1069        let tree_id = idx.write_tree().unwrap();
1070        let tree = repo.find_tree(tree_id).unwrap();
1071        repo.commit(
1072            Some("HEAD"),
1073            &signature,
1074            &signature,
1075            "feat(cli): only",
1076            &tree,
1077            &[],
1078        )
1079        .unwrap();
1080        temp_dir
1081    }
1082
1083    fn passing_check_yaml(hash_prefix: &str) -> String {
1084        format!("checks:\n  - commit: {hash_prefix}\n    passes: true\n    issues: []\n")
1085    }
1086
1087    fn failing_check_yaml(hash_prefix: &str) -> String {
1088        format!(
1089            "checks:\n  - commit: {hash_prefix}\n    passes: false\n    issues:\n      - severity: error\n        section: subject\n        rule: format\n        explanation: bad\n"
1090        )
1091    }
1092
1093    #[tokio::test]
1094    async fn run_check_with_client_happy_path_passing() {
1095        let temp_dir = init_test_repo();
1096        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1097            .await
1098            .unwrap();
1099
1100        // Use a short hash prefix that resolves in the mini repo.
1101        let mock = ConfigurableMockAiClient::new(vec![Ok(passing_check_yaml("00000000"))]);
1102        let client = ClaudeClient::new(Box::new(mock));
1103
1104        let outcome = run_check_with_client("HEAD", None, false, &client)
1105            .await
1106            .unwrap();
1107        assert!(!outcome.has_errors);
1108        assert!(!outcome.has_warnings);
1109        assert_eq!(outcome.exit_code, 0);
1110        assert_eq!(outcome.total_commits, 1);
1111        assert!(outcome.report_yaml.contains("commits:"));
1112        assert!(!outcome.strict);
1113    }
1114
1115    #[tokio::test]
1116    async fn run_check_with_client_failing_commit_sets_error_exit_code() {
1117        let temp_dir = init_test_repo();
1118        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1119            .await
1120            .unwrap();
1121
1122        let mock = ConfigurableMockAiClient::new(vec![Ok(failing_check_yaml("00000000"))]);
1123        let client = ClaudeClient::new(Box::new(mock));
1124
1125        let outcome = run_check_with_client("HEAD", None, false, &client)
1126            .await
1127            .unwrap();
1128        assert!(outcome.has_errors);
1129        assert_eq!(outcome.exit_code, 1);
1130    }
1131
1132    #[tokio::test]
1133    async fn run_check_with_client_strict_does_not_affect_no_issues() {
1134        let temp_dir = init_test_repo();
1135        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1136            .await
1137            .unwrap();
1138
1139        let mock = ConfigurableMockAiClient::new(vec![Ok(passing_check_yaml("00000000"))]);
1140        let client = ClaudeClient::new(Box::new(mock));
1141
1142        let outcome = run_check_with_client("HEAD", None, true, &client)
1143            .await
1144            .unwrap();
1145        assert_eq!(outcome.exit_code, 0);
1146        assert!(outcome.strict);
1147    }
1148
1149    #[tokio::test]
1150    async fn run_check_with_client_explicit_guidelines_path() {
1151        let temp_dir = init_test_repo();
1152        let guidelines_path = temp_dir.path().join("guidelines.md");
1153        std::fs::write(&guidelines_path, "guideline body").unwrap();
1154        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1155            .await
1156            .unwrap();
1157
1158        let mock = ConfigurableMockAiClient::new(vec![Ok(passing_check_yaml("00000000"))]);
1159        let client = ClaudeClient::new(Box::new(mock));
1160
1161        let outcome = run_check_with_client("HEAD", Some(&guidelines_path), false, &client)
1162            .await
1163            .unwrap();
1164        assert_eq!(outcome.exit_code, 0);
1165    }
1166
1167    #[tokio::test]
1168    async fn run_check_with_client_guidelines_path_missing_errors() {
1169        let temp_dir = init_test_repo();
1170        let missing = temp_dir.path().join("no-such.md");
1171        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1172            .await
1173            .unwrap();
1174
1175        let mock = ConfigurableMockAiClient::new(vec![Ok(passing_check_yaml("00000000"))]);
1176        let client = ClaudeClient::new(Box::new(mock));
1177        let err = run_check_with_client("HEAD", Some(&missing), false, &client)
1178            .await
1179            .unwrap_err();
1180        assert!(
1181            format!("{err:#}").contains("guidelines"),
1182            "expected guidelines read error"
1183        );
1184    }
1185
1186    #[tokio::test]
1187    async fn run_check_with_client_empty_range_bails() {
1188        let temp_dir = init_test_repo();
1189        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1190            .await
1191            .unwrap();
1192
1193        let mock = ConfigurableMockAiClient::new(vec![]);
1194        let client = ClaudeClient::new(Box::new(mock));
1195        // A range with no commits reachable → get_commits_in_range returns empty.
1196        let err = run_check_with_client("HEAD..HEAD", None, false, &client)
1197            .await
1198            .unwrap_err();
1199        assert!(format!("{err:#}").contains("no commits"));
1200    }
1201
1202    #[tokio::test]
1203    async fn run_check_with_client_ai_failure_propagates() {
1204        let temp_dir = init_test_repo();
1205        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1206            .await
1207            .unwrap();
1208
1209        // No responses → mock returns "no more mock responses" error; this
1210        // propagates after check_commits_with_scopes exhausts its retries.
1211        let mock = ConfigurableMockAiClient::new(vec![]);
1212        let client = ClaudeClient::new(Box::new(mock));
1213        let err = run_check_with_client("HEAD", None, false, &client)
1214            .await
1215            .unwrap_err();
1216        let _ = err; // any error is acceptable — the point is we didn't panic
1217    }
1218
1219    #[test]
1220    fn check_outcome_clone_and_debug() {
1221        // Cover derived impls.
1222        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
1235// --- Extracted pure functions ---
1236
1237/// Returns whether a commit should be displayed based on its pass status.
1238fn should_display_commit(passes: bool, show_passing: bool) -> bool {
1239    !passes || show_passing
1240}
1241
1242/// Returns whether any issues have Error or Warning severity.
1243fn has_errors_or_warnings(issues: &[crate::data::check::CommitIssue]) -> bool {
1244    use crate::data::check::IssueSeverity;
1245    issues
1246        .iter()
1247        .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning))
1248}
1249
1250/// Returns whether the twiddle (auto-fix) flow should be offered.
1251fn should_offer_twiddle(
1252    twiddle_flag: bool,
1253    has_errors: bool,
1254    format: crate::data::check::OutputFormat,
1255) -> bool {
1256    twiddle_flag && has_errors && format == crate::data::check::OutputFormat::Text
1257}
1258
1259/// Formats a commit suggestion as indented text.
1260fn format_suggestion_text(
1261    suggestion: &crate::data::check::CommitSuggestion,
1262    verbose: bool,
1263) -> String {
1264    let mut output = String::new();
1265    output.push_str("   Suggested message:\n");
1266    for line in suggestion.message.lines() {
1267        output.push_str(&format!("      {line}\n"));
1268    }
1269    if verbose {
1270        output.push('\n');
1271        output.push_str("   Why this is better:\n");
1272        for line in suggestion.explanation.lines() {
1273            output.push_str(&format!("   {line}\n"));
1274        }
1275    }
1276    output
1277}
1278
1279/// Formats the summary section of a check report.
1280fn format_summary_text(summary: &crate::data::check::CheckSummary) -> String {
1281    format!(
1282        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
1283         Summary: {} commits checked\n\
1284         \x20 {} errors, {} warnings\n\
1285         \x20 {} passed, {} with issues",
1286        summary.total_commits,
1287        summary.error_count,
1288        summary.warning_count,
1289        summary.passing_commits,
1290        summary.failing_commits,
1291    )
1292}
1293
1294/// Formats a single commit line for text output.
1295fn format_commit_line(icon: &str, short_hash: &str, message: &str) -> String {
1296    format!("{icon} {short_hash} - \"{message}\"")
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301    use super::*;
1302    use crate::data::check::{
1303        CheckSummary, CommitIssue, CommitSuggestion, IssueSeverity, OutputFormat,
1304    };
1305
1306    // --- should_display_commit ---
1307
1308    #[test]
1309    fn display_commit_passing_hidden() {
1310        assert!(!should_display_commit(true, false));
1311    }
1312
1313    #[test]
1314    fn display_commit_passing_shown() {
1315        assert!(should_display_commit(true, true));
1316    }
1317
1318    #[test]
1319    fn display_commit_failing() {
1320        assert!(should_display_commit(false, false));
1321        assert!(should_display_commit(false, true));
1322    }
1323
1324    // --- has_errors_or_warnings ---
1325
1326    #[test]
1327    fn errors_or_warnings_with_error() {
1328        let issues = vec![CommitIssue {
1329            severity: IssueSeverity::Error,
1330            section: "subject".to_string(),
1331            rule: "length".to_string(),
1332            explanation: "too long".to_string(),
1333        }];
1334        assert!(has_errors_or_warnings(&issues));
1335    }
1336
1337    #[test]
1338    fn errors_or_warnings_with_warning() {
1339        let issues = vec![CommitIssue {
1340            severity: IssueSeverity::Warning,
1341            section: "body".to_string(),
1342            rule: "style".to_string(),
1343            explanation: "minor issue".to_string(),
1344        }];
1345        assert!(has_errors_or_warnings(&issues));
1346    }
1347
1348    #[test]
1349    fn errors_or_warnings_info_only() {
1350        let issues = vec![CommitIssue {
1351            severity: IssueSeverity::Info,
1352            section: "body".to_string(),
1353            rule: "suggestion".to_string(),
1354            explanation: "consider adding more detail".to_string(),
1355        }];
1356        assert!(!has_errors_or_warnings(&issues));
1357    }
1358
1359    #[test]
1360    fn errors_or_warnings_empty() {
1361        assert!(!has_errors_or_warnings(&[]));
1362    }
1363
1364    // --- should_offer_twiddle ---
1365
1366    #[test]
1367    fn offer_twiddle_all_conditions_met() {
1368        assert!(should_offer_twiddle(true, true, OutputFormat::Text));
1369    }
1370
1371    #[test]
1372    fn offer_twiddle_flag_off() {
1373        assert!(!should_offer_twiddle(false, true, OutputFormat::Text));
1374    }
1375
1376    #[test]
1377    fn offer_twiddle_no_errors() {
1378        assert!(!should_offer_twiddle(true, false, OutputFormat::Text));
1379    }
1380
1381    #[test]
1382    fn offer_twiddle_json_format() {
1383        assert!(!should_offer_twiddle(true, true, OutputFormat::Json));
1384    }
1385
1386    // --- format_suggestion_text ---
1387
1388    #[test]
1389    fn suggestion_text_basic() {
1390        let suggestion = CommitSuggestion {
1391            message: "feat(cli): add new flag".to_string(),
1392            explanation: "uses conventional format".to_string(),
1393        };
1394        let result = format_suggestion_text(&suggestion, false);
1395        assert!(result.contains("Suggested message:"));
1396        assert!(result.contains("feat(cli): add new flag"));
1397        assert!(!result.contains("Why this is better"));
1398    }
1399
1400    #[test]
1401    fn suggestion_text_verbose() {
1402        let suggestion = CommitSuggestion {
1403            message: "fix: resolve crash".to_string(),
1404            explanation: "clear description of fix".to_string(),
1405        };
1406        let result = format_suggestion_text(&suggestion, true);
1407        assert!(result.contains("Suggested message:"));
1408        assert!(result.contains("fix: resolve crash"));
1409        assert!(result.contains("Why this is better:"));
1410        assert!(result.contains("clear description of fix"));
1411    }
1412
1413    // --- format_summary_text ---
1414
1415    #[test]
1416    fn summary_text_formatting() {
1417        let summary = CheckSummary {
1418            total_commits: 5,
1419            passing_commits: 3,
1420            failing_commits: 2,
1421            error_count: 1,
1422            warning_count: 4,
1423            info_count: 0,
1424        };
1425        let result = format_summary_text(&summary);
1426        assert!(result.contains("5 commits checked"));
1427        assert!(result.contains("1 errors, 4 warnings"));
1428        assert!(result.contains("3 passed, 2 with issues"));
1429    }
1430
1431    // --- format_commit_line ---
1432
1433    #[test]
1434    fn commit_line_formatting() {
1435        let line = format_commit_line("✅", "abc1234", "feat: add feature");
1436        assert_eq!(line, "✅ abc1234 - \"feat: add feature\"");
1437    }
1438
1439    // --- check_with_map_reduce (error path coverage) ---
1440
1441    fn make_check_cmd(quiet: bool) -> CheckCommand {
1442        CheckCommand {
1443            commit_range: None,
1444            model: None,
1445            beta_header: None,
1446            context_dir: None,
1447            guidelines: None,
1448            format: "text".to_string(),
1449            strict: false,
1450            quiet,
1451            verbose: false,
1452            show_passing: false,
1453            concurrency: 4,
1454            batch_size: None,
1455            no_coherence: true,
1456            no_suggestions: false,
1457            twiddle: false,
1458        }
1459    }
1460
1461    fn make_check_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
1462        use crate::git::commit::FileChanges;
1463        use crate::git::{CommitAnalysis, CommitInfo};
1464        let tmp = tempfile::NamedTempFile::new().unwrap();
1465        let commit = CommitInfo {
1466            hash: hash.to_string(),
1467            author: "Test <test@test.com>".to_string(),
1468            date: chrono::Utc::now().fixed_offset(),
1469            original_message: format!("feat: commit {hash}"),
1470            in_main_branches: vec![],
1471            analysis: CommitAnalysis {
1472                detected_type: "feat".to_string(),
1473                detected_scope: String::new(),
1474                proposed_message: format!("feat: commit {hash}"),
1475                file_changes: FileChanges {
1476                    total_files: 0,
1477                    files_added: 0,
1478                    files_deleted: 0,
1479                    file_list: vec![],
1480                },
1481                diff_summary: String::new(),
1482                diff_file: tmp.path().to_string_lossy().to_string(),
1483                file_diffs: Vec::new(),
1484            },
1485        };
1486        (commit, tmp)
1487    }
1488
1489    fn make_check_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
1490        use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
1491        RepositoryView {
1492            versions: None,
1493            explanation: FieldExplanation::default(),
1494            working_directory: WorkingDirectoryInfo {
1495                clean: true,
1496                untracked_changes: vec![],
1497            },
1498            remotes: vec![],
1499            ai: AiInfo {
1500                scratch: String::new(),
1501            },
1502            branch_info: None,
1503            pr_template: None,
1504            pr_template_location: None,
1505            branch_prs: None,
1506            commits,
1507        }
1508    }
1509
1510    fn check_yaml(hash: &str) -> String {
1511        format!("checks:\n  - commit: {hash}\n    passes: true\n    issues: []\n")
1512    }
1513
1514    fn make_client(responses: Vec<anyhow::Result<String>>) -> crate::claude::client::ClaudeClient {
1515        crate::claude::client::ClaudeClient::new(Box::new(
1516            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
1517        ))
1518    }
1519
1520    // check_commits_with_retry uses max_retries=2 (3 total attempts), so a
1521    // batch or individual commit needs 3 consecutive Err responses to fail.
1522    fn errs(n: usize) -> Vec<anyhow::Result<String>> {
1523        (0..n)
1524            .map(|_| Err(anyhow::anyhow!("mock failure")))
1525            .collect()
1526    }
1527
1528    #[tokio::test]
1529    async fn check_with_map_reduce_single_commit_fails_returns_err() {
1530        // A single-commit batch that exhausts all retries records the index in
1531        // failed_indices and returns Ok(([], [idx])). With successes empty the
1532        // method bails, so the overall result is Err.
1533        let (commit, _tmp) = make_check_commit("abc00000");
1534        let cmd = make_check_cmd(true);
1535        let repo_view = make_check_repo_view(vec![commit]);
1536        let client = make_client(errs(3));
1537        let result = cmd
1538            .check_with_map_reduce(&client, &repo_view, None, &[])
1539            .await;
1540        assert!(result.is_err(), "empty successes should bail");
1541    }
1542
1543    #[tokio::test]
1544    async fn check_with_map_reduce_single_commit_succeeds() {
1545        // Happy path: one commit, one successful batch response.
1546        let (commit, _tmp) = make_check_commit("abc00000");
1547        let cmd = make_check_cmd(true);
1548        let repo_view = make_check_repo_view(vec![commit]);
1549        let client = make_client(vec![Ok(check_yaml("abc00000"))]);
1550        let result = cmd
1551            .check_with_map_reduce(&client, &repo_view, None, &[])
1552            .await;
1553        assert!(result.is_ok());
1554        assert_eq!(result.unwrap().commits.len(), 1);
1555    }
1556
1557    #[tokio::test]
1558    async fn check_with_map_reduce_batch_fails_split_retry_both_succeed() {
1559        // Two commits fit into one batch. The batch fails (3 retries exhausted),
1560        // triggering split-and-retry. Both individual commits then succeed.
1561        let (c1, _t1) = make_check_commit("abc00000");
1562        let (c2, _t2) = make_check_commit("def00000");
1563        let cmd = make_check_cmd(true);
1564        let repo_view = make_check_repo_view(vec![c1, c2]);
1565        let mut responses = errs(3); // batch failure
1566        responses.push(Ok(check_yaml("abc00000"))); // abc individual
1567        responses.push(Ok(check_yaml("def00000"))); // def individual
1568        let client = make_client(responses);
1569        let result = cmd
1570            .check_with_map_reduce(&client, &repo_view, None, &[])
1571            .await;
1572        assert!(result.is_ok());
1573        assert_eq!(result.unwrap().commits.len(), 2);
1574    }
1575
1576    #[tokio::test]
1577    async fn check_with_map_reduce_batch_fails_split_one_individual_fails_quiet() {
1578        // Batch fails → split-and-retry. abc succeeds; def exhausts its retries
1579        // and is recorded in failed_indices. In quiet mode the method returns
1580        // Ok with partial results rather than bailing (successes is non-empty).
1581        let (c1, _t1) = make_check_commit("abc00000");
1582        let (c2, _t2) = make_check_commit("def00000");
1583        let cmd = make_check_cmd(true);
1584        let repo_view = make_check_repo_view(vec![c1, c2]);
1585        let mut responses = errs(3); // batch failure
1586        responses.push(Ok(check_yaml("abc00000"))); // abc individual succeeds
1587        responses.extend(errs(3)); // def individual exhausts retries
1588        let client = make_client(responses);
1589        let result = cmd
1590            .check_with_map_reduce(&client, &repo_view, None, &[])
1591            .await;
1592        // abc succeeded, so successes is non-empty and the method returns Ok
1593        assert!(result.is_ok());
1594        assert_eq!(result.unwrap().commits.len(), 1);
1595    }
1596
1597    #[tokio::test]
1598    async fn check_with_map_reduce_all_fail_in_split_retry_returns_err() {
1599        // Batch fails → split-and-retry. Both individual commits also fail.
1600        // successes stays empty so the method bails.
1601        let (c1, _t1) = make_check_commit("abc00000");
1602        let (c2, _t2) = make_check_commit("def00000");
1603        let cmd = make_check_cmd(true);
1604        let repo_view = make_check_repo_view(vec![c1, c2]);
1605        let mut responses = errs(3); // batch failure
1606        responses.extend(errs(3)); // abc individual exhausts retries
1607        responses.extend(errs(3)); // def individual exhausts retries
1608        let client = make_client(responses);
1609        let result = cmd
1610            .check_with_map_reduce(&client, &repo_view, None, &[])
1611            .await;
1612        assert!(result.is_err(), "no successes should bail");
1613    }
1614
1615    // Non-quiet variants: cover the `if !self.quiet { println!(...) }` branches
1616    // that are skipped when quiet=true. With quiet=false and all commits
1617    // ultimately succeeding, failed_indices stays empty so the interactive
1618    // stdin loop is never entered.
1619
1620    #[tokio::test]
1621    async fn check_with_map_reduce_non_quiet_single_commit_succeeds() {
1622        // quiet=false covers the "✅ All commits checked!" and multi-batch
1623        // grouping print paths. Two commits in one batch → batches(1) < total(2)
1624        // triggers the "📦 Grouped..." message.
1625        let (c1, _t1) = make_check_commit("abc00000");
1626        let (c2, _t2) = make_check_commit("def00000");
1627        let cmd = make_check_cmd(false);
1628        let repo_view = make_check_repo_view(vec![c1, c2]);
1629        let mut responses = errs(3); // batch failure → split-and-retry
1630        responses.push(Ok(check_yaml("abc00000")));
1631        responses.push(Ok(check_yaml("def00000")));
1632        let client = make_client(responses);
1633        let result = cmd
1634            .check_with_map_reduce(&client, &repo_view, None, &[])
1635            .await;
1636        assert!(result.is_ok());
1637        assert_eq!(result.unwrap().commits.len(), 2);
1638    }
1639
1640    // --- run_interactive_retry_check ---
1641
1642    #[tokio::test]
1643    async fn interactive_retry_skip_immediately() {
1644        // "s" input → loop exits without calling the AI client at all.
1645        let (commit, _tmp) = make_check_commit("abc00000");
1646        let cmd = make_check_cmd(false);
1647        let repo_view = make_check_repo_view(vec![commit]);
1648        let client = make_client(vec![]); // no responses needed
1649        let mut failed = vec![0usize];
1650        let mut successes = vec![];
1651        let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
1652        cmd.run_interactive_retry_check(
1653            &mut failed,
1654            &repo_view,
1655            &client,
1656            None,
1657            &[],
1658            &mut successes,
1659            &mut stdin,
1660        )
1661        .await
1662        .unwrap();
1663        assert_eq!(
1664            failed,
1665            vec![0],
1666            "skip should leave failed_indices unchanged"
1667        );
1668        assert!(successes.is_empty());
1669    }
1670
1671    #[tokio::test]
1672    async fn interactive_retry_retry_succeeds() {
1673        // "r" input → retries the failed commit, which succeeds.
1674        let (commit, _tmp) = make_check_commit("abc00000");
1675        let cmd = make_check_cmd(false);
1676        let repo_view = make_check_repo_view(vec![commit]);
1677        let client = make_client(vec![Ok(check_yaml("abc00000"))]);
1678        let mut failed = vec![0usize];
1679        let mut successes = vec![];
1680        let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
1681        cmd.run_interactive_retry_check(
1682            &mut failed,
1683            &repo_view,
1684            &client,
1685            None,
1686            &[],
1687            &mut successes,
1688            &mut stdin,
1689        )
1690        .await
1691        .unwrap();
1692        assert!(
1693            failed.is_empty(),
1694            "retry succeeded → failed_indices cleared"
1695        );
1696        assert_eq!(successes.len(), 1);
1697    }
1698
1699    #[tokio::test]
1700    async fn interactive_retry_default_input_retries() {
1701        // Empty input (just Enter) is treated as "r" (retry).
1702        let (commit, _tmp) = make_check_commit("abc00000");
1703        let cmd = make_check_cmd(false);
1704        let repo_view = make_check_repo_view(vec![commit]);
1705        let client = make_client(vec![Ok(check_yaml("abc00000"))]);
1706        let mut failed = vec![0usize];
1707        let mut successes = vec![];
1708        let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
1709        cmd.run_interactive_retry_check(
1710            &mut failed,
1711            &repo_view,
1712            &client,
1713            None,
1714            &[],
1715            &mut successes,
1716            &mut stdin,
1717        )
1718        .await
1719        .unwrap();
1720        assert!(failed.is_empty());
1721        assert_eq!(successes.len(), 1);
1722    }
1723
1724    #[tokio::test]
1725    async fn interactive_retry_still_fails_then_skip() {
1726        // "r" → retry fails → still in failed_indices → "s" → skip.
1727        let (commit, _tmp) = make_check_commit("abc00000");
1728        let cmd = make_check_cmd(false);
1729        let repo_view = make_check_repo_view(vec![commit]);
1730        // Retry fails (3 attempts), then skip.
1731        let responses = errs(3);
1732        let client = make_client(responses);
1733        let mut failed = vec![0usize];
1734        let mut successes = vec![];
1735        let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
1736        cmd.run_interactive_retry_check(
1737            &mut failed,
1738            &repo_view,
1739            &client,
1740            None,
1741            &[],
1742            &mut successes,
1743            &mut stdin,
1744        )
1745        .await
1746        .unwrap();
1747        assert_eq!(failed, vec![0], "commit still failed after retry");
1748        assert!(successes.is_empty());
1749    }
1750
1751    #[tokio::test]
1752    async fn interactive_retry_invalid_input_then_skip() {
1753        // Unrecognised input → "please enter r or s" message → "s" exits.
1754        let (commit, _tmp) = make_check_commit("abc00000");
1755        let cmd = make_check_cmd(false);
1756        let repo_view = make_check_repo_view(vec![commit]);
1757        let client = make_client(vec![]);
1758        let mut failed = vec![0usize];
1759        let mut successes = vec![];
1760        let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
1761        cmd.run_interactive_retry_check(
1762            &mut failed,
1763            &repo_view,
1764            &client,
1765            None,
1766            &[],
1767            &mut successes,
1768            &mut stdin,
1769        )
1770        .await
1771        .unwrap();
1772        assert_eq!(failed, vec![0]);
1773        assert!(successes.is_empty());
1774    }
1775
1776    #[tokio::test]
1777    async fn interactive_retry_eof_breaks_immediately() {
1778        // EOF (empty reader) → read_line returns Ok(0) → loop breaks without
1779        // calling the AI client. failed_indices stays unchanged.
1780        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![]); // no responses consumed
1784        let mut failed = vec![0usize];
1785        let mut successes = vec![];
1786        let mut stdin = std::io::Cursor::new(b"" 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_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
1799        assert!(successes.is_empty());
1800    }
1801
1802    // --- prompt_and_apply_suggestions ---
1803
1804    fn make_amendment() -> crate::data::amendments::Amendment {
1805        crate::data::amendments::Amendment {
1806            commit: "abc0000000000000000000000000000000000001".to_string(),
1807            message: "feat: improved commit message".to_string(),
1808            summary: None,
1809        }
1810    }
1811
1812    #[tokio::test]
1813    async fn prompt_and_apply_suggestions_non_terminal_returns_false() {
1814        // is_terminal=false → non-interactive warning, returns Ok(false) immediately.
1815        let cmd = make_check_cmd(false);
1816        let mut reader = std::io::Cursor::new(b"" as &[u8]);
1817        let result = cmd
1818            .prompt_and_apply_suggestions(vec![make_amendment()], false, &mut reader)
1819            .await
1820            .unwrap();
1821        assert!(!result, "non-terminal should return false");
1822    }
1823
1824    #[tokio::test]
1825    async fn prompt_and_apply_suggestions_eof_returns_false() {
1826        // is_terminal=true, EOF reader → read_line returns 0, returns Ok(false).
1827        let cmd = make_check_cmd(false);
1828        let mut reader = std::io::Cursor::new(b"" as &[u8]);
1829        let result = cmd
1830            .prompt_and_apply_suggestions(vec![make_amendment()], true, &mut reader)
1831            .await
1832            .unwrap();
1833        assert!(!result, "EOF should return false");
1834    }
1835
1836    #[tokio::test]
1837    async fn prompt_and_apply_suggestions_quit_returns_false() {
1838        // is_terminal=true, "q\n" → user quits, returns Ok(false).
1839        let cmd = make_check_cmd(false);
1840        let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
1841        let result = cmd
1842            .prompt_and_apply_suggestions(vec![make_amendment()], true, &mut reader)
1843            .await
1844            .unwrap();
1845        assert!(!result, "quit should return false");
1846    }
1847
1848    #[tokio::test]
1849    async fn prompt_and_apply_suggestions_invalid_then_quit_returns_false() {
1850        // is_terminal=true, invalid input then "q\n" → prints error, then user quits.
1851        let cmd = make_check_cmd(false);
1852        let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
1853        let result = cmd
1854            .prompt_and_apply_suggestions(vec![make_amendment()], true, &mut reader)
1855            .await
1856            .unwrap();
1857        assert!(!result, "invalid then quit should return false");
1858    }
1859}