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