Skip to main content

omni_dev/cli/git/
twiddle.rs

1//! Twiddle command — AI-powered commit message improvement.
2
3use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::debug;
6
7use super::parse_beta_header;
8use crate::data::amendments::AmendmentFile;
9use crate::data::RepositoryView;
10
11/// Twiddle command options.
12#[derive(Parser)]
13pub struct TwiddleCommand {
14    /// Commit range to analyze and improve (e.g., HEAD~3..HEAD, abc123..def456).
15    #[arg(value_name = "COMMIT_RANGE")]
16    pub commit_range: Option<String>,
17
18    /// Claude API model to use (if not specified, uses settings or default).
19    #[arg(long)]
20    pub model: Option<String>,
21
22    /// Beta header to send with API requests (format: key:value).
23    /// Only sent if the model supports it in the registry.
24    #[arg(long, value_name = "KEY:VALUE")]
25    pub beta_header: Option<String>,
26
27    /// Skips confirmation prompt and applies amendments automatically.
28    #[arg(long)]
29    pub auto_apply: bool,
30
31    /// Saves generated amendments to file without applying.
32    #[arg(long, value_name = "FILE")]
33    pub save_only: Option<String>,
34
35    /// Uses additional project context for better suggestions (Phase 3).
36    #[arg(long, default_value = "true")]
37    pub use_context: bool,
38
39    /// Path to custom context directory (defaults to .omni-dev/).
40    #[arg(long)]
41    pub context_dir: Option<std::path::PathBuf>,
42
43    /// Specifies work context (e.g., "feature: user authentication").
44    #[arg(long)]
45    pub work_context: Option<String>,
46
47    /// Overrides detected branch context.
48    #[arg(long)]
49    pub branch_context: Option<String>,
50
51    /// Disables contextual analysis (uses basic prompting only).
52    #[arg(long)]
53    pub no_context: bool,
54
55    /// Maximum number of concurrent AI requests (default: 4).
56    #[arg(long, default_value = "4")]
57    pub concurrency: usize,
58
59    /// Deprecated: use --concurrency instead.
60    #[arg(long, hide = true)]
61    pub batch_size: Option<usize>,
62
63    /// Disables the cross-commit coherence pass.
64    #[arg(long)]
65    pub no_coherence: bool,
66
67    /// Skips AI processing and only outputs repository YAML.
68    #[arg(long)]
69    pub no_ai: bool,
70
71    /// Ignores existing commit messages and generates fresh ones based solely on diffs.
72    /// This is the default behavior.
73    #[arg(long, conflicts_with = "refine")]
74    pub fresh: bool,
75
76    /// Uses existing commit messages as a starting point for AI refinement
77    /// instead of generating fresh messages from scratch.
78    #[arg(long, conflicts_with = "fresh")]
79    pub refine: bool,
80
81    /// Runs commit message validation after applying amendments.
82    #[arg(long)]
83    pub check: bool,
84
85    /// Only shows errors/warnings, suppresses info-level output.
86    #[arg(long)]
87    pub quiet: bool,
88}
89
90impl TwiddleCommand {
91    /// Returns true when existing messages should be hidden from the AI.
92    /// Fresh is the default; `--refine` overrides it.
93    fn is_fresh(&self) -> bool {
94        !self.refine
95    }
96
97    /// Executes the twiddle command with contextual intelligence.
98    pub async fn execute(mut self) -> Result<()> {
99        // Resolve deprecated --batch-size into --concurrency
100        if let Some(bs) = self.batch_size {
101            eprintln!("warning: --batch-size is deprecated; use --concurrency instead");
102            self.concurrency = bs;
103        }
104
105        // If --no-ai flag is set, skip AI processing and output YAML directly
106        if self.no_ai {
107            return self.execute_no_ai().await;
108        }
109
110        // Preflight check: validate AI credentials before any processing
111        let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
112        println!(
113            "✓ {} credentials verified (model: {})",
114            ai_info.provider, ai_info.model
115        );
116
117        // Preflight check: ensure working directory is clean before expensive operations
118        crate::utils::preflight::check_working_directory_clean()?;
119        println!("✓ Working directory is clean");
120
121        // Determine if contextual analysis should be used
122        let use_contextual = self.use_context && !self.no_context;
123
124        if use_contextual {
125            println!(
126                "🪄 Starting AI-powered commit message improvement with contextual intelligence..."
127            );
128        } else {
129            println!("🪄 Starting AI-powered commit message improvement...");
130        }
131
132        // 1. Generate repository view to get all commits
133        let mut full_repo_view = self.generate_repository_view().await?;
134
135        // 2. Use parallel map-reduce for multiple commits
136        if full_repo_view.commits.len() > 1 {
137            return self
138                .execute_with_map_reduce(use_contextual, full_repo_view)
139                .await;
140        }
141
142        // 3. Collect contextual information (Phase 3)
143        let context = if use_contextual {
144            Some(self.collect_context(&full_repo_view).await?)
145        } else {
146            None
147        };
148
149        // Refine detected scopes using file_patterns from scope definitions
150        let scope_defs = match &context {
151            Some(ctx) => ctx.project.valid_scopes.clone(),
152            None => self.load_check_scopes(),
153        };
154        for commit in &mut full_repo_view.commits {
155            commit.analysis.refine_scope(&scope_defs);
156        }
157
158        // 4. Show context summary if available
159        if let Some(ref ctx) = context {
160            self.show_context_summary(ctx)?;
161        }
162
163        // 5. Initialize Claude client
164        let beta = self
165            .beta_header
166            .as_deref()
167            .map(parse_beta_header)
168            .transpose()?;
169        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
170
171        // Show model information
172        self.show_model_info_from_client(&claude_client)?;
173
174        // 6. Generate amendments via Claude API with context
175        if self.refine {
176            println!("🔄 Refine mode: using existing commit messages as starting point...");
177        }
178        if use_contextual && context.is_some() {
179            println!("🤖 Analyzing commits with enhanced contextual intelligence...");
180        } else {
181            println!("🤖 Analyzing commits with Claude AI...");
182        }
183
184        let mut amendments = if let Some(ctx) = context {
185            claude_client
186                .generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.is_fresh())
187                .await?
188        } else {
189            claude_client
190                .generate_amendments_with_options(&full_repo_view, self.is_fresh())
191                .await?
192        };
193
194        refine_amendment_scopes(&mut amendments, &full_repo_view, &scope_defs);
195
196        // 6. Handle different output modes
197        if let Some(save_path) = self.save_only {
198            amendments.save_to_file(save_path)?;
199            println!("💾 Amendments saved to file");
200            return Ok(());
201        }
202
203        // 7. Handle amendments
204        if !amendments.amendments.is_empty() {
205            // Create temporary file for amendments
206            let temp_dir = tempfile::tempdir()?;
207            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
208            amendments.save_to_file(&amendments_file)?;
209
210            // Show file path and get user choice
211            {
212                use std::io::IsTerminal;
213                if !self.auto_apply
214                    && !self.handle_amendments_file(
215                        &amendments_file,
216                        &amendments,
217                        std::io::stdin().is_terminal(),
218                        &mut std::io::BufReader::new(std::io::stdin()),
219                    )?
220                {
221                    println!("❌ Amendment cancelled by user");
222                    return Ok(());
223                }
224            }
225
226            // 8. Apply amendments (re-read from file to capture any user edits)
227            self.apply_amendments_from_file(&amendments_file).await?;
228            println!("✅ Commit messages improved successfully!");
229
230            // 9. Run post-twiddle check if --check flag is set
231            if self.check {
232                self.run_post_twiddle_check().await?;
233            }
234        } else {
235            println!("✨ No commits found to process!");
236        }
237
238        Ok(())
239    }
240
241    /// Executes the twiddle command with batched parallel map-reduce for multiple commits.
242    ///
243    /// Commits are grouped into token-budget-aware batches (map phase),
244    /// then an optional coherence pass refines results across all commits
245    /// (reduce phase). Coherence is skipped when all commits fit in a
246    /// single batch since the AI already saw them together.
247    async fn execute_with_map_reduce(
248        &self,
249        use_contextual: bool,
250        mut full_repo_view: crate::data::RepositoryView,
251    ) -> Result<()> {
252        use std::sync::atomic::{AtomicUsize, Ordering};
253        use std::sync::Arc;
254
255        use crate::claude::batch;
256        use crate::claude::token_budget;
257
258        let concurrency = self.concurrency;
259
260        // Initialize Claude client
261        let beta = self
262            .beta_header
263            .as_deref()
264            .map(parse_beta_header)
265            .transpose()?;
266        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
267
268        // Show model information
269        self.show_model_info_from_client(&claude_client)?;
270
271        if self.refine {
272            println!("🔄 Refine mode: using existing commit messages as starting point...");
273        }
274
275        let total_commits = full_repo_view.commits.len();
276        println!(
277            "🔄 Processing {total_commits} commits in parallel (concurrency: {concurrency})..."
278        );
279
280        // Collect context once (shared across all commits)
281        let context = if use_contextual {
282            Some(self.collect_context(&full_repo_view).await?)
283        } else {
284            None
285        };
286
287        if let Some(ref ctx) = context {
288            self.show_context_summary(ctx)?;
289        }
290
291        // Refine scopes on all commits upfront
292        let scope_defs = match &context {
293            Some(ctx) => ctx.project.valid_scopes.clone(),
294            None => self.load_check_scopes(),
295        };
296        for commit in &mut full_repo_view.commits {
297            commit.analysis.refine_scope(&scope_defs);
298        }
299
300        // Plan batches based on token budget
301        let metadata = claude_client.get_ai_client_metadata();
302        let system_prompt_tokens = if let Some(ref ctx) = context {
303            let prompt_style = metadata.prompt_style();
304            let system_prompt =
305                crate::claude::prompts::generate_contextual_system_prompt_for_provider(
306                    ctx,
307                    prompt_style,
308                );
309            token_budget::estimate_tokens(&system_prompt)
310        } else {
311            token_budget::estimate_tokens(crate::claude::prompts::SYSTEM_PROMPT)
312        };
313        let batch_plan =
314            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
315
316        if batch_plan.batches.len() < total_commits {
317            println!(
318                "   📦 Grouped {} commits into {} batches by token budget",
319                total_commits,
320                batch_plan.batches.len()
321            );
322        }
323
324        // Map phase: process batches in parallel
325        let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
326        let completed = Arc::new(AtomicUsize::new(0));
327
328        let repo_ref = &full_repo_view;
329        let client_ref = &claude_client;
330        let context_ref = &context;
331        let fresh = self.is_fresh();
332
333        let futs: Vec<_> = batch_plan
334            .batches
335            .iter()
336            .map(|batch| {
337                let sem = semaphore.clone();
338                let completed = completed.clone();
339                let batch_indices = &batch.commit_indices;
340
341                async move {
342                    let _permit = sem
343                        .acquire()
344                        .await
345                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
346
347                    let batch_size = batch_indices.len();
348
349                    // Create view for this batch
350                    let batch_view = if batch_size == 1 {
351                        repo_ref.single_commit_view(&repo_ref.commits[batch_indices[0]])
352                    } else {
353                        let commits: Vec<_> = batch_indices
354                            .iter()
355                            .map(|&i| &repo_ref.commits[i])
356                            .collect();
357                        repo_ref.multi_commit_view(&commits)
358                    };
359
360                    // Generate amendments for the batch
361                    let result = if let Some(ref ctx) = context_ref {
362                        client_ref
363                            .generate_contextual_amendments_with_options(&batch_view, ctx, fresh)
364                            .await
365                    } else {
366                        client_ref
367                            .generate_amendments_with_options(&batch_view, fresh)
368                            .await
369                    };
370
371                    match result {
372                        Ok(amendment_file) => {
373                            let done =
374                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
375                            println!("   ✅ {done}/{total_commits} commits processed");
376
377                            let items: Vec<_> = amendment_file
378                                .amendments
379                                .into_iter()
380                                .map(|a| {
381                                    let summary = a.summary.clone().unwrap_or_default();
382                                    (a, summary)
383                                })
384                                .collect();
385                            Ok::<_, anyhow::Error>((items, vec![]))
386                        }
387                        Err(e) if batch_size > 1 => {
388                            // Split-and-retry: fall back to individual commits
389                            eprintln!(
390                                "warning: batch of {batch_size} failed, retrying individually: {e}"
391                            );
392                            let mut items = Vec::new();
393                            let mut failed_indices = Vec::new();
394                            for &idx in batch_indices {
395                                let single_view =
396                                    repo_ref.single_commit_view(&repo_ref.commits[idx]);
397                                let single_result = if let Some(ref ctx) = context_ref {
398                                    client_ref
399                                        .generate_contextual_amendments_with_options(
400                                            &single_view,
401                                            ctx,
402                                            fresh,
403                                        )
404                                        .await
405                                } else {
406                                    client_ref
407                                        .generate_amendments_with_options(&single_view, fresh)
408                                        .await
409                                };
410                                match single_result {
411                                    Ok(af) => {
412                                        if let Some(a) = af.amendments.into_iter().next() {
413                                            let summary = a.summary.clone().unwrap_or_default();
414                                            items.push((a, summary));
415                                        }
416                                        let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
417                                        println!("   ✅ {done}/{total_commits} commits processed");
418                                    }
419                                    Err(e) => {
420                                        eprintln!("warning: failed to process commit: {e}");
421                                        // Print the full error chain for debugging using anyhow's chain()
422                                        for (i, cause) in e.chain().skip(1).enumerate() {
423                                            eprintln!("  caused by [{i}]: {cause}");
424                                        }
425                                        failed_indices.push(idx);
426                                        println!("   ❌ commit processing failed");
427                                    }
428                                }
429                            }
430                            Ok((items, failed_indices))
431                        }
432                        Err(e) => {
433                            // Single-commit batch failed; record the index so the user can retry
434                            let idx = batch_indices[0];
435                            eprintln!("warning: failed to process commit: {e}");
436                            // Print the full error chain for debugging using anyhow's chain()
437                            for (i, cause) in e.chain().skip(1).enumerate() {
438                                eprintln!("  caused by [{i}]: {cause}");
439                            }
440                            let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
441                            println!("   ❌ {done}/{total_commits} commits processed (failed)");
442                            Ok((vec![], vec![idx]))
443                        }
444                    }
445                }
446            })
447            .collect();
448
449        let results = futures::future::join_all(futs).await;
450
451        // Flatten batch results
452        let mut successes: Vec<(crate::data::amendments::Amendment, String)> = Vec::new();
453        let mut failed_indices: Vec<usize> = Vec::new();
454
455        for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
456            match result {
457                Ok((items, failed)) => {
458                    successes.extend(items);
459                    failed_indices.extend(failed);
460                }
461                Err(e) => {
462                    eprintln!("warning: batch processing error: {e}");
463                    failed_indices.extend(&batch.commit_indices);
464                }
465            }
466        }
467
468        // Offer interactive retry for commits that failed
469        if !failed_indices.is_empty() && !self.quiet {
470            use std::io::IsTerminal;
471            self.run_interactive_retry_generate_amendments(
472                &mut failed_indices,
473                &full_repo_view,
474                &claude_client,
475                context.as_ref(),
476                fresh,
477                &mut successes,
478                std::io::stdin().is_terminal(),
479                &mut std::io::BufReader::new(std::io::stdin()),
480            )
481            .await?;
482        } else if !failed_indices.is_empty() {
483            eprintln!(
484                "warning: {} commit(s) failed to process",
485                failed_indices.len()
486            );
487        }
488
489        if !failed_indices.is_empty() {
490            eprintln!(
491                "warning: {} commit(s) ultimately failed to process",
492                failed_indices.len()
493            );
494        }
495
496        if successes.is_empty() {
497            anyhow::bail!("All commits failed to process");
498        }
499
500        // Reduce phase: optional coherence pass
501        // Skip when all commits were in a single batch (AI already saw them together)
502        let single_batch = batch_plan.batches.len() <= 1;
503        let mut all_amendments = if !self.no_coherence && !single_batch && successes.len() >= 2 {
504            println!("🔗 Running cross-commit coherence pass...");
505            match claude_client.refine_amendments_coherence(&successes).await {
506                Ok(refined) => refined,
507                Err(e) => {
508                    eprintln!("warning: coherence pass failed, using individual results: {e}");
509                    AmendmentFile {
510                        amendments: successes.into_iter().map(|(a, _)| a).collect(),
511                    }
512                }
513            }
514        } else {
515            AmendmentFile {
516                amendments: successes.into_iter().map(|(a, _)| a).collect(),
517            }
518        };
519
520        refine_amendment_scopes(&mut all_amendments, &full_repo_view, &scope_defs);
521
522        println!(
523            "✅ All commits processed! Found {} amendments.",
524            all_amendments.amendments.len()
525        );
526
527        // Handle different output modes
528        if let Some(save_path) = &self.save_only {
529            all_amendments.save_to_file(save_path)?;
530            println!("💾 Amendments saved to file");
531            return Ok(());
532        }
533
534        // Handle amendments
535        if !all_amendments.amendments.is_empty() {
536            let temp_dir = tempfile::tempdir()?;
537            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
538            all_amendments.save_to_file(&amendments_file)?;
539
540            {
541                use std::io::IsTerminal;
542                if !self.auto_apply
543                    && !self.handle_amendments_file(
544                        &amendments_file,
545                        &all_amendments,
546                        std::io::stdin().is_terminal(),
547                        &mut std::io::BufReader::new(std::io::stdin()),
548                    )?
549                {
550                    println!("❌ Amendment cancelled by user");
551                    return Ok(());
552                }
553            }
554
555            self.apply_amendments_from_file(&amendments_file).await?;
556            println!("✅ Commit messages improved successfully!");
557
558            if self.check {
559                self.run_post_twiddle_check().await?;
560            }
561        } else {
562            println!("✨ No commits found to process!");
563        }
564
565        Ok(())
566    }
567
568    /// Generates the repository view (reuses ViewCommand logic).
569    async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
570        use crate::data::{
571            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
572            WorkingDirectoryInfo,
573        };
574        use crate::git::{GitRepository, RemoteInfo};
575        use crate::utils::ai_scratch;
576
577        let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
578
579        // Open git repository
580        let repo = GitRepository::open()
581            .context("Failed to open git repository. Make sure you're in a git repository.")?;
582
583        // Get current branch name
584        let current_branch = repo
585            .get_current_branch()
586            .unwrap_or_else(|_| "HEAD".to_string());
587
588        // Get working directory status
589        let wd_status = repo.get_working_directory_status()?;
590        let working_directory = WorkingDirectoryInfo {
591            clean: wd_status.clean,
592            untracked_changes: wd_status
593                .untracked_changes
594                .into_iter()
595                .map(|fs| FileStatusInfo {
596                    status: fs.status,
597                    file: fs.file,
598                })
599                .collect(),
600        };
601
602        // Get remote information
603        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
604
605        // Parse commit range and get commits
606        let commits = repo.get_commits_in_range(commit_range)?;
607
608        // Create version information
609        let versions = Some(VersionInfo {
610            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
611        });
612
613        // Get AI scratch directory
614        let ai_scratch_path =
615            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
616        let ai_info = AiInfo {
617            scratch: ai_scratch_path.to_string_lossy().to_string(),
618        };
619
620        // Build repository view with branch info
621        let mut repo_view = RepositoryView {
622            versions,
623            explanation: FieldExplanation::default(),
624            working_directory,
625            remotes,
626            ai: ai_info,
627            branch_info: Some(BranchInfo {
628                branch: current_branch,
629            }),
630            pr_template: None,
631            pr_template_location: None,
632            branch_prs: None,
633            commits,
634        };
635
636        // Update field presence based on actual data
637        repo_view.update_field_presence();
638
639        Ok(repo_view)
640    }
641
642    /// Handles the amendments file by showing the path and getting the user choice.
643    ///
644    /// `is_terminal` and `reader` are injected so tests can drive the function
645    /// without blocking on real stdin.
646    fn handle_amendments_file(
647        &self,
648        amendments_file: &std::path::Path,
649        amendments: &crate::data::amendments::AmendmentFile,
650        is_terminal: bool,
651        reader: &mut (dyn std::io::BufRead + Send),
652    ) -> Result<bool> {
653        use std::io::{self, Write};
654
655        println!(
656            "\n📝 Found {} commits that could be improved.",
657            amendments.amendments.len()
658        );
659        println!("💾 Amendments saved to: {}", amendments_file.display());
660        println!();
661
662        if !is_terminal {
663            eprintln!("warning: stdin is not interactive, cannot prompt for amendments");
664            return Ok(false);
665        }
666
667        loop {
668            print!("❓ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
669            io::stdout().flush()?;
670
671            let Some(input) = super::read_interactive_line(reader)? else {
672                eprintln!("warning: stdin closed, cancelling amendments");
673                return Ok(false);
674            };
675
676            match input.trim().to_lowercase().as_str() {
677                "a" | "apply" | "" => return Ok(true),
678                "s" | "show" => {
679                    self.show_amendments_file(amendments_file)?;
680                    println!();
681                }
682                "e" | "edit" => {
683                    self.edit_amendments_file(amendments_file)?;
684                    println!();
685                }
686                "q" | "quit" => return Ok(false),
687                _ => {
688                    println!(
689                        "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
690                    );
691                }
692            }
693        }
694    }
695
696    /// Shows the contents of the amendments file.
697    fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
698        use std::fs;
699
700        println!("\n📄 Amendments file contents:");
701        println!("─────────────────────────────");
702
703        let contents =
704            fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
705
706        println!("{contents}");
707        println!("─────────────────────────────");
708
709        Ok(())
710    }
711
712    /// Opens the amendments file in an external editor.
713    fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
714        use std::env;
715        use std::io::{self, Write};
716        use std::process::Command;
717
718        // Try to get editor from environment variables
719        let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
720            e
721        } else {
722            // Prompt user for editor if neither environment variable is set
723            println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
724            print!("Please enter the command to use as your editor: ");
725            io::stdout().flush().context("Failed to flush stdout")?;
726
727            let mut input = String::new();
728            io::stdin()
729                .read_line(&mut input)
730                .context("Failed to read user input")?;
731            input.trim().to_string()
732        };
733
734        if editor.is_empty() {
735            println!("❌ No editor specified. Returning to menu.");
736            return Ok(());
737        }
738
739        println!("📝 Opening amendments file in editor: {editor}");
740
741        let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
742
743        let mut command = Command::new(editor_cmd);
744        command.args(args);
745        command.arg(amendments_file.to_string_lossy().as_ref());
746
747        match command.status() {
748            Ok(status) => {
749                if status.success() {
750                    println!("✅ Editor session completed.");
751                } else {
752                    println!(
753                        "⚠️  Editor exited with non-zero status: {:?}",
754                        status.code()
755                    );
756                }
757            }
758            Err(e) => {
759                println!("❌ Failed to execute editor '{editor}': {e}");
760                println!("   Please check that the editor command is correct and available in your PATH.");
761            }
762        }
763
764        Ok(())
765    }
766
767    /// Applies amendments from a file path (re-reads from disk to capture user edits).
768    async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
769        use crate::git::AmendmentHandler;
770
771        // Use AmendmentHandler to apply amendments directly from file
772        let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
773        handler
774            .apply_amendments(&amendments_file.to_string_lossy())
775            .context("Failed to apply amendments")?;
776
777        Ok(())
778    }
779
780    /// Collects contextual information for enhanced commit message generation.
781    async fn collect_context(
782        &self,
783        repo_view: &crate::data::RepositoryView,
784    ) -> Result<crate::data::context::CommitContext> {
785        use crate::claude::context::{
786            BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
787        };
788        use crate::data::context::CommitContext;
789
790        let mut context = CommitContext::new();
791
792        // 1. Discover project context
793        let (context_dir, dir_source) =
794            crate::claude::context::resolve_context_dir_with_source(self.context_dir.as_deref());
795
796        // ProjectDiscovery takes repo root and context directory
797        let repo_root = std::path::PathBuf::from(".");
798        let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
799        debug!(context_dir = ?context_dir, "Using context directory");
800        match discovery.discover() {
801            Ok(project_context) => {
802                debug!("Discovery successful");
803
804                // Show diagnostic information about loaded guidance files
805                self.show_guidance_files_status(&project_context, &context_dir, &dir_source)?;
806
807                context.project = project_context;
808            }
809            Err(e) => {
810                debug!(error = %e, "Discovery failed");
811                context.project = crate::data::context::ProjectContext::default();
812            }
813        }
814
815        // 2. Analyze current branch from repository view
816        if let Some(branch_info) = &repo_view.branch_info {
817            context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
818        } else {
819            // Fallback to getting current branch directly if not in repo view
820            use crate::git::GitRepository;
821            let repo = GitRepository::open()?;
822            let current_branch = repo
823                .get_current_branch()
824                .unwrap_or_else(|_| "HEAD".to_string());
825            context.branch = BranchAnalyzer::analyze(&current_branch).unwrap_or_default();
826        }
827
828        // 3. Analyze commit range patterns
829        if !repo_view.commits.is_empty() {
830            context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
831        }
832
833        // 3.5. Analyze file-level context
834        if !repo_view.commits.is_empty() {
835            context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
836        }
837
838        // 4. Apply user-provided context overrides
839        if let Some(ref work_ctx) = self.work_context {
840            context.user_provided = Some(work_ctx.clone());
841        }
842
843        if let Some(ref branch_ctx) = self.branch_context {
844            context.branch.description.clone_from(branch_ctx);
845        }
846
847        Ok(context)
848    }
849
850    /// Shows the context summary to the user.
851    fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
852        println!("🔍 Context Analysis:");
853
854        // Project context
855        if !context.project.valid_scopes.is_empty() {
856            println!(
857                "   📁 Valid scopes: {}",
858                format_scope_list(&context.project.valid_scopes)
859            );
860        }
861
862        // Branch context
863        if context.branch.is_feature_branch {
864            println!(
865                "   🌿 Branch: {} ({})",
866                context.branch.description, context.branch.work_type
867            );
868            if let Some(ref ticket) = context.branch.ticket_id {
869                println!("   🎫 Ticket: {ticket}");
870            }
871        }
872
873        // Work pattern
874        if let Some(label) = format_work_pattern(&context.range.work_pattern) {
875            println!("   {label}");
876        }
877
878        // File analysis
879        if let Some(label) = super::formatting::format_file_analysis(&context.files) {
880            println!("   {label}");
881        }
882
883        // Verbosity level
884        println!(
885            "   {}",
886            format_verbosity_level(context.suggested_verbosity())
887        );
888
889        // User context
890        if let Some(ref user_ctx) = context.user_provided {
891            println!("   👤 User context: {user_ctx}");
892        }
893
894        println!();
895        Ok(())
896    }
897
898    /// Shows model information from the actual AI client.
899    fn show_model_info_from_client(
900        &self,
901        client: &crate::claude::client::ClaudeClient,
902    ) -> Result<()> {
903        use crate::claude::model_config::get_model_registry;
904
905        println!("🤖 AI Model Configuration:");
906
907        // Get actual metadata from the client
908        let metadata = client.get_ai_client_metadata();
909        let registry = get_model_registry();
910
911        if let Some(spec) = registry.get_model_spec(&metadata.model) {
912            // Highlight the API identifier portion in yellow
913            if metadata.model != spec.api_identifier {
914                println!(
915                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
916                    metadata.model, spec.api_identifier
917                );
918            } else {
919                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
920            }
921
922            println!("   🏷️  Provider: {}", spec.provider);
923            println!("   📊 Generation: {}", spec.generation);
924            println!("   ⭐ Tier: {} ({})", spec.tier, {
925                if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
926                    &tier_info.description
927                } else {
928                    "No description available"
929                }
930            });
931            println!("   📤 Max output tokens: {}", metadata.max_response_length);
932            println!("   📥 Input context: {}", metadata.max_context_length);
933
934            if let Some((ref key, ref value)) = metadata.active_beta {
935                println!("   🔬 Beta header: {key}: {value}");
936            }
937
938            if spec.legacy {
939                println!("   ⚠️  Legacy model (consider upgrading to newer version)");
940            }
941        } else {
942            // Fallback to client metadata if not in registry
943            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
944            println!("   🏷️  Provider: {}", metadata.provider);
945            println!("   ⚠️  Model not found in registry, using client metadata:");
946            println!("   📤 Max output tokens: {}", metadata.max_response_length);
947            println!("   📥 Input context: {}", metadata.max_context_length);
948        }
949
950        println!();
951        Ok(())
952    }
953
954    /// Shows diagnostic information about loaded guidance files.
955    fn show_guidance_files_status(
956        &self,
957        project_context: &crate::data::context::ProjectContext,
958        context_dir: &std::path::Path,
959        dir_source: &crate::claude::context::ConfigDirSource,
960    ) -> Result<()> {
961        use crate::claude::context::{config_source_label, ConfigSourceLabel};
962
963        println!("📋 Project guidance files status:");
964        println!("   📂 Config dir: {} ({dir_source})", context_dir.display());
965
966        // Check commit guidelines
967        let guidelines_source = if project_context.commit_guidelines.is_some() {
968            match config_source_label(context_dir, "commit-guidelines.md") {
969                ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
970                label => format!("✅ {label}"),
971            }
972        } else {
973            "❌ None found".to_string()
974        };
975        println!("   📝 Commit guidelines: {guidelines_source}");
976
977        // Check scopes
978        let scopes_count = project_context.valid_scopes.len();
979        let scopes_source = if scopes_count > 0 {
980            match config_source_label(context_dir, "scopes.yaml") {
981                ConfigSourceLabel::NotFound => {
982                    format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
983                }
984                label => format!("✅ {label} ({scopes_count} scopes)"),
985            }
986        } else {
987            "❌ None found".to_string()
988        };
989        println!("   🎯 Valid scopes: {scopes_source}");
990
991        println!();
992        Ok(())
993    }
994
995    /// Executes the twiddle command without AI, creating amendments with original messages.
996    async fn execute_no_ai(&self) -> Result<()> {
997        use crate::data::amendments::{Amendment, AmendmentFile};
998
999        println!("📋 Generating amendments YAML without AI processing...");
1000
1001        // Generate repository view to get all commits
1002        let repo_view = self.generate_repository_view().await?;
1003
1004        // Create amendments with original commit messages (no AI improvements)
1005        let amendments: Vec<Amendment> = repo_view
1006            .commits
1007            .iter()
1008            .map(|commit| Amendment {
1009                commit: commit.hash.clone(),
1010                message: commit.original_message.clone(),
1011                summary: None,
1012            })
1013            .collect();
1014
1015        let amendment_file = AmendmentFile { amendments };
1016
1017        // Handle different output modes
1018        if let Some(save_path) = &self.save_only {
1019            amendment_file.save_to_file(save_path)?;
1020            println!("💾 Amendments saved to file");
1021            return Ok(());
1022        }
1023
1024        // Handle amendments using the same flow as the AI-powered version
1025        if !amendment_file.amendments.is_empty() {
1026            // Create temporary file for amendments
1027            let temp_dir = tempfile::tempdir()?;
1028            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1029            amendment_file.save_to_file(&amendments_file)?;
1030
1031            // Show file path and get user choice
1032            {
1033                use std::io::IsTerminal;
1034                if !self.auto_apply
1035                    && !self.handle_amendments_file(
1036                        &amendments_file,
1037                        &amendment_file,
1038                        std::io::stdin().is_terminal(),
1039                        &mut std::io::BufReader::new(std::io::stdin()),
1040                    )?
1041                {
1042                    println!("❌ Amendment cancelled by user");
1043                    return Ok(());
1044                }
1045            }
1046
1047            // Apply amendments (re-read from file to capture any user edits)
1048            self.apply_amendments_from_file(&amendments_file).await?;
1049            println!("✅ Commit messages applied successfully!");
1050
1051            // Run post-twiddle check if --check flag is set
1052            if self.check {
1053                self.run_post_twiddle_check().await?;
1054            }
1055        } else {
1056            println!("✨ No commits found to process!");
1057        }
1058
1059        Ok(())
1060    }
1061
1062    /// Runs commit message validation after twiddle amendments are applied.
1063    /// If the check finds errors with suggestions, automatically applies the
1064    /// suggestions and re-checks, up to 3 retries.
1065    async fn run_post_twiddle_check(&self) -> Result<()> {
1066        const MAX_CHECK_RETRIES: u32 = 3;
1067
1068        // Load guidelines, scopes, and Claude client once (they don't change between retries)
1069        let guidelines = self.load_check_guidelines()?;
1070        let valid_scopes = self.load_check_scopes();
1071        let beta = self
1072            .beta_header
1073            .as_deref()
1074            .map(parse_beta_header)
1075            .transpose()?;
1076        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
1077
1078        for attempt in 0..=MAX_CHECK_RETRIES {
1079            println!();
1080            if attempt == 0 {
1081                println!("🔍 Running commit message validation...");
1082            } else {
1083                println!("🔍 Re-checking commit messages (retry {attempt}/{MAX_CHECK_RETRIES})...");
1084            }
1085
1086            // Generate fresh repository view to get updated commit messages
1087            let mut repo_view = self.generate_repository_view().await?;
1088
1089            if repo_view.commits.is_empty() {
1090                println!("⚠️  No commits to check");
1091                return Ok(());
1092            }
1093
1094            println!("📊 Checking {} commits", repo_view.commits.len());
1095
1096            // Refine detected scopes using file_patterns from scope definitions
1097            for commit in &mut repo_view.commits {
1098                commit.analysis.refine_scope(&valid_scopes);
1099            }
1100
1101            if attempt == 0 {
1102                self.show_check_guidance_files_status(&guidelines, &valid_scopes);
1103            }
1104
1105            // Run check
1106            let report = if repo_view.commits.len() > 1 {
1107                println!(
1108                    "🔄 Checking {} commits in parallel...",
1109                    repo_view.commits.len()
1110                );
1111                self.check_commits_map_reduce(
1112                    &claude_client,
1113                    &repo_view,
1114                    guidelines.as_deref(),
1115                    &valid_scopes,
1116                )
1117                .await?
1118            } else {
1119                println!("🤖 Analyzing commits with AI...");
1120                claude_client
1121                    .check_commits_with_scopes(
1122                        &repo_view,
1123                        guidelines.as_deref(),
1124                        &valid_scopes,
1125                        true,
1126                    )
1127                    .await?
1128            };
1129
1130            // Output text report
1131            self.output_check_text_report(&report)?;
1132
1133            // If no errors, we're done
1134            if !report.has_errors() {
1135                if report.has_warnings() {
1136                    println!("ℹ️  Some commit messages have minor warnings");
1137                } else {
1138                    println!("✅ All commit messages pass validation");
1139                }
1140                return Ok(());
1141            }
1142
1143            // If we've exhausted retries, report and stop
1144            if attempt == MAX_CHECK_RETRIES {
1145                println!(
1146                    "⚠️  Some commit messages still have issues after {MAX_CHECK_RETRIES} retries"
1147                );
1148                return Ok(());
1149            }
1150
1151            // Build amendments from suggestions for failing commits
1152            let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
1153
1154            if amendments.is_empty() {
1155                println!(
1156                    "⚠️  Some commit messages have issues but no suggestions available to retry"
1157                );
1158                return Ok(());
1159            }
1160
1161            // Apply the suggested amendments
1162            println!(
1163                "🔄 Applying {} suggested fix(es) and re-checking...",
1164                amendments.len()
1165            );
1166            let amendment_file = AmendmentFile { amendments };
1167            let temp_file = tempfile::NamedTempFile::new()
1168                .context("Failed to create temp file for retry amendments")?;
1169            amendment_file
1170                .save_to_file(temp_file.path())
1171                .context("Failed to save retry amendments")?;
1172            self.apply_amendments_from_file(temp_file.path()).await?;
1173        }
1174
1175        Ok(())
1176    }
1177
1178    /// Builds amendments from check report suggestions for failing commits.
1179    /// Resolves short hashes from the AI response to full 40-char hashes
1180    /// from the repository view.
1181    fn build_amendments_from_suggestions(
1182        &self,
1183        report: &crate::data::check::CheckReport,
1184        repo_view: &crate::data::RepositoryView,
1185    ) -> Vec<crate::data::amendments::Amendment> {
1186        use crate::data::amendments::Amendment;
1187
1188        let candidate_hashes: Vec<String> =
1189            repo_view.commits.iter().map(|c| c.hash.clone()).collect();
1190
1191        report
1192            .commits
1193            .iter()
1194            .filter(|r| !r.passes)
1195            .filter_map(|r| {
1196                let suggestion = r.suggestion.as_ref()?;
1197                let full_hash = super::formatting::resolve_short_hash(&r.hash, &candidate_hashes)?;
1198                Some(Amendment::new(
1199                    full_hash.to_string(),
1200                    suggestion.message.clone(),
1201                ))
1202            })
1203            .collect()
1204    }
1205
1206    /// Loads commit guidelines for check via the standard resolution chain.
1207    fn load_check_guidelines(&self) -> Result<Option<String>> {
1208        let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
1209        crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")
1210    }
1211
1212    /// Loads valid scopes for check with ecosystem defaults.
1213    fn load_check_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
1214        let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
1215        crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."))
1216    }
1217
1218    /// Shows guidance files status for check.
1219    fn show_check_guidance_files_status(
1220        &self,
1221        guidelines: &Option<String>,
1222        valid_scopes: &[crate::data::context::ScopeDefinition],
1223    ) {
1224        use crate::claude::context::{
1225            config_source_label, resolve_context_dir_with_source, ConfigSourceLabel,
1226        };
1227
1228        let (context_dir, dir_source) =
1229            resolve_context_dir_with_source(self.context_dir.as_deref());
1230
1231        println!("📋 Project guidance files status:");
1232        println!("   📂 Config dir: {} ({dir_source})", context_dir.display());
1233
1234        // Check commit guidelines
1235        let guidelines_source = if guidelines.is_some() {
1236            match config_source_label(&context_dir, "commit-guidelines.md") {
1237                ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
1238                label => format!("✅ {label}"),
1239            }
1240        } else {
1241            "⚪ Using defaults".to_string()
1242        };
1243        println!("   📝 Commit guidelines: {guidelines_source}");
1244
1245        // Check scopes
1246        let scopes_count = valid_scopes.len();
1247        let scopes_source = if scopes_count > 0 {
1248            match config_source_label(&context_dir, "scopes.yaml") {
1249                ConfigSourceLabel::NotFound => {
1250                    format!("✅ (source unknown) ({scopes_count} scopes)")
1251                }
1252                label => format!("✅ {label} ({scopes_count} scopes)"),
1253            }
1254        } else {
1255            "⚪ None found (any scope accepted)".to_string()
1256        };
1257        println!("   🎯 Valid scopes: {scopes_source}");
1258
1259        println!();
1260    }
1261
1262    /// Checks commits using batched parallel map-reduce.
1263    async fn check_commits_map_reduce(
1264        &self,
1265        claude_client: &crate::claude::client::ClaudeClient,
1266        full_repo_view: &crate::data::RepositoryView,
1267        guidelines: Option<&str>,
1268        valid_scopes: &[crate::data::context::ScopeDefinition],
1269    ) -> Result<crate::data::check::CheckReport> {
1270        use std::sync::atomic::{AtomicUsize, Ordering};
1271        use std::sync::Arc;
1272
1273        use crate::claude::batch;
1274        use crate::claude::token_budget;
1275        use crate::data::check::{CheckReport, CommitCheckResult};
1276
1277        let total_commits = full_repo_view.commits.len();
1278
1279        // Plan batches based on token budget
1280        let metadata = claude_client.get_ai_client_metadata();
1281        let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
1282            guidelines,
1283            valid_scopes,
1284        );
1285        let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
1286        let batch_plan =
1287            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
1288
1289        if batch_plan.batches.len() < total_commits {
1290            println!(
1291                "   📦 Grouped {} commits into {} batches by token budget",
1292                total_commits,
1293                batch_plan.batches.len()
1294            );
1295        }
1296
1297        let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
1298        let completed = Arc::new(AtomicUsize::new(0));
1299
1300        let futs: Vec<_> = batch_plan
1301            .batches
1302            .iter()
1303            .map(|batch| {
1304                let sem = semaphore.clone();
1305                let completed = completed.clone();
1306                let batch_indices = &batch.commit_indices;
1307
1308                async move {
1309                    let _permit = sem
1310                        .acquire()
1311                        .await
1312                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
1313
1314                    let batch_size = batch_indices.len();
1315
1316                    let batch_view = if batch_size == 1 {
1317                        full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
1318                    } else {
1319                        let commits: Vec<_> = batch_indices
1320                            .iter()
1321                            .map(|&i| &full_repo_view.commits[i])
1322                            .collect();
1323                        full_repo_view.multi_commit_view(&commits)
1324                    };
1325
1326                    let result = claude_client
1327                        .check_commits_with_scopes(&batch_view, guidelines, valid_scopes, true)
1328                        .await;
1329
1330                    match result {
1331                        Ok(report) => {
1332                            let done =
1333                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
1334                            println!("   ✅ {done}/{total_commits} commits checked");
1335
1336                            let items: Vec<_> = report
1337                                .commits
1338                                .into_iter()
1339                                .map(|r| {
1340                                    let summary = r.summary.clone().unwrap_or_default();
1341                                    (r, summary)
1342                                })
1343                                .collect();
1344                            Ok::<_, anyhow::Error>((items, vec![]))
1345                        }
1346                        Err(e) if batch_size > 1 => {
1347                            eprintln!(
1348                                "warning: batch of {batch_size} failed, retrying individually: {e}"
1349                            );
1350                            let mut items = Vec::new();
1351                            let mut failed_indices = Vec::new();
1352                            for &idx in batch_indices {
1353                                let single_view =
1354                                    full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1355                                let single_result = claude_client
1356                                    .check_commits_with_scopes(
1357                                        &single_view,
1358                                        guidelines,
1359                                        valid_scopes,
1360                                        true,
1361                                    )
1362                                    .await;
1363                                match single_result {
1364                                    Ok(report) => {
1365                                        if let Some(r) = report.commits.into_iter().next() {
1366                                            let summary = r.summary.clone().unwrap_or_default();
1367                                            items.push((r, summary));
1368                                        }
1369                                        let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1370                                        println!("   ✅ {done}/{total_commits} commits checked");
1371                                    }
1372                                    Err(e) => {
1373                                        eprintln!("warning: failed to check commit: {e}");
1374                                        failed_indices.push(idx);
1375                                        println!("   ❌ commit check failed");
1376                                    }
1377                                }
1378                            }
1379                            Ok((items, failed_indices))
1380                        }
1381                        Err(e) => {
1382                            // Single-commit batch failed; record the index so the user can retry
1383                            let idx = batch_indices[0];
1384                            eprintln!("warning: failed to check commit: {e}");
1385                            let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1386                            println!("   ❌ {done}/{total_commits} commits checked (failed)");
1387                            Ok((vec![], vec![idx]))
1388                        }
1389                    }
1390                }
1391            })
1392            .collect();
1393
1394        let results = futures::future::join_all(futs).await;
1395
1396        let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
1397        let mut failed_indices: Vec<usize> = Vec::new();
1398
1399        for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
1400            match result {
1401                Ok((items, failed)) => {
1402                    successes.extend(items);
1403                    failed_indices.extend(failed);
1404                }
1405                Err(e) => {
1406                    eprintln!("warning: batch processing error: {e}");
1407                    failed_indices.extend(&batch.commit_indices);
1408                }
1409            }
1410        }
1411
1412        // Offer interactive retry for commits that failed
1413        if !failed_indices.is_empty() && !self.quiet {
1414            use std::io::IsTerminal;
1415            if std::io::stdin().is_terminal() {
1416                self.run_interactive_retry_twiddle_check(
1417                    &mut failed_indices,
1418                    full_repo_view,
1419                    claude_client,
1420                    guidelines,
1421                    valid_scopes,
1422                    &mut successes,
1423                    &mut std::io::BufReader::new(std::io::stdin()),
1424                )
1425                .await?;
1426            } else {
1427                eprintln!(
1428                    "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1429                    failed_indices.len()
1430                );
1431            }
1432        } else if !failed_indices.is_empty() {
1433            eprintln!(
1434                "warning: {} commit(s) failed to check",
1435                failed_indices.len()
1436            );
1437        }
1438
1439        if !failed_indices.is_empty() {
1440            eprintln!(
1441                "warning: {} commit(s) ultimately failed to check",
1442                failed_indices.len()
1443            );
1444        }
1445
1446        if successes.is_empty() {
1447            anyhow::bail!("All commits failed to check");
1448        }
1449
1450        // Coherence pass: skip when all commits were in a single batch
1451        let single_batch = batch_plan.batches.len() <= 1;
1452        if !self.no_coherence && !single_batch && successes.len() >= 2 {
1453            println!("🔗 Running cross-commit coherence pass...");
1454            match claude_client
1455                .refine_checks_coherence(&successes, full_repo_view)
1456                .await
1457            {
1458                Ok(refined) => return Ok(refined),
1459                Err(e) => {
1460                    eprintln!("warning: coherence pass failed, using individual results: {e}");
1461                }
1462            }
1463        }
1464
1465        Ok(CheckReport::new(
1466            successes.into_iter().map(|(r, _)| r).collect(),
1467        ))
1468    }
1469
1470    /// Prompts the user to retry or skip failed commits, updating `failed_indices` and `successes`.
1471    ///
1472    /// Accepts `reader` for stdin injection so the interactive loop can be unit-tested.
1473    #[allow(clippy::too_many_arguments)]
1474    async fn run_interactive_retry_twiddle_check(
1475        &self,
1476        failed_indices: &mut Vec<usize>,
1477        full_repo_view: &crate::data::RepositoryView,
1478        claude_client: &crate::claude::client::ClaudeClient,
1479        guidelines: Option<&str>,
1480        valid_scopes: &[crate::data::context::ScopeDefinition],
1481        successes: &mut Vec<(crate::data::check::CommitCheckResult, String)>,
1482        reader: &mut (dyn std::io::BufRead + Send),
1483    ) -> Result<()> {
1484        use std::io::Write as _;
1485        println!("\n⚠️  {} commit(s) failed to check:", failed_indices.len());
1486        for &idx in failed_indices.iter() {
1487            let commit = &full_repo_view.commits[idx];
1488            let subject = commit
1489                .original_message
1490                .lines()
1491                .next()
1492                .unwrap_or("(no message)");
1493            println!("  - {}: {}", &commit.hash[..8], subject);
1494        }
1495        loop {
1496            print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1497            std::io::stdout().flush()?;
1498            let Some(input) = super::read_interactive_line(reader)? else {
1499                eprintln!("warning: stdin closed, skipping failed commit(s)");
1500                break;
1501            };
1502            match input.trim().to_lowercase().as_str() {
1503                "r" | "retry" | "" => {
1504                    let mut still_failed = Vec::new();
1505                    for &idx in failed_indices.iter() {
1506                        let single_view =
1507                            full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1508                        match claude_client
1509                            .check_commits_with_scopes(&single_view, guidelines, valid_scopes, true)
1510                            .await
1511                        {
1512                            Ok(report) => {
1513                                if let Some(r) = report.commits.into_iter().next() {
1514                                    let summary = r.summary.clone().unwrap_or_default();
1515                                    successes.push((r, summary));
1516                                }
1517                            }
1518                            Err(e) => {
1519                                eprintln!("warning: still failed: {e}");
1520                                still_failed.push(idx);
1521                            }
1522                        }
1523                    }
1524                    *failed_indices = still_failed;
1525                    if failed_indices.is_empty() {
1526                        println!("✅ All retried commits succeeded.");
1527                        break;
1528                    }
1529                    println!("\n⚠️  {} commit(s) still failed:", failed_indices.len());
1530                    for &idx in failed_indices.iter() {
1531                        let commit = &full_repo_view.commits[idx];
1532                        let subject = commit
1533                            .original_message
1534                            .lines()
1535                            .next()
1536                            .unwrap_or("(no message)");
1537                        println!("  - {}: {}", &commit.hash[..8], subject);
1538                    }
1539                }
1540                "s" | "skip" => {
1541                    println!("Skipping {} failed commit(s).", failed_indices.len());
1542                    break;
1543                }
1544                _ => println!("Please enter 'r' to retry or 's' to skip."),
1545            }
1546        }
1547        Ok(())
1548    }
1549
1550    /// Prompts the user to retry or skip commits that failed amendment generation,
1551    /// updating `failed_indices` and `successes` in place.
1552    ///
1553    /// `is_terminal` and `reader` are injected so tests can drive the function
1554    /// without blocking on real stdin.
1555    #[allow(clippy::too_many_arguments)]
1556    async fn run_interactive_retry_generate_amendments(
1557        &self,
1558        failed_indices: &mut Vec<usize>,
1559        full_repo_view: &crate::data::RepositoryView,
1560        claude_client: &crate::claude::client::ClaudeClient,
1561        context: Option<&crate::data::context::CommitContext>,
1562        fresh: bool,
1563        successes: &mut Vec<(crate::data::amendments::Amendment, String)>,
1564        is_terminal: bool,
1565        reader: &mut (dyn std::io::BufRead + Send),
1566    ) -> Result<()> {
1567        use std::io::Write as _;
1568        println!(
1569            "\n⚠️  {} commit(s) failed to process:",
1570            failed_indices.len()
1571        );
1572        for &idx in failed_indices.iter() {
1573            let commit = &full_repo_view.commits[idx];
1574            let subject = commit
1575                .original_message
1576                .lines()
1577                .next()
1578                .unwrap_or("(no message)");
1579            println!("  - {}: {}", &commit.hash[..8], subject);
1580        }
1581        if !is_terminal {
1582            eprintln!(
1583                "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1584                failed_indices.len()
1585            );
1586            return Ok(());
1587        }
1588        loop {
1589            print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1590            std::io::stdout().flush()?;
1591            let Some(input) = super::read_interactive_line(reader)? else {
1592                eprintln!("warning: stdin closed, skipping failed commit(s)");
1593                break;
1594            };
1595            match input.trim().to_lowercase().as_str() {
1596                "r" | "retry" | "" => {
1597                    let mut still_failed = Vec::new();
1598                    for &idx in failed_indices.iter() {
1599                        let single_view =
1600                            full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1601                        let result = if let Some(ctx) = context {
1602                            claude_client
1603                                .generate_contextual_amendments_with_options(
1604                                    &single_view,
1605                                    ctx,
1606                                    fresh,
1607                                )
1608                                .await
1609                        } else {
1610                            claude_client
1611                                .generate_amendments_with_options(&single_view, fresh)
1612                                .await
1613                        };
1614                        match result {
1615                            Ok(af) => {
1616                                if let Some(a) = af.amendments.into_iter().next() {
1617                                    let summary = a.summary.clone().unwrap_or_default();
1618                                    successes.push((a, summary));
1619                                }
1620                            }
1621                            Err(e) => {
1622                                eprintln!("warning: still failed: {e}");
1623                                still_failed.push(idx);
1624                            }
1625                        }
1626                    }
1627                    *failed_indices = still_failed;
1628                    if failed_indices.is_empty() {
1629                        println!("✅ All retried commits succeeded.");
1630                        break;
1631                    }
1632                    println!("\n⚠️  {} commit(s) still failed:", failed_indices.len());
1633                    for &idx in failed_indices.iter() {
1634                        let commit = &full_repo_view.commits[idx];
1635                        let subject = commit
1636                            .original_message
1637                            .lines()
1638                            .next()
1639                            .unwrap_or("(no message)");
1640                        println!("  - {}: {}", &commit.hash[..8], subject);
1641                    }
1642                }
1643                "s" | "skip" => {
1644                    println!("Skipping {} failed commit(s).", failed_indices.len());
1645                    break;
1646                }
1647                _ => println!("Please enter 'r' to retry or 's' to skip."),
1648            }
1649        }
1650        Ok(())
1651    }
1652
1653    /// Outputs the text format check report (mirrors `CheckCommand::output_text_report`).
1654    fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1655        println!();
1656
1657        for result in &report.commits {
1658            // Skip passing commits
1659            if result.passes {
1660                continue;
1661            }
1662
1663            let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
1664            let short_hash = super::formatting::truncate_hash(&result.hash);
1665
1666            println!("{} {} - \"{}\"", icon, short_hash, result.message);
1667
1668            // Print issues
1669            for issue in &result.issues {
1670                let severity_str = super::formatting::format_severity_label(issue.severity);
1671
1672                println!(
1673                    "   {} [{}] {}",
1674                    severity_str, issue.section, issue.explanation
1675                );
1676            }
1677
1678            // Print suggestion if available
1679            if let Some(suggestion) = &result.suggestion {
1680                println!();
1681                println!("   Suggested message:");
1682                for line in suggestion.message.lines() {
1683                    println!("      {line}");
1684                }
1685            }
1686
1687            println!();
1688        }
1689
1690        // Print summary
1691        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1692        println!("Summary: {} commits checked", report.summary.total_commits);
1693        println!(
1694            "  {} errors, {} warnings",
1695            report.summary.error_count, report.summary.warning_count
1696        );
1697        println!(
1698            "  {} passed, {} with issues",
1699            report.summary.passing_commits, report.summary.failing_commits
1700        );
1701
1702        Ok(())
1703    }
1704}
1705
1706// --- Extracted pure functions ---
1707
1708/// Formats a work pattern as a display label with emoji.
1709///
1710/// Returns `None` for `WorkPattern::Unknown` since it should not be displayed.
1711fn format_work_pattern(pattern: &crate::data::context::WorkPattern) -> Option<&'static str> {
1712    use crate::data::context::WorkPattern;
1713    match pattern {
1714        WorkPattern::Sequential => Some("\u{1f504} Pattern: Sequential development"),
1715        WorkPattern::Refactoring => Some("\u{1f9f9} Pattern: Refactoring work"),
1716        WorkPattern::BugHunt => Some("\u{1f41b} Pattern: Bug investigation"),
1717        WorkPattern::Documentation => Some("\u{1f4d6} Pattern: Documentation updates"),
1718        WorkPattern::Configuration => Some("\u{2699}\u{fe0f}  Pattern: Configuration changes"),
1719        WorkPattern::Unknown => None,
1720    }
1721}
1722
1723/// Formats a verbosity level as a display label with emoji.
1724fn format_verbosity_level(level: crate::data::context::VerbosityLevel) -> &'static str {
1725    use crate::data::context::VerbosityLevel;
1726    match level {
1727        VerbosityLevel::Comprehensive => {
1728            "\u{1f4dd} Detail level: Comprehensive (significant changes detected)"
1729        }
1730        VerbosityLevel::Detailed => "\u{1f4dd} Detail level: Detailed",
1731        VerbosityLevel::Concise => "\u{1f4dd} Detail level: Concise",
1732    }
1733}
1734
1735/// Formats a list of scope definitions as a comma-separated string of names.
1736fn format_scope_list(scopes: &[crate::data::context::ScopeDefinition]) -> String {
1737    scopes
1738        .iter()
1739        .map(|s| s.name.as_str())
1740        .collect::<Vec<_>>()
1741        .join(", ")
1742}
1743
1744/// Refine scopes in generated amendment messages using the same deterministic
1745/// file-pattern logic the checker uses, so generator and checker agree.
1746fn refine_amendment_scopes(
1747    amendments: &mut AmendmentFile,
1748    repo_view: &RepositoryView,
1749    scope_defs: &[crate::data::context::ScopeDefinition],
1750) {
1751    for amendment in &mut amendments.amendments {
1752        if let Some(commit) = repo_view
1753            .commits
1754            .iter()
1755            .find(|c| c.hash == amendment.commit)
1756        {
1757            let files: Vec<&str> = commit
1758                .analysis
1759                .file_changes
1760                .file_list
1761                .iter()
1762                .map(|f| f.file.as_str())
1763                .collect();
1764            amendment.message =
1765                crate::git::refine_message_scope(&amendment.message, &files, scope_defs);
1766        }
1767    }
1768}
1769
1770#[cfg(test)]
1771#[allow(clippy::unwrap_used, clippy::expect_used)]
1772mod tests {
1773    use super::*;
1774    use crate::data::context::{ScopeDefinition, VerbosityLevel, WorkPattern};
1775
1776    // --- format_work_pattern ---
1777
1778    #[test]
1779    fn work_pattern_sequential() {
1780        let result = format_work_pattern(&WorkPattern::Sequential);
1781        assert!(result.is_some());
1782        assert!(result.unwrap().contains("Sequential development"));
1783    }
1784
1785    #[test]
1786    fn work_pattern_refactoring() {
1787        let result = format_work_pattern(&WorkPattern::Refactoring);
1788        assert!(result.is_some());
1789        assert!(result.unwrap().contains("Refactoring work"));
1790    }
1791
1792    #[test]
1793    fn work_pattern_bug_hunt() {
1794        let result = format_work_pattern(&WorkPattern::BugHunt);
1795        assert!(result.is_some());
1796        assert!(result.unwrap().contains("Bug investigation"));
1797    }
1798
1799    #[test]
1800    fn work_pattern_docs() {
1801        let result = format_work_pattern(&WorkPattern::Documentation);
1802        assert!(result.is_some());
1803        assert!(result.unwrap().contains("Documentation updates"));
1804    }
1805
1806    #[test]
1807    fn work_pattern_config() {
1808        let result = format_work_pattern(&WorkPattern::Configuration);
1809        assert!(result.is_some());
1810        assert!(result.unwrap().contains("Configuration changes"));
1811    }
1812
1813    #[test]
1814    fn work_pattern_unknown() {
1815        assert!(format_work_pattern(&WorkPattern::Unknown).is_none());
1816    }
1817
1818    // --- format_verbosity_level ---
1819
1820    #[test]
1821    fn verbosity_comprehensive() {
1822        let label = format_verbosity_level(VerbosityLevel::Comprehensive);
1823        assert!(label.contains("Comprehensive"));
1824        assert!(label.contains("significant changes"));
1825    }
1826
1827    #[test]
1828    fn verbosity_detailed() {
1829        let label = format_verbosity_level(VerbosityLevel::Detailed);
1830        assert!(label.contains("Detailed"));
1831    }
1832
1833    #[test]
1834    fn verbosity_concise() {
1835        let label = format_verbosity_level(VerbosityLevel::Concise);
1836        assert!(label.contains("Concise"));
1837    }
1838
1839    // --- format_scope_list ---
1840
1841    #[test]
1842    fn scope_list_single() {
1843        let scopes = vec![ScopeDefinition {
1844            name: "cli".to_string(),
1845            description: String::new(),
1846            examples: vec![],
1847            file_patterns: vec![],
1848        }];
1849        assert_eq!(format_scope_list(&scopes), "cli");
1850    }
1851
1852    #[test]
1853    fn scope_list_multiple() {
1854        let scopes = vec![
1855            ScopeDefinition {
1856                name: "cli".to_string(),
1857                description: String::new(),
1858                examples: vec![],
1859                file_patterns: vec![],
1860            },
1861            ScopeDefinition {
1862                name: "git".to_string(),
1863                description: String::new(),
1864                examples: vec![],
1865                file_patterns: vec![],
1866            },
1867            ScopeDefinition {
1868                name: "docs".to_string(),
1869                description: String::new(),
1870                examples: vec![],
1871                file_patterns: vec![],
1872            },
1873        ];
1874        assert_eq!(format_scope_list(&scopes), "cli, git, docs");
1875    }
1876
1877    // --- resolve_context_dir ---
1878
1879    #[test]
1880    fn context_dir_default() {
1881        let result = crate::claude::context::resolve_context_dir(None);
1882        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
1883        assert!(
1884            result.ends_with(".omni-dev"),
1885            "expected path ending in .omni-dev, got {result:?}"
1886        );
1887    }
1888
1889    #[test]
1890    fn context_dir_override() {
1891        let custom = std::path::PathBuf::from("custom-dir");
1892        let result = crate::claude::context::resolve_context_dir(Some(&custom));
1893        assert_eq!(result, custom);
1894    }
1895
1896    // --- is_fresh ---
1897
1898    fn parse_twiddle(args: &[&str]) -> TwiddleCommand {
1899        let mut full_args = vec!["twiddle"];
1900        full_args.extend_from_slice(args);
1901        TwiddleCommand::try_parse_from(full_args).unwrap()
1902    }
1903
1904    #[test]
1905    fn default_is_fresh() {
1906        let cmd = parse_twiddle(&[]);
1907        assert!(cmd.is_fresh(), "default should be fresh mode");
1908    }
1909
1910    #[test]
1911    fn refine_disables_fresh() {
1912        let cmd = parse_twiddle(&["--refine"]);
1913        assert!(!cmd.is_fresh(), "--refine should disable fresh mode");
1914    }
1915
1916    #[test]
1917    fn explicit_fresh_is_fresh() {
1918        let cmd = parse_twiddle(&["--fresh"]);
1919        assert!(cmd.is_fresh(), "--fresh should be fresh mode");
1920    }
1921
1922    #[test]
1923    fn fresh_and_refine_conflict() {
1924        let result = TwiddleCommand::try_parse_from(["twiddle", "--fresh", "--refine"]);
1925        assert!(result.is_err(), "--fresh and --refine should conflict");
1926    }
1927
1928    // --- check_commits_map_reduce (success paths via mock client) ---
1929
1930    fn make_twiddle_cmd() -> TwiddleCommand {
1931        TwiddleCommand {
1932            commit_range: None,
1933            model: None,
1934            beta_header: None,
1935            auto_apply: false,
1936            save_only: None,
1937            use_context: false,
1938            context_dir: None,
1939            work_context: None,
1940            branch_context: None,
1941            no_context: true,
1942            concurrency: 4,
1943            batch_size: None,
1944            no_coherence: true,
1945            no_ai: false,
1946            fresh: false,
1947            refine: false,
1948            check: false,
1949            quiet: false,
1950        }
1951    }
1952
1953    fn make_twiddle_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
1954        use crate::git::commit::FileChanges;
1955        use crate::git::{CommitAnalysis, CommitInfo};
1956        let tmp = tempfile::NamedTempFile::new().unwrap();
1957        let commit = CommitInfo {
1958            hash: hash.to_string(),
1959            author: "Test <test@test.com>".to_string(),
1960            date: chrono::Utc::now().fixed_offset(),
1961            original_message: format!("feat: commit {hash}"),
1962            in_main_branches: vec![],
1963            analysis: CommitAnalysis {
1964                detected_type: "feat".to_string(),
1965                detected_scope: String::new(),
1966                proposed_message: format!("feat: commit {hash}"),
1967                file_changes: FileChanges {
1968                    total_files: 0,
1969                    files_added: 0,
1970                    files_deleted: 0,
1971                    file_list: vec![],
1972                },
1973                diff_summary: String::new(),
1974                diff_file: tmp.path().to_string_lossy().to_string(),
1975                file_diffs: Vec::new(),
1976            },
1977        };
1978        (commit, tmp)
1979    }
1980
1981    fn make_twiddle_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
1982        use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
1983        RepositoryView {
1984            versions: None,
1985            explanation: FieldExplanation::default(),
1986            working_directory: WorkingDirectoryInfo {
1987                clean: true,
1988                untracked_changes: vec![],
1989            },
1990            remotes: vec![],
1991            ai: AiInfo {
1992                scratch: String::new(),
1993            },
1994            branch_info: None,
1995            pr_template: None,
1996            pr_template_location: None,
1997            branch_prs: None,
1998            commits,
1999        }
2000    }
2001
2002    fn twiddle_check_yaml(hash: &str) -> String {
2003        format!("checks:\n  - commit: {hash}\n    passes: true\n    issues: []\n")
2004    }
2005
2006    fn make_mock_client(
2007        responses: Vec<anyhow::Result<String>>,
2008    ) -> crate::claude::client::ClaudeClient {
2009        crate::claude::client::ClaudeClient::new(Box::new(
2010            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2011        ))
2012    }
2013
2014    #[tokio::test]
2015    async fn check_commits_map_reduce_single_commit_succeeds() {
2016        // Happy path: one commit, batch succeeds on first attempt.
2017        let (commit, _tmp) = make_twiddle_commit("abc00000");
2018        let cmd = make_twiddle_cmd();
2019        let repo_view = make_twiddle_repo_view(vec![commit]);
2020        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2021        let result = cmd
2022            .check_commits_map_reduce(&client, &repo_view, None, &[])
2023            .await;
2024        assert!(result.is_ok());
2025        assert_eq!(result.unwrap().commits.len(), 1);
2026    }
2027
2028    #[tokio::test]
2029    async fn check_commits_map_reduce_batch_fails_split_retry_both_succeed() {
2030        // Two commits in one batch. Batch fails (3 retries), then each commit
2031        // succeeds individually via split-and-retry. No stdin interaction since
2032        // failed_indices stays empty after both retries succeed.
2033        let (c1, _t1) = make_twiddle_commit("abc00000");
2034        let (c2, _t2) = make_twiddle_commit("def00000");
2035        let cmd = make_twiddle_cmd();
2036        let repo_view = make_twiddle_repo_view(vec![c1, c2]);
2037        let mut responses: Vec<anyhow::Result<String>> =
2038            (0..3).map(|_| Err(anyhow::anyhow!("batch fail"))).collect();
2039        responses.push(Ok(twiddle_check_yaml("abc00000")));
2040        responses.push(Ok(twiddle_check_yaml("def00000")));
2041        let client = make_mock_client(responses);
2042        let result = cmd
2043            .check_commits_map_reduce(&client, &repo_view, None, &[])
2044            .await;
2045        assert!(result.is_ok());
2046        assert_eq!(result.unwrap().commits.len(), 2);
2047    }
2048
2049    // --- run_interactive_retry_twiddle_check ---
2050
2051    #[tokio::test]
2052    async fn interactive_retry_twiddle_skip_immediately() {
2053        // "s" input → loop exits without calling the AI client at all.
2054        let (commit, _tmp) = make_twiddle_commit("abc00000");
2055        let cmd = make_twiddle_cmd();
2056        let repo_view = make_twiddle_repo_view(vec![commit]);
2057        let client = make_mock_client(vec![]);
2058        let mut failed = vec![0usize];
2059        let mut successes = vec![];
2060        let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
2061        cmd.run_interactive_retry_twiddle_check(
2062            &mut failed,
2063            &repo_view,
2064            &client,
2065            None,
2066            &[],
2067            &mut successes,
2068            &mut stdin,
2069        )
2070        .await
2071        .unwrap();
2072        assert_eq!(
2073            failed,
2074            vec![0],
2075            "skip should leave failed_indices unchanged"
2076        );
2077        assert!(successes.is_empty());
2078    }
2079
2080    #[tokio::test]
2081    async fn interactive_retry_twiddle_retry_succeeds() {
2082        // "r" input → retries the failed commit, which succeeds.
2083        let (commit, _tmp) = make_twiddle_commit("abc00000");
2084        let cmd = make_twiddle_cmd();
2085        let repo_view = make_twiddle_repo_view(vec![commit]);
2086        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2087        let mut failed = vec![0usize];
2088        let mut successes = vec![];
2089        let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
2090        cmd.run_interactive_retry_twiddle_check(
2091            &mut failed,
2092            &repo_view,
2093            &client,
2094            None,
2095            &[],
2096            &mut successes,
2097            &mut stdin,
2098        )
2099        .await
2100        .unwrap();
2101        assert!(
2102            failed.is_empty(),
2103            "retry succeeded → failed_indices cleared"
2104        );
2105        assert_eq!(successes.len(), 1);
2106    }
2107
2108    #[tokio::test]
2109    async fn interactive_retry_twiddle_default_input_retries() {
2110        // Empty input (just Enter) is treated as "r" (retry).
2111        let (commit, _tmp) = make_twiddle_commit("abc00000");
2112        let cmd = make_twiddle_cmd();
2113        let repo_view = make_twiddle_repo_view(vec![commit]);
2114        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2115        let mut failed = vec![0usize];
2116        let mut successes = vec![];
2117        let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
2118        cmd.run_interactive_retry_twiddle_check(
2119            &mut failed,
2120            &repo_view,
2121            &client,
2122            None,
2123            &[],
2124            &mut successes,
2125            &mut stdin,
2126        )
2127        .await
2128        .unwrap();
2129        assert!(failed.is_empty());
2130        assert_eq!(successes.len(), 1);
2131    }
2132
2133    #[tokio::test]
2134    async fn interactive_retry_twiddle_still_fails_then_skip() {
2135        // "r" → retry fails → still in failed_indices → "s" → skip.
2136        let (commit, _tmp) = make_twiddle_commit("abc00000");
2137        let cmd = make_twiddle_cmd();
2138        let repo_view = make_twiddle_repo_view(vec![commit]);
2139        // Retry attempt hits max_retries=2 (3 total attempts).
2140        let responses = (0..3).map(|_| Err(anyhow::anyhow!("mock fail"))).collect();
2141        let client = make_mock_client(responses);
2142        let mut failed = vec![0usize];
2143        let mut successes = vec![];
2144        let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
2145        cmd.run_interactive_retry_twiddle_check(
2146            &mut failed,
2147            &repo_view,
2148            &client,
2149            None,
2150            &[],
2151            &mut successes,
2152            &mut stdin,
2153        )
2154        .await
2155        .unwrap();
2156        assert_eq!(failed, vec![0], "commit still failed after retry");
2157        assert!(successes.is_empty());
2158    }
2159
2160    #[tokio::test]
2161    async fn interactive_retry_twiddle_invalid_input_then_skip() {
2162        // Unrecognised input → "please enter r or s" message → "s" exits.
2163        let (commit, _tmp) = make_twiddle_commit("abc00000");
2164        let cmd = make_twiddle_cmd();
2165        let repo_view = make_twiddle_repo_view(vec![commit]);
2166        let client = make_mock_client(vec![]);
2167        let mut failed = vec![0usize];
2168        let mut successes = vec![];
2169        let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
2170        cmd.run_interactive_retry_twiddle_check(
2171            &mut failed,
2172            &repo_view,
2173            &client,
2174            None,
2175            &[],
2176            &mut successes,
2177            &mut stdin,
2178        )
2179        .await
2180        .unwrap();
2181        assert_eq!(failed, vec![0]);
2182        assert!(successes.is_empty());
2183    }
2184
2185    #[tokio::test]
2186    async fn interactive_retry_twiddle_eof_breaks_immediately() {
2187        // EOF (empty reader) → read_line returns Ok(0) → loop breaks without
2188        // calling the AI client. failed_indices stays unchanged.
2189        let (commit, _tmp) = make_twiddle_commit("abc00000");
2190        let cmd = make_twiddle_cmd();
2191        let repo_view = make_twiddle_repo_view(vec![commit]);
2192        let client = make_mock_client(vec![]); // no responses consumed
2193        let mut failed = vec![0usize];
2194        let mut successes = vec![];
2195        let mut stdin = std::io::Cursor::new(b"" as &[u8]);
2196        cmd.run_interactive_retry_twiddle_check(
2197            &mut failed,
2198            &repo_view,
2199            &client,
2200            None,
2201            &[],
2202            &mut successes,
2203            &mut stdin,
2204        )
2205        .await
2206        .unwrap();
2207        assert_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
2208        assert!(successes.is_empty());
2209    }
2210
2211    // --- handle_amendments_file ---
2212
2213    fn make_amendment_file() -> crate::data::amendments::AmendmentFile {
2214        crate::data::amendments::AmendmentFile {
2215            amendments: vec![crate::data::amendments::Amendment {
2216                commit: "abc0000000000000000000000000000000000001".to_string(),
2217                message: "feat: improved commit message".to_string(),
2218                summary: None,
2219            }],
2220        }
2221    }
2222
2223    #[test]
2224    fn handle_amendments_file_non_terminal_returns_false() {
2225        // is_terminal=false → non-interactive warning, returns Ok(false) immediately.
2226        let cmd = make_twiddle_cmd();
2227        let amendments = make_amendment_file();
2228        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2229        let mut reader = std::io::Cursor::new(b"" as &[u8]);
2230        let result = cmd
2231            .handle_amendments_file(dummy_path, &amendments, false, &mut reader)
2232            .unwrap();
2233        assert!(!result, "non-terminal should return false");
2234    }
2235
2236    #[test]
2237    fn handle_amendments_file_eof_returns_false() {
2238        // is_terminal=true, EOF reader → read_line returns 0, returns Ok(false).
2239        let cmd = make_twiddle_cmd();
2240        let amendments = make_amendment_file();
2241        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2242        let mut reader = std::io::Cursor::new(b"" as &[u8]);
2243        let result = cmd
2244            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2245            .unwrap();
2246        assert!(!result, "EOF should return false");
2247    }
2248
2249    #[test]
2250    fn handle_amendments_file_quit_returns_false() {
2251        // is_terminal=true, "q\n" → user quits, returns Ok(false).
2252        let cmd = make_twiddle_cmd();
2253        let amendments = make_amendment_file();
2254        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2255        let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
2256        let result = cmd
2257            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2258            .unwrap();
2259        assert!(!result, "quit should return false");
2260    }
2261
2262    #[test]
2263    fn handle_amendments_file_apply_returns_true() {
2264        // is_terminal=true, "a\n" → user applies, returns Ok(true).
2265        let cmd = make_twiddle_cmd();
2266        let amendments = make_amendment_file();
2267        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2268        let mut reader = std::io::Cursor::new(b"a\n" as &[u8]);
2269        let result = cmd
2270            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2271            .unwrap();
2272        assert!(result, "apply should return true");
2273    }
2274
2275    #[test]
2276    fn handle_amendments_file_invalid_then_quit_returns_false() {
2277        // is_terminal=true, invalid input then "q\n" → prints error, then user quits.
2278        let cmd = make_twiddle_cmd();
2279        let amendments = make_amendment_file();
2280        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2281        let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
2282        let result = cmd
2283            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2284            .unwrap();
2285        assert!(!result, "invalid then quit should return false");
2286    }
2287
2288    // --- run_interactive_retry_generate_amendments ---
2289
2290    /// Full 40-char hex hash used for amendment retry tests (validation requires ≥40 chars).
2291    const HASH_40: &str = "abc0000000000000000000000000000000000000";
2292
2293    fn twiddle_amendment_yaml(hash: &str) -> String {
2294        format!("amendments:\n  - commit: \"{hash}\"\n    message: \"feat: improved message\"\n")
2295    }
2296
2297    #[tokio::test]
2298    async fn retry_generate_amendments_non_terminal_returns_immediately() {
2299        // is_terminal=false → warning printed, returns Ok(()) without prompting.
2300        let (commit, _tmp) = make_twiddle_commit("abc00000");
2301        let cmd = make_twiddle_cmd();
2302        let repo_view = make_twiddle_repo_view(vec![commit]);
2303        let client = make_mock_client(vec![]); // no calls expected
2304        let mut failed = vec![0usize];
2305        let mut successes = vec![];
2306        let mut reader = std::io::Cursor::new(b"" as &[u8]);
2307        cmd.run_interactive_retry_generate_amendments(
2308            &mut failed,
2309            &repo_view,
2310            &client,
2311            None,
2312            false,
2313            &mut successes,
2314            false, // is_terminal
2315            &mut reader,
2316        )
2317        .await
2318        .unwrap();
2319        assert_eq!(
2320            failed,
2321            vec![0],
2322            "non-terminal should leave failed unchanged"
2323        );
2324        assert!(successes.is_empty());
2325    }
2326
2327    #[tokio::test]
2328    async fn retry_generate_amendments_eof_breaks_immediately() {
2329        // is_terminal=true, EOF → read_line returns 0 → breaks without AI calls.
2330        let (commit, _tmp) = make_twiddle_commit("abc00000");
2331        let cmd = make_twiddle_cmd();
2332        let repo_view = make_twiddle_repo_view(vec![commit]);
2333        let client = make_mock_client(vec![]); // no calls expected
2334        let mut failed = vec![0usize];
2335        let mut successes = vec![];
2336        let mut reader = std::io::Cursor::new(b"" as &[u8]);
2337        cmd.run_interactive_retry_generate_amendments(
2338            &mut failed,
2339            &repo_view,
2340            &client,
2341            None,
2342            false,
2343            &mut successes,
2344            true, // is_terminal
2345            &mut reader,
2346        )
2347        .await
2348        .unwrap();
2349        assert_eq!(failed, vec![0], "EOF should leave failed unchanged");
2350        assert!(successes.is_empty());
2351    }
2352
2353    #[tokio::test]
2354    async fn retry_generate_amendments_skip_breaks_immediately() {
2355        // is_terminal=true, "s\n" → user skips, failed stays unchanged.
2356        let (commit, _tmp) = make_twiddle_commit("abc00000");
2357        let cmd = make_twiddle_cmd();
2358        let repo_view = make_twiddle_repo_view(vec![commit]);
2359        let client = make_mock_client(vec![]); // no calls expected
2360        let mut failed = vec![0usize];
2361        let mut successes = vec![];
2362        let mut reader = std::io::Cursor::new(b"s\n" as &[u8]);
2363        cmd.run_interactive_retry_generate_amendments(
2364            &mut failed,
2365            &repo_view,
2366            &client,
2367            None,
2368            false,
2369            &mut successes,
2370            true,
2371            &mut reader,
2372        )
2373        .await
2374        .unwrap();
2375        assert_eq!(failed, vec![0], "skip should leave failed unchanged");
2376        assert!(successes.is_empty());
2377    }
2378
2379    #[tokio::test]
2380    async fn retry_generate_amendments_invalid_then_skip() {
2381        // Unrecognised input → "please enter r or s" message → "s" exits.
2382        let (commit, _tmp) = make_twiddle_commit("abc00000");
2383        let cmd = make_twiddle_cmd();
2384        let repo_view = make_twiddle_repo_view(vec![commit]);
2385        let client = make_mock_client(vec![]);
2386        let mut failed = vec![0usize];
2387        let mut successes = vec![];
2388        let mut reader = std::io::Cursor::new(b"x\ns\n" as &[u8]);
2389        cmd.run_interactive_retry_generate_amendments(
2390            &mut failed,
2391            &repo_view,
2392            &client,
2393            None,
2394            false,
2395            &mut successes,
2396            true,
2397            &mut reader,
2398        )
2399        .await
2400        .unwrap();
2401        assert_eq!(failed, vec![0]);
2402        assert!(successes.is_empty());
2403    }
2404
2405    #[tokio::test]
2406    async fn retry_generate_amendments_retry_fails_then_skip() {
2407        // "r" → AI call fails → still in failed → "s" → skips.
2408        let (commit, _tmp) = make_twiddle_commit("abc00000");
2409        let cmd = make_twiddle_cmd();
2410        let repo_view = make_twiddle_repo_view(vec![commit]);
2411        let client = make_mock_client(vec![Err(anyhow::anyhow!("mock fail"))]);
2412        let mut failed = vec![0usize];
2413        let mut successes = vec![];
2414        let mut reader = std::io::Cursor::new(b"r\ns\n" as &[u8]);
2415        cmd.run_interactive_retry_generate_amendments(
2416            &mut failed,
2417            &repo_view,
2418            &client,
2419            None,
2420            false,
2421            &mut successes,
2422            true,
2423            &mut reader,
2424        )
2425        .await
2426        .unwrap();
2427        assert_eq!(failed, vec![0], "commit still failed after retry");
2428        assert!(successes.is_empty());
2429    }
2430
2431    #[tokio::test]
2432    async fn retry_generate_amendments_retry_succeeds() {
2433        // "r" → AI returns valid amendment → failed cleared, success recorded.
2434        let (commit, _tmp) = make_twiddle_commit(HASH_40);
2435        let cmd = make_twiddle_cmd();
2436        let repo_view = make_twiddle_repo_view(vec![commit]);
2437        let client = make_mock_client(vec![Ok(twiddle_amendment_yaml(HASH_40))]);
2438        let mut failed = vec![0usize];
2439        let mut successes = vec![];
2440        let mut reader = std::io::Cursor::new(b"r\n" as &[u8]);
2441        cmd.run_interactive_retry_generate_amendments(
2442            &mut failed,
2443            &repo_view,
2444            &client,
2445            None,
2446            false,
2447            &mut successes,
2448            true,
2449            &mut reader,
2450        )
2451        .await
2452        .unwrap();
2453        assert!(failed.is_empty(), "retry succeeded → failed cleared");
2454        assert_eq!(successes.len(), 1);
2455    }
2456
2457    #[test]
2458    fn refine_amendment_scopes_replaces_scope_from_file_patterns() {
2459        use crate::data::amendments::Amendment;
2460        use crate::data::context::ScopeDefinition;
2461        use crate::git::commit::FileChange;
2462
2463        // Build a commit whose files match the "cli" scope pattern.
2464        let (mut commit, _tmp) = make_twiddle_commit("aaa00000");
2465        commit.analysis.file_changes.file_list = vec![FileChange {
2466            status: "M".to_string(),
2467            file: "src/cli/git/twiddle.rs".to_string(),
2468        }];
2469
2470        let repo_view = make_twiddle_repo_view(vec![commit]);
2471
2472        let scope_defs = vec![ScopeDefinition {
2473            name: "cli".to_string(),
2474            description: "CLI commands".to_string(),
2475            examples: vec![],
2476            file_patterns: vec!["src/cli/**".to_string()],
2477        }];
2478
2479        let mut amendments = AmendmentFile {
2480            amendments: vec![Amendment {
2481                commit: "aaa00000".to_string(),
2482                message: "fix(wrong-scope): tweak something".to_string(),
2483                summary: None,
2484            }],
2485        };
2486
2487        refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
2488
2489        assert_eq!(
2490            amendments.amendments[0].message,
2491            "fix(cli): tweak something",
2492        );
2493    }
2494
2495    #[test]
2496    fn refine_amendment_scopes_no_match_leaves_message_unchanged() {
2497        use crate::data::amendments::Amendment;
2498
2499        let (commit, _tmp) = make_twiddle_commit("bbb00000");
2500        let repo_view = make_twiddle_repo_view(vec![commit]);
2501
2502        let mut amendments = AmendmentFile {
2503            amendments: vec![Amendment {
2504                commit: "bbb00000".to_string(),
2505                message: "feat(stuff): add feature".to_string(),
2506                summary: None,
2507            }],
2508        };
2509
2510        // No scope defs → no refinement.
2511        refine_amendment_scopes(&mut amendments, &repo_view, &[]);
2512
2513        assert_eq!(amendments.amendments[0].message, "feat(stuff): add feature",);
2514    }
2515
2516    #[test]
2517    fn refine_amendment_scopes_skips_unknown_commits() {
2518        use crate::data::amendments::Amendment;
2519        use crate::data::context::ScopeDefinition;
2520
2521        let (commit, _tmp) = make_twiddle_commit("ccc00000");
2522        let repo_view = make_twiddle_repo_view(vec![commit]);
2523
2524        let scope_defs = vec![ScopeDefinition {
2525            name: "cli".to_string(),
2526            description: "CLI".to_string(),
2527            examples: vec![],
2528            file_patterns: vec!["src/cli/**".to_string()],
2529        }];
2530
2531        let mut amendments = AmendmentFile {
2532            amendments: vec![Amendment {
2533                commit: "unknown_hash".to_string(),
2534                message: "fix(wrong): something".to_string(),
2535                summary: None,
2536            }],
2537        };
2538
2539        refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
2540
2541        // Message unchanged because commit wasn't found in repo_view.
2542        assert_eq!(amendments.amendments[0].message, "fix(wrong): something",);
2543    }
2544}