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 with ecosystem defaults.
322    fn load_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
323        let context_dir = self
324            .context_dir
325            .clone()
326            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
327        crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."))
328    }
329
330    /// Shows diagnostic information about loaded guidance files.
331    fn show_guidance_files_status(
332        &self,
333        guidelines: &Option<String>,
334        valid_scopes: &[crate::data::context::ScopeDefinition],
335    ) {
336        let context_dir = self
337            .context_dir
338            .clone()
339            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
340
341        println!("📋 Project guidance files status:");
342
343        // Check commit guidelines
344        let guidelines_found = guidelines.is_some();
345        let guidelines_source = if guidelines_found {
346            let local_path = context_dir.join("local").join("commit-guidelines.md");
347            let project_path = context_dir.join("commit-guidelines.md");
348            let home_path = dirs::home_dir()
349                .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
350                .unwrap_or_default();
351
352            if local_path.exists() {
353                format!("✅ Local override: {}", local_path.display())
354            } else if project_path.exists() {
355                format!("✅ Project: {}", project_path.display())
356            } else if home_path.exists() {
357                format!("✅ Global: {}", home_path.display())
358            } else {
359                "✅ (source unknown)".to_string()
360            }
361        } else {
362            "⚪ Using defaults".to_string()
363        };
364        println!("   📝 Commit guidelines: {}", guidelines_source);
365
366        // Check scopes
367        let scopes_count = valid_scopes.len();
368        let scopes_source = if scopes_count > 0 {
369            let local_path = context_dir.join("local").join("scopes.yaml");
370            let project_path = context_dir.join("scopes.yaml");
371            let home_path = dirs::home_dir()
372                .map(|h| h.join(".omni-dev").join("scopes.yaml"))
373                .unwrap_or_default();
374
375            let source = if local_path.exists() {
376                format!("Local override: {}", local_path.display())
377            } else if project_path.exists() {
378                format!("Project: {}", project_path.display())
379            } else if home_path.exists() {
380                format!("Global: {}", home_path.display())
381            } else {
382                "(source unknown)".to_string()
383            };
384            format!("✅ {} ({} scopes)", source, scopes_count)
385        } else {
386            "⚪ None found (any scope accepted)".to_string()
387        };
388        println!("   🎯 Valid scopes: {}", scopes_source);
389
390        println!();
391    }
392
393    /// Checks commits in parallel using batched map-reduce pattern.
394    ///
395    /// Groups commits into token-budget-aware batches, processes batches
396    /// in parallel, then runs an optional coherence pass (skipped when
397    /// all commits fit in a single batch).
398    async fn check_with_map_reduce(
399        &self,
400        claude_client: &crate::claude::client::ClaudeClient,
401        full_repo_view: &crate::data::RepositoryView,
402        guidelines: Option<&str>,
403        valid_scopes: &[crate::data::context::ScopeDefinition],
404    ) -> Result<crate::data::check::CheckReport> {
405        use std::sync::atomic::{AtomicUsize, Ordering};
406        use std::sync::Arc;
407
408        use crate::claude::batch;
409        use crate::claude::token_budget;
410        use crate::data::check::{CheckReport, CommitCheckResult};
411
412        let total_commits = full_repo_view.commits.len();
413
414        // Plan batches based on token budget
415        let metadata = claude_client.get_ai_client_metadata();
416        let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
417            guidelines,
418            valid_scopes,
419        );
420        let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
421        let batch_plan =
422            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
423
424        if !self.quiet && batch_plan.batches.len() < total_commits {
425            println!(
426                "   📦 Grouped {} commits into {} batches by token budget",
427                total_commits,
428                batch_plan.batches.len()
429            );
430        }
431
432        let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
433        let completed = Arc::new(AtomicUsize::new(0));
434
435        // Map phase: check batches in parallel
436        let futs: Vec<_> = batch_plan
437            .batches
438            .iter()
439            .map(|batch| {
440                let sem = semaphore.clone();
441                let completed = completed.clone();
442                let batch_indices = &batch.commit_indices;
443
444                async move {
445                    let _permit = sem
446                        .acquire()
447                        .await
448                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
449
450                    let batch_size = batch_indices.len();
451
452                    // Create view for this batch
453                    let batch_view = if batch_size == 1 {
454                        full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
455                    } else {
456                        let commits: Vec<_> = batch_indices
457                            .iter()
458                            .map(|&i| &full_repo_view.commits[i])
459                            .collect();
460                        full_repo_view.multi_commit_view(&commits)
461                    };
462
463                    let result = claude_client
464                        .check_commits_with_scopes(
465                            &batch_view,
466                            guidelines,
467                            valid_scopes,
468                            !self.no_suggestions,
469                        )
470                        .await;
471
472                    match result {
473                        Ok(report) => {
474                            let done =
475                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
476                            if !self.quiet {
477                                println!("   ✅ {}/{} commits checked", done, total_commits);
478                            }
479
480                            let items: Vec<_> = report
481                                .commits
482                                .into_iter()
483                                .map(|r| {
484                                    let summary = r.summary.clone().unwrap_or_default();
485                                    (r, summary)
486                                })
487                                .collect();
488                            Ok(items)
489                        }
490                        Err(e) if batch_size > 1 => {
491                            // Split-and-retry: fall back to individual commits
492                            eprintln!(
493                                "warning: batch of {} failed, retrying individually: {e}",
494                                batch_size
495                            );
496                            let mut items = Vec::new();
497                            for &idx in batch_indices {
498                                let single_view =
499                                    full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
500                                let single_result = claude_client
501                                    .check_commits_with_scopes(
502                                        &single_view,
503                                        guidelines,
504                                        valid_scopes,
505                                        !self.no_suggestions,
506                                    )
507                                    .await;
508                                match single_result {
509                                    Ok(report) => {
510                                        if let Some(r) = report.commits.into_iter().next() {
511                                            let summary = r.summary.clone().unwrap_or_default();
512                                            items.push((r, summary));
513                                        }
514                                    }
515                                    Err(e) => {
516                                        eprintln!("warning: failed to check commit: {e}");
517                                    }
518                                }
519                                let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
520                                if !self.quiet {
521                                    println!("   ✅ {}/{} commits checked", done, total_commits);
522                                }
523                            }
524                            Ok(items)
525                        }
526                        Err(e) => Err(e),
527                    }
528                }
529            })
530            .collect();
531
532        let results = futures::future::join_all(futs).await;
533
534        // Flatten batch results
535        let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
536        let mut failure_count = 0;
537
538        for result in results {
539            match result {
540                Ok(items) => successes.extend(items),
541                Err(e) => {
542                    eprintln!("warning: failed to check commit: {e}");
543                    failure_count += 1;
544                }
545            }
546        }
547
548        if failure_count > 0 {
549            eprintln!("warning: {failure_count} commit(s) failed to check");
550        }
551
552        if successes.is_empty() {
553            anyhow::bail!("All commits failed to check");
554        }
555
556        // Reduce phase: optional coherence pass
557        // Skip when all commits were in a single batch (AI already saw them together)
558        let single_batch = batch_plan.batches.len() <= 1;
559        if !self.no_coherence && !single_batch && successes.len() >= 2 {
560            if !self.quiet {
561                println!("🔗 Running cross-commit coherence pass...");
562            }
563            match claude_client
564                .refine_checks_coherence(&successes, full_repo_view)
565                .await
566            {
567                Ok(refined) => {
568                    if !self.quiet {
569                        println!("✅ All commits checked!");
570                    }
571                    return Ok(refined);
572                }
573                Err(e) => {
574                    eprintln!("warning: coherence pass failed, using individual results: {e}");
575                }
576            }
577        }
578
579        if !self.quiet {
580            println!("✅ All commits checked!");
581        }
582
583        let all_results: Vec<CommitCheckResult> = successes.into_iter().map(|(r, _)| r).collect();
584
585        Ok(CheckReport::new(all_results))
586    }
587
588    /// Outputs the check report in the specified format.
589    fn output_report(
590        &self,
591        report: &crate::data::check::CheckReport,
592        format: crate::data::check::OutputFormat,
593    ) -> Result<()> {
594        use crate::data::check::OutputFormat;
595
596        match format {
597            OutputFormat::Text => self.output_text_report(report),
598            OutputFormat::Json => {
599                let json = serde_json::to_string_pretty(report)
600                    .context("Failed to serialize report to JSON")?;
601                println!("{}", json);
602                Ok(())
603            }
604            OutputFormat::Yaml => {
605                let yaml =
606                    crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
607                println!("{}", yaml);
608                Ok(())
609            }
610        }
611    }
612
613    /// Outputs the text format report.
614    fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
615        use crate::data::check::IssueSeverity;
616
617        println!();
618
619        for result in &report.commits {
620            // Skip passing commits unless --show-passing is set
621            if result.passes && !self.show_passing {
622                continue;
623            }
624
625            // Skip info-only commits in quiet mode
626            if self.quiet {
627                let has_errors_or_warnings = result
628                    .issues
629                    .iter()
630                    .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning));
631                if !has_errors_or_warnings {
632                    continue;
633                }
634            }
635
636            // Determine icon
637            let icon = if result.passes {
638                "✅"
639            } else if result
640                .issues
641                .iter()
642                .any(|i| i.severity == IssueSeverity::Error)
643            {
644                "❌"
645            } else {
646                "⚠️ "
647            };
648
649            // Short hash
650            let short_hash = if result.hash.len() > 7 {
651                &result.hash[..7]
652            } else {
653                &result.hash
654            };
655
656            println!("{} {} - \"{}\"", icon, short_hash, result.message);
657
658            // Print issues
659            for issue in &result.issues {
660                // Skip info issues in quiet mode
661                if self.quiet && issue.severity == IssueSeverity::Info {
662                    continue;
663                }
664
665                let severity_str = match issue.severity {
666                    IssueSeverity::Error => "\x1b[31mERROR\x1b[0m  ",
667                    IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
668                    IssueSeverity::Info => "\x1b[36mINFO\x1b[0m   ",
669                };
670
671                println!(
672                    "   {} [{}] {}",
673                    severity_str, issue.section, issue.explanation
674                );
675            }
676
677            // Print suggestion if available and not in quiet mode
678            if !self.quiet {
679                if let Some(suggestion) = &result.suggestion {
680                    println!();
681                    println!("   Suggested message:");
682                    for line in suggestion.message.lines() {
683                        println!("      {}", line);
684                    }
685                    if self.verbose {
686                        println!();
687                        println!("   Why this is better:");
688                        for line in suggestion.explanation.lines() {
689                            println!("   {}", line);
690                        }
691                    }
692                }
693            }
694
695            println!();
696        }
697
698        // Print summary
699        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
700        println!("Summary: {} commits checked", report.summary.total_commits);
701        println!(
702            "  {} errors, {} warnings",
703            report.summary.error_count, report.summary.warning_count
704        );
705        println!(
706            "  {} passed, {} with issues",
707            report.summary.passing_commits, report.summary.failing_commits
708        );
709
710        Ok(())
711    }
712
713    /// Shows model information.
714    fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
715        use crate::claude::model_config::get_model_registry;
716
717        println!("🤖 AI Model Configuration:");
718
719        let metadata = client.get_ai_client_metadata();
720        let registry = get_model_registry();
721
722        if let Some(spec) = registry.get_model_spec(&metadata.model) {
723            if metadata.model != spec.api_identifier {
724                println!(
725                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
726                    metadata.model, spec.api_identifier
727                );
728            } else {
729                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
730            }
731            println!("   🏷️  Provider: {}", spec.provider);
732        } else {
733            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
734            println!("   🏷️  Provider: {}", metadata.provider);
735        }
736
737        println!();
738        Ok(())
739    }
740
741    /// Builds amendments from check report suggestions for failing commits.
742    fn build_amendments_from_suggestions(
743        &self,
744        report: &crate::data::check::CheckReport,
745        repo_view: &crate::data::RepositoryView,
746    ) -> Vec<crate::data::amendments::Amendment> {
747        use crate::data::amendments::Amendment;
748
749        report
750            .commits
751            .iter()
752            .filter(|r| !r.passes)
753            .filter_map(|r| {
754                let suggestion = r.suggestion.as_ref()?;
755                let full_hash = repo_view.commits.iter().find_map(|c| {
756                    if c.hash.starts_with(&r.hash) || r.hash.starts_with(&c.hash) {
757                        Some(c.hash.clone())
758                    } else {
759                        None
760                    }
761                });
762                full_hash.map(|hash| Amendment::new(hash, suggestion.message.clone()))
763            })
764            .collect()
765    }
766
767    /// Prompts the user to apply suggested amendments and applies them if accepted.
768    /// Returns true if amendments were applied, false if user declined.
769    async fn prompt_and_apply_suggestions(
770        &self,
771        amendments: Vec<crate::data::amendments::Amendment>,
772    ) -> Result<bool> {
773        use crate::data::amendments::AmendmentFile;
774        use crate::git::AmendmentHandler;
775        use std::io::{self, Write};
776
777        println!();
778        println!(
779            "🔧 {} commit(s) have issues with suggested fixes available.",
780            amendments.len()
781        );
782
783        loop {
784            print!("❓ [A]pply suggested fixes, or [Q]uit? [A/q] ");
785            io::stdout().flush()?;
786
787            let mut input = String::new();
788            io::stdin().read_line(&mut input)?;
789
790            match input.trim().to_lowercase().as_str() {
791                "a" | "apply" | "" => {
792                    let amendment_file = AmendmentFile { amendments };
793                    let temp_file = tempfile::NamedTempFile::new()
794                        .context("Failed to create temp file for amendments")?;
795                    amendment_file
796                        .save_to_file(temp_file.path())
797                        .context("Failed to save amendments")?;
798
799                    let handler = AmendmentHandler::new()
800                        .context("Failed to initialize amendment handler")?;
801                    handler
802                        .apply_amendments(&temp_file.path().to_string_lossy())
803                        .context("Failed to apply amendments")?;
804
805                    println!("✅ Suggested fixes applied successfully!");
806                    return Ok(true);
807                }
808                "q" | "quit" => return Ok(false),
809                _ => {
810                    println!("Invalid choice. Please enter 'a' to apply or 'q' to quit.");
811                }
812            }
813        }
814    }
815}