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    /// Deprecated: use --concurrency instead.
54    #[arg(long, default_value = "4", hide = true)]
55    pub batch_size: usize,
56
57    /// Maximum number of concurrent AI requests (default: 4).
58    #[arg(long, default_value = "4")]
59    pub concurrency: 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(self) -> Result<()> {
77        use crate::data::check::OutputFormat;
78
79        // Parse output format
80        let output_format: OutputFormat = self.format.parse().unwrap_or(OutputFormat::Text);
81
82        // Preflight check: validate AI credentials before any processing
83        let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
84        if !self.quiet && output_format == OutputFormat::Text {
85            println!(
86                "✓ {} credentials verified (model: {})",
87                ai_info.provider, ai_info.model
88            );
89        }
90
91        if !self.quiet && output_format == OutputFormat::Text {
92            println!("🔍 Checking commit messages against guidelines...");
93        }
94
95        // 1. Generate repository view to get all commits
96        let mut repo_view = self.generate_repository_view().await?;
97
98        // 2. Check for empty commit range (exit code 3)
99        if repo_view.commits.is_empty() {
100            eprintln!("error: no commits found in range");
101            std::process::exit(3);
102        }
103
104        if !self.quiet && output_format == OutputFormat::Text {
105            println!("📊 Found {} commits to check", repo_view.commits.len());
106        }
107
108        // 3. Load commit guidelines and scopes
109        let guidelines = self.load_guidelines().await?;
110        let valid_scopes = self.load_scopes();
111
112        // Refine detected scopes using file_patterns from scope definitions
113        for commit in &mut repo_view.commits {
114            commit.analysis.refine_scope(&valid_scopes);
115        }
116
117        if !self.quiet && output_format == OutputFormat::Text {
118            self.show_guidance_files_status(&guidelines, &valid_scopes);
119        }
120
121        // 4. Initialize Claude client
122        let beta = self
123            .beta_header
124            .as_deref()
125            .map(parse_beta_header)
126            .transpose()?;
127        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
128
129        if self.verbose && output_format == OutputFormat::Text {
130            self.show_model_info(&claude_client)?;
131        }
132
133        // 5. Use parallel map-reduce for multiple commits, direct call for single
134        let report = if repo_view.commits.len() > 1 {
135            if !self.quiet && output_format == OutputFormat::Text {
136                println!(
137                    "🔄 Processing {} commits in parallel (concurrency: {})...",
138                    repo_view.commits.len(),
139                    self.concurrency
140                );
141            }
142            self.check_with_map_reduce(
143                &claude_client,
144                &repo_view,
145                guidelines.as_deref(),
146                &valid_scopes,
147            )
148            .await?
149        } else {
150            // Single commit — direct call
151            if !self.quiet && output_format == OutputFormat::Text {
152                println!("🤖 Analyzing commits with AI...");
153            }
154            claude_client
155                .check_commits_with_scopes(
156                    &repo_view,
157                    guidelines.as_deref(),
158                    &valid_scopes,
159                    !self.no_suggestions,
160                )
161                .await?
162        };
163
164        // 7. Output results
165        self.output_report(&report, output_format)?;
166
167        // 8. If --twiddle and there are errors with suggestions, offer to apply them
168        if self.twiddle && report.has_errors() && output_format == OutputFormat::Text {
169            let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
170            if !amendments.is_empty() && self.prompt_and_apply_suggestions(amendments).await? {
171                // Amendments applied — exit successfully
172                return Ok(());
173            }
174        }
175
176        // 9. Determine exit code
177        let exit_code = report.exit_code(self.strict);
178        if exit_code != 0 {
179            std::process::exit(exit_code);
180        }
181
182        Ok(())
183    }
184
185    /// Generates the repository view (reuses logic from TwiddleCommand).
186    async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
187        use crate::data::{
188            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
189            WorkingDirectoryInfo,
190        };
191        use crate::git::{GitRepository, RemoteInfo};
192        use crate::utils::ai_scratch;
193
194        // Open git repository
195        let repo = GitRepository::open()
196            .context("Failed to open git repository. Make sure you're in a git repository.")?;
197
198        // Get current branch name
199        let current_branch = repo
200            .get_current_branch()
201            .unwrap_or_else(|_| "HEAD".to_string());
202
203        // Determine commit range
204        let commit_range = match &self.commit_range {
205            Some(range) => range.clone(),
206            None => {
207                // Default to commits ahead of main branch
208                let base = if repo.branch_exists("main")? {
209                    "main"
210                } else if repo.branch_exists("master")? {
211                    "master"
212                } else {
213                    "HEAD~5"
214                };
215                format!("{}..HEAD", base)
216            }
217        };
218
219        // Get working directory status
220        let wd_status = repo.get_working_directory_status()?;
221        let working_directory = WorkingDirectoryInfo {
222            clean: wd_status.clean,
223            untracked_changes: wd_status
224                .untracked_changes
225                .into_iter()
226                .map(|fs| FileStatusInfo {
227                    status: fs.status,
228                    file: fs.file,
229                })
230                .collect(),
231        };
232
233        // Get remote information
234        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
235
236        // Parse commit range and get commits
237        let commits = repo.get_commits_in_range(&commit_range)?;
238
239        // Create version information
240        let versions = Some(VersionInfo {
241            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
242        });
243
244        // Get AI scratch directory
245        let ai_scratch_path =
246            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
247        let ai_info = AiInfo {
248            scratch: ai_scratch_path.to_string_lossy().to_string(),
249        };
250
251        // Build repository view with branch info
252        let mut repo_view = RepositoryView {
253            versions,
254            explanation: FieldExplanation::default(),
255            working_directory,
256            remotes,
257            ai: ai_info,
258            branch_info: Some(BranchInfo {
259                branch: current_branch,
260            }),
261            pr_template: None,
262            pr_template_location: None,
263            branch_prs: None,
264            commits,
265        };
266
267        // Update field presence based on actual data
268        repo_view.update_field_presence();
269
270        Ok(repo_view)
271    }
272
273    /// Loads commit guidelines from file or context directory.
274    async fn load_guidelines(&self) -> Result<Option<String>> {
275        use std::fs;
276
277        // If explicit guidelines path is provided, use it
278        if let Some(guidelines_path) = &self.guidelines {
279            let content = fs::read_to_string(guidelines_path).with_context(|| {
280                format!("Failed to read guidelines file: {:?}", guidelines_path)
281            })?;
282            return Ok(Some(content));
283        }
284
285        // Otherwise, use project discovery to find guidelines
286        let context_dir = self
287            .context_dir
288            .clone()
289            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
290
291        // Try local override first
292        let local_path = context_dir.join("local").join("commit-guidelines.md");
293        if local_path.exists() {
294            let content = fs::read_to_string(&local_path)
295                .with_context(|| format!("Failed to read guidelines: {:?}", local_path))?;
296            return Ok(Some(content));
297        }
298
299        // Try project-level guidelines
300        let project_path = context_dir.join("commit-guidelines.md");
301        if project_path.exists() {
302            let content = fs::read_to_string(&project_path)
303                .with_context(|| format!("Failed to read guidelines: {:?}", project_path))?;
304            return Ok(Some(content));
305        }
306
307        // Try global guidelines
308        if let Some(home) = dirs::home_dir() {
309            let home_path = home.join(".omni-dev").join("commit-guidelines.md");
310            if home_path.exists() {
311                let content = fs::read_to_string(&home_path)
312                    .with_context(|| format!("Failed to read guidelines: {:?}", home_path))?;
313                return Ok(Some(content));
314            }
315        }
316
317        // No custom guidelines found, will use defaults
318        Ok(None)
319    }
320
321    /// Loads valid scopes from context directory.
322    ///
323    /// This ensures the check command uses the same scopes as the twiddle command,
324    /// preventing false positives when validating commit messages.
325    fn load_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
326        use crate::data::context::ScopeDefinition;
327        use std::fs;
328
329        // Local config struct matching the YAML format
330        #[derive(serde::Deserialize)]
331        struct ScopesConfig {
332            scopes: Vec<ScopeDefinition>,
333        }
334
335        let context_dir = self
336            .context_dir
337            .clone()
338            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
339
340        // Search paths in priority order: local override → project-level → global
341        let mut candidates: Vec<std::path::PathBuf> = vec![
342            context_dir.join("local").join("scopes.yaml"),
343            context_dir.join("scopes.yaml"),
344        ];
345        if let Some(home) = dirs::home_dir() {
346            candidates.push(home.join(".omni-dev").join("scopes.yaml"));
347        }
348
349        for path in &candidates {
350            if !path.exists() {
351                continue;
352            }
353            match fs::read_to_string(path) {
354                Ok(content) => match serde_yaml::from_str::<ScopesConfig>(&content) {
355                    Ok(config) => return config.scopes,
356                    Err(e) => {
357                        eprintln!(
358                            "warning: ignoring malformed scopes file {}: {e}",
359                            path.display()
360                        );
361                    }
362                },
363                Err(e) => {
364                    eprintln!("warning: cannot read scopes file {}: {e}", path.display());
365                }
366            }
367        }
368
369        // No scopes found
370        Vec::new()
371    }
372
373    /// Shows diagnostic information about loaded guidance files.
374    fn show_guidance_files_status(
375        &self,
376        guidelines: &Option<String>,
377        valid_scopes: &[crate::data::context::ScopeDefinition],
378    ) {
379        let context_dir = self
380            .context_dir
381            .clone()
382            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
383
384        println!("📋 Project guidance files status:");
385
386        // Check commit guidelines
387        let guidelines_found = guidelines.is_some();
388        let guidelines_source = if guidelines_found {
389            let local_path = context_dir.join("local").join("commit-guidelines.md");
390            let project_path = context_dir.join("commit-guidelines.md");
391            let home_path = dirs::home_dir()
392                .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
393                .unwrap_or_default();
394
395            if local_path.exists() {
396                format!("✅ Local override: {}", local_path.display())
397            } else if project_path.exists() {
398                format!("✅ Project: {}", project_path.display())
399            } else if home_path.exists() {
400                format!("✅ Global: {}", home_path.display())
401            } else {
402                "✅ (source unknown)".to_string()
403            }
404        } else {
405            "⚪ Using defaults".to_string()
406        };
407        println!("   📝 Commit guidelines: {}", guidelines_source);
408
409        // Check scopes
410        let scopes_count = valid_scopes.len();
411        let scopes_source = if scopes_count > 0 {
412            let local_path = context_dir.join("local").join("scopes.yaml");
413            let project_path = context_dir.join("scopes.yaml");
414            let home_path = dirs::home_dir()
415                .map(|h| h.join(".omni-dev").join("scopes.yaml"))
416                .unwrap_or_default();
417
418            let source = if local_path.exists() {
419                format!("Local override: {}", local_path.display())
420            } else if project_path.exists() {
421                format!("Project: {}", project_path.display())
422            } else if home_path.exists() {
423                format!("Global: {}", home_path.display())
424            } else {
425                "(source unknown)".to_string()
426            };
427            format!("✅ {} ({} scopes)", source, scopes_count)
428        } else {
429            "⚪ None found (any scope accepted)".to_string()
430        };
431        println!("   🎯 Valid scopes: {}", scopes_source);
432
433        println!();
434    }
435
436    /// Checks commits in parallel using batched map-reduce pattern.
437    ///
438    /// Groups commits into token-budget-aware batches, processes batches
439    /// in parallel, then runs an optional coherence pass (skipped when
440    /// all commits fit in a single batch).
441    async fn check_with_map_reduce(
442        &self,
443        claude_client: &crate::claude::client::ClaudeClient,
444        full_repo_view: &crate::data::RepositoryView,
445        guidelines: Option<&str>,
446        valid_scopes: &[crate::data::context::ScopeDefinition],
447    ) -> Result<crate::data::check::CheckReport> {
448        use std::sync::atomic::{AtomicUsize, Ordering};
449        use std::sync::Arc;
450
451        use crate::claude::batch;
452        use crate::claude::token_budget;
453        use crate::data::check::{CheckReport, CommitCheckResult};
454
455        let total_commits = full_repo_view.commits.len();
456
457        // Plan batches based on token budget
458        let metadata = claude_client.get_ai_client_metadata();
459        let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
460            guidelines,
461            valid_scopes,
462        );
463        let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
464        let batch_plan =
465            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
466
467        if !self.quiet && batch_plan.batches.len() < total_commits {
468            println!(
469                "   📦 Grouped {} commits into {} batches by token budget",
470                total_commits,
471                batch_plan.batches.len()
472            );
473        }
474
475        let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
476        let completed = Arc::new(AtomicUsize::new(0));
477
478        // Map phase: check batches in parallel
479        let futs: Vec<_> = batch_plan
480            .batches
481            .iter()
482            .map(|batch| {
483                let sem = semaphore.clone();
484                let completed = completed.clone();
485                let batch_indices = &batch.commit_indices;
486
487                async move {
488                    let _permit = sem
489                        .acquire()
490                        .await
491                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
492
493                    let batch_size = batch_indices.len();
494
495                    // Create view for this batch
496                    let batch_view = if batch_size == 1 {
497                        full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
498                    } else {
499                        let commits: Vec<_> = batch_indices
500                            .iter()
501                            .map(|&i| &full_repo_view.commits[i])
502                            .collect();
503                        full_repo_view.multi_commit_view(&commits)
504                    };
505
506                    let result = claude_client
507                        .check_commits_with_scopes(
508                            &batch_view,
509                            guidelines,
510                            valid_scopes,
511                            !self.no_suggestions,
512                        )
513                        .await;
514
515                    match result {
516                        Ok(report) => {
517                            let done =
518                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
519                            if !self.quiet {
520                                println!("   ✅ {}/{} commits checked", done, total_commits);
521                            }
522
523                            let items: Vec<_> = report
524                                .commits
525                                .into_iter()
526                                .map(|r| {
527                                    let summary = r.summary.clone().unwrap_or_default();
528                                    (r, summary)
529                                })
530                                .collect();
531                            Ok(items)
532                        }
533                        Err(e) if batch_size > 1 => {
534                            // Split-and-retry: fall back to individual commits
535                            eprintln!(
536                                "warning: batch of {} failed, retrying individually: {e}",
537                                batch_size
538                            );
539                            let mut items = Vec::new();
540                            for &idx in batch_indices {
541                                let single_view =
542                                    full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
543                                let single_result = claude_client
544                                    .check_commits_with_scopes(
545                                        &single_view,
546                                        guidelines,
547                                        valid_scopes,
548                                        !self.no_suggestions,
549                                    )
550                                    .await;
551                                match single_result {
552                                    Ok(report) => {
553                                        if let Some(r) = report.commits.into_iter().next() {
554                                            let summary = r.summary.clone().unwrap_or_default();
555                                            items.push((r, summary));
556                                        }
557                                    }
558                                    Err(e) => {
559                                        eprintln!("warning: failed to check commit: {e}");
560                                    }
561                                }
562                                let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
563                                if !self.quiet {
564                                    println!("   ✅ {}/{} commits checked", done, total_commits);
565                                }
566                            }
567                            Ok(items)
568                        }
569                        Err(e) => Err(e),
570                    }
571                }
572            })
573            .collect();
574
575        let results = futures::future::join_all(futs).await;
576
577        // Flatten batch results
578        let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
579        let mut failure_count = 0;
580
581        for result in results {
582            match result {
583                Ok(items) => successes.extend(items),
584                Err(e) => {
585                    eprintln!("warning: failed to check commit: {e}");
586                    failure_count += 1;
587                }
588            }
589        }
590
591        if failure_count > 0 {
592            eprintln!("warning: {failure_count} commit(s) failed to check");
593        }
594
595        if successes.is_empty() {
596            anyhow::bail!("All commits failed to check");
597        }
598
599        // Reduce phase: optional coherence pass
600        // Skip when all commits were in a single batch (AI already saw them together)
601        let single_batch = batch_plan.batches.len() <= 1;
602        if !self.no_coherence && !single_batch && successes.len() >= 2 {
603            if !self.quiet {
604                println!("🔗 Running cross-commit coherence pass...");
605            }
606            match claude_client
607                .refine_checks_coherence(&successes, full_repo_view)
608                .await
609            {
610                Ok(refined) => {
611                    if !self.quiet {
612                        println!("✅ All commits checked!");
613                    }
614                    return Ok(refined);
615                }
616                Err(e) => {
617                    eprintln!("warning: coherence pass failed, using individual results: {e}");
618                }
619            }
620        }
621
622        if !self.quiet {
623            println!("✅ All commits checked!");
624        }
625
626        let all_results: Vec<CommitCheckResult> = successes.into_iter().map(|(r, _)| r).collect();
627
628        Ok(CheckReport::new(all_results))
629    }
630
631    /// Outputs the check report in the specified format.
632    fn output_report(
633        &self,
634        report: &crate::data::check::CheckReport,
635        format: crate::data::check::OutputFormat,
636    ) -> Result<()> {
637        use crate::data::check::OutputFormat;
638
639        match format {
640            OutputFormat::Text => self.output_text_report(report),
641            OutputFormat::Json => {
642                let json = serde_json::to_string_pretty(report)
643                    .context("Failed to serialize report to JSON")?;
644                println!("{}", json);
645                Ok(())
646            }
647            OutputFormat::Yaml => {
648                let yaml =
649                    crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
650                println!("{}", yaml);
651                Ok(())
652            }
653        }
654    }
655
656    /// Outputs the text format report.
657    fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
658        use crate::data::check::IssueSeverity;
659
660        println!();
661
662        for result in &report.commits {
663            // Skip passing commits unless --show-passing is set
664            if result.passes && !self.show_passing {
665                continue;
666            }
667
668            // Skip info-only commits in quiet mode
669            if self.quiet {
670                let has_errors_or_warnings = result
671                    .issues
672                    .iter()
673                    .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning));
674                if !has_errors_or_warnings {
675                    continue;
676                }
677            }
678
679            // Determine icon
680            let icon = if result.passes {
681                "✅"
682            } else if result
683                .issues
684                .iter()
685                .any(|i| i.severity == IssueSeverity::Error)
686            {
687                "❌"
688            } else {
689                "⚠️ "
690            };
691
692            // Short hash
693            let short_hash = if result.hash.len() > 7 {
694                &result.hash[..7]
695            } else {
696                &result.hash
697            };
698
699            println!("{} {} - \"{}\"", icon, short_hash, result.message);
700
701            // Print issues
702            for issue in &result.issues {
703                // Skip info issues in quiet mode
704                if self.quiet && issue.severity == IssueSeverity::Info {
705                    continue;
706                }
707
708                let severity_str = match issue.severity {
709                    IssueSeverity::Error => "\x1b[31mERROR\x1b[0m  ",
710                    IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
711                    IssueSeverity::Info => "\x1b[36mINFO\x1b[0m   ",
712                };
713
714                println!(
715                    "   {} [{}] {}",
716                    severity_str, issue.section, issue.explanation
717                );
718            }
719
720            // Print suggestion if available and not in quiet mode
721            if !self.quiet {
722                if let Some(suggestion) = &result.suggestion {
723                    println!();
724                    println!("   Suggested message:");
725                    for line in suggestion.message.lines() {
726                        println!("      {}", line);
727                    }
728                    if self.verbose {
729                        println!();
730                        println!("   Why this is better:");
731                        for line in suggestion.explanation.lines() {
732                            println!("   {}", line);
733                        }
734                    }
735                }
736            }
737
738            println!();
739        }
740
741        // Print summary
742        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
743        println!("Summary: {} commits checked", report.summary.total_commits);
744        println!(
745            "  {} errors, {} warnings",
746            report.summary.error_count, report.summary.warning_count
747        );
748        println!(
749            "  {} passed, {} with issues",
750            report.summary.passing_commits, report.summary.failing_commits
751        );
752
753        Ok(())
754    }
755
756    /// Shows model information.
757    fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
758        use crate::claude::model_config::get_model_registry;
759
760        println!("🤖 AI Model Configuration:");
761
762        let metadata = client.get_ai_client_metadata();
763        let registry = get_model_registry();
764
765        if let Some(spec) = registry.get_model_spec(&metadata.model) {
766            if metadata.model != spec.api_identifier {
767                println!(
768                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
769                    metadata.model, spec.api_identifier
770                );
771            } else {
772                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
773            }
774            println!("   🏷️  Provider: {}", spec.provider);
775        } else {
776            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
777            println!("   🏷️  Provider: {}", metadata.provider);
778        }
779
780        println!();
781        Ok(())
782    }
783
784    /// Builds amendments from check report suggestions for failing commits.
785    fn build_amendments_from_suggestions(
786        &self,
787        report: &crate::data::check::CheckReport,
788        repo_view: &crate::data::RepositoryView,
789    ) -> Vec<crate::data::amendments::Amendment> {
790        use crate::data::amendments::Amendment;
791
792        report
793            .commits
794            .iter()
795            .filter(|r| !r.passes)
796            .filter_map(|r| {
797                let suggestion = r.suggestion.as_ref()?;
798                let full_hash = repo_view.commits.iter().find_map(|c| {
799                    if c.hash.starts_with(&r.hash) || r.hash.starts_with(&c.hash) {
800                        Some(c.hash.clone())
801                    } else {
802                        None
803                    }
804                });
805                full_hash.map(|hash| Amendment::new(hash, suggestion.message.clone()))
806            })
807            .collect()
808    }
809
810    /// Prompts the user to apply suggested amendments and applies them if accepted.
811    /// Returns true if amendments were applied, false if user declined.
812    async fn prompt_and_apply_suggestions(
813        &self,
814        amendments: Vec<crate::data::amendments::Amendment>,
815    ) -> Result<bool> {
816        use crate::data::amendments::AmendmentFile;
817        use crate::git::AmendmentHandler;
818        use std::io::{self, Write};
819
820        println!();
821        println!(
822            "🔧 {} commit(s) have issues with suggested fixes available.",
823            amendments.len()
824        );
825
826        loop {
827            print!("❓ [A]pply suggested fixes, or [Q]uit? [A/q] ");
828            io::stdout().flush()?;
829
830            let mut input = String::new();
831            io::stdin().read_line(&mut input)?;
832
833            match input.trim().to_lowercase().as_str() {
834                "a" | "apply" | "" => {
835                    let amendment_file = AmendmentFile { amendments };
836                    let temp_file = tempfile::NamedTempFile::new()
837                        .context("Failed to create temp file for amendments")?;
838                    amendment_file
839                        .save_to_file(temp_file.path())
840                        .context("Failed to save amendments")?;
841
842                    let handler = AmendmentHandler::new()
843                        .context("Failed to initialize amendment handler")?;
844                    handler
845                        .apply_amendments(&temp_file.path().to_string_lossy())
846                        .context("Failed to apply amendments")?;
847
848                    println!("✅ Suggested fixes applied successfully!");
849                    return Ok(true);
850                }
851                "q" | "quit" => return Ok(false),
852                _ => {
853                    println!("Invalid choice. Please enter 'a' to apply or 'q' to quit.");
854                }
855            }
856        }
857    }
858}