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