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