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