Skip to main content

omni_dev/cli/git/
check.rs

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