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/// Structured output from [`run_twiddle`] for programmatic consumers (MCP).
1707#[derive(Debug, Clone)]
1708pub struct TwiddleOutcome {
1709    /// YAML serialisation of the generated [`AmendmentFile`].
1710    pub amendments_yaml: String,
1711    /// `true` when amendments were applied to the repository; `false` for a
1712    /// dry-run or when no amendments were generated.
1713    pub applied: bool,
1714    /// Number of amendments generated.
1715    pub amendment_count: usize,
1716}
1717
1718/// Non-interactive core for `omni-dev git commit message twiddle`.
1719///
1720/// Shared by the CLI (wrapped by [`TwiddleCommand::execute`] for the
1721/// interactive flow) and the MCP server. The MCP tool boundary is
1722/// non-interactive, so this entry point forces `--auto-apply` semantics when
1723/// `dry_run` is false and never opens an editor. When `dry_run` is true,
1724/// proposed amendments are returned as YAML without being applied.
1725///
1726/// Like [`super::run_check`], a `Some` `repo_path` pins the process CWD for
1727/// the duration of the call.
1728pub async fn run_twiddle(
1729    range: Option<&str>,
1730    model: Option<String>,
1731    dry_run: bool,
1732    repo_path: Option<&std::path::Path>,
1733) -> Result<TwiddleOutcome> {
1734    let _cwd_guard = match repo_path {
1735        Some(p) => Some(super::CwdGuard::enter(p).await?),
1736        None => None,
1737    };
1738
1739    crate::utils::check_ai_command_prerequisites(model.as_deref())?;
1740
1741    if !dry_run {
1742        crate::utils::preflight::check_working_directory_clean()?;
1743    }
1744
1745    let claude_client = crate::claude::create_default_claude_client(model, None)?;
1746    run_twiddle_with_client(range, dry_run, &claude_client).await
1747}
1748
1749/// Non-credential-gated inner core of [`run_twiddle`] for unit tests.
1750///
1751/// Extracted so tests can inject a [`crate::claude::client::ClaudeClient`]
1752/// backed by the in-crate mock AI client and exercise the full flow without
1753/// real credentials. Callers are responsible for holding any
1754/// [`super::CwdGuard`] they need and for running preflight themselves.
1755pub(crate) async fn run_twiddle_with_client(
1756    range: Option<&str>,
1757    dry_run: bool,
1758    claude_client: &crate::claude::client::ClaudeClient,
1759) -> Result<TwiddleOutcome> {
1760    use crate::data::{
1761        AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1762        WorkingDirectoryInfo,
1763    };
1764    use crate::git::{GitRepository, RemoteInfo};
1765    use crate::utils::ai_scratch;
1766
1767    let resolved_range = range.unwrap_or("HEAD~5..HEAD");
1768
1769    let repo = GitRepository::open()
1770        .context("Failed to open git repository. Make sure you're in a git repository.")?;
1771
1772    let current_branch = repo
1773        .get_current_branch()
1774        .unwrap_or_else(|_| "HEAD".to_string());
1775
1776    let wd_status = repo.get_working_directory_status()?;
1777    let working_directory = WorkingDirectoryInfo {
1778        clean: wd_status.clean,
1779        untracked_changes: wd_status
1780            .untracked_changes
1781            .into_iter()
1782            .map(|fs| FileStatusInfo {
1783                status: fs.status,
1784                file: fs.file,
1785            })
1786            .collect(),
1787    };
1788
1789    let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1790    let commits = repo.get_commits_in_range(resolved_range)?;
1791
1792    if commits.is_empty() {
1793        let empty_file = AmendmentFile { amendments: vec![] };
1794        let yaml =
1795            crate::data::to_yaml(&empty_file).context("Failed to serialise empty AmendmentFile")?;
1796        return Ok(TwiddleOutcome {
1797            amendments_yaml: yaml,
1798            applied: false,
1799            amendment_count: 0,
1800        });
1801    }
1802
1803    let ai_scratch_path =
1804        ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1805    let ai_info = AiInfo {
1806        scratch: ai_scratch_path.to_string_lossy().to_string(),
1807    };
1808
1809    let mut repo_view = RepositoryView {
1810        versions: Some(VersionInfo {
1811            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1812        }),
1813        explanation: FieldExplanation::default(),
1814        working_directory,
1815        remotes,
1816        ai: ai_info,
1817        branch_info: Some(BranchInfo {
1818            branch: current_branch,
1819        }),
1820        pr_template: None,
1821        pr_template_location: None,
1822        branch_prs: None,
1823        commits,
1824    };
1825    repo_view.update_field_presence();
1826
1827    let mut amendments = claude_client
1828        .generate_amendments_with_options(&repo_view, true)
1829        .await?;
1830
1831    let context_dir = crate::claude::context::resolve_context_dir(None);
1832    let scope_defs =
1833        crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."));
1834    refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
1835
1836    let amendments_yaml =
1837        crate::data::to_yaml(&amendments).context("Failed to serialise AmendmentFile")?;
1838    let amendment_count = amendments.amendments.len();
1839
1840    if dry_run || amendment_count == 0 {
1841        return Ok(TwiddleOutcome {
1842            amendments_yaml,
1843            applied: false,
1844            amendment_count,
1845        });
1846    }
1847
1848    let temp_dir = tempfile::tempdir().context("Failed to create temp dir")?;
1849    let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1850    amendments
1851        .save_to_file(&amendments_file)
1852        .context("Failed to save amendments")?;
1853    let handler =
1854        crate::git::AmendmentHandler::new().context("Failed to initialise amendment handler")?;
1855    handler
1856        .apply_amendments(&amendments_file.to_string_lossy())
1857        .context("Failed to apply amendments")?;
1858
1859    Ok(TwiddleOutcome {
1860        amendments_yaml,
1861        applied: true,
1862        amendment_count,
1863    })
1864}
1865
1866#[cfg(test)]
1867#[allow(clippy::unwrap_used, clippy::expect_used)]
1868mod run_twiddle_tests {
1869    use super::*;
1870    use crate::claude::client::ClaudeClient;
1871    use crate::claude::test_utils::ConfigurableMockAiClient;
1872    use git2::{Repository, Signature};
1873
1874    #[tokio::test]
1875    async fn run_twiddle_invalid_repo_path_errors_before_ai() {
1876        let err = run_twiddle(
1877            None,
1878            None,
1879            true,
1880            Some(std::path::Path::new("/no/such/path/exists")),
1881        )
1882        .await
1883        .unwrap_err();
1884        let msg = format!("{err:#}");
1885        assert!(
1886            msg.to_lowercase().contains("set_current_dir")
1887                || msg.to_lowercase().contains("no such")
1888                || msg.to_lowercase().contains("directory"),
1889            "expected cwd-related error, got: {msg}"
1890        );
1891    }
1892
1893    fn init_test_repo_with_commit() -> (tempfile::TempDir, String) {
1894        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1895        std::fs::create_dir_all(&tmp_root).unwrap();
1896        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1897        let repo = Repository::init(temp_dir.path()).unwrap();
1898        {
1899            let mut cfg = repo.config().unwrap();
1900            cfg.set_str("user.name", "Test").unwrap();
1901            cfg.set_str("user.email", "test@example.com").unwrap();
1902        }
1903        let signature = Signature::now("Test", "test@example.com").unwrap();
1904        std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
1905        let mut idx = repo.index().unwrap();
1906        idx.add_path(std::path::Path::new("f.txt")).unwrap();
1907        idx.write().unwrap();
1908        let tree_id = idx.write_tree().unwrap();
1909        let tree = repo.find_tree(tree_id).unwrap();
1910        let oid = repo
1911            .commit(
1912                Some("HEAD"),
1913                &signature,
1914                &signature,
1915                "feat: original",
1916                &tree,
1917                &[],
1918            )
1919            .unwrap();
1920        (temp_dir, oid.to_string())
1921    }
1922
1923    fn amendment_yaml(hash: &str, msg: &str) -> String {
1924        format!("amendments:\n  - commit: {hash}\n    message: '{msg}'\n")
1925    }
1926
1927    #[tokio::test]
1928    async fn run_twiddle_with_client_dry_run_returns_amendments() {
1929        let (temp_dir, hash) = init_test_repo_with_commit();
1930        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1931            .await
1932            .unwrap();
1933
1934        let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
1935            &hash,
1936            "feat(cli): better subject",
1937        ))]);
1938        let client = ClaudeClient::new(Box::new(mock));
1939
1940        let outcome = run_twiddle_with_client(Some("HEAD"), true, &client)
1941            .await
1942            .unwrap();
1943        assert!(!outcome.applied, "dry_run must not apply");
1944        assert_eq!(outcome.amendment_count, 1);
1945        assert!(outcome.amendments_yaml.contains("amendments:"));
1946    }
1947
1948    #[tokio::test]
1949    async fn run_twiddle_with_client_empty_range_returns_empty() {
1950        let (temp_dir, _hash) = init_test_repo_with_commit();
1951        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1952            .await
1953            .unwrap();
1954
1955        let mock = ConfigurableMockAiClient::new(vec![]);
1956        let client = ClaudeClient::new(Box::new(mock));
1957
1958        let outcome = run_twiddle_with_client(Some("HEAD..HEAD"), true, &client)
1959            .await
1960            .unwrap();
1961        assert_eq!(outcome.amendment_count, 0);
1962        assert!(!outcome.applied);
1963    }
1964
1965    #[tokio::test]
1966    async fn run_twiddle_with_client_ai_failure_errors() {
1967        let (temp_dir, _hash) = init_test_repo_with_commit();
1968        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1969            .await
1970            .unwrap();
1971
1972        let mock = ConfigurableMockAiClient::new(vec![]);
1973        let client = ClaudeClient::new(Box::new(mock));
1974        let err = run_twiddle_with_client(Some("HEAD"), true, &client)
1975            .await
1976            .unwrap_err();
1977        let _ = err;
1978    }
1979
1980    #[tokio::test]
1981    async fn run_twiddle_with_client_default_range_errors_on_sparse_repo() {
1982        let (temp_dir, _hash) = init_test_repo_with_commit();
1983        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1984            .await
1985            .unwrap();
1986
1987        // Default range HEAD~5..HEAD cannot resolve HEAD~5 in a repo with
1988        // only one commit — get_commits_in_range returns an error, which
1989        // propagates. This still exercises the default-range code path.
1990        let mock = ConfigurableMockAiClient::new(vec![]);
1991        let client = ClaudeClient::new(Box::new(mock));
1992
1993        let err = run_twiddle_with_client(None, true, &client)
1994            .await
1995            .unwrap_err();
1996        assert!(
1997            format!("{err:#}").contains("HEAD~5")
1998                || format!("{err:#}").to_lowercase().contains("not found"),
1999            "expected HEAD~5 resolution error"
2000        );
2001    }
2002
2003    #[test]
2004    fn twiddle_outcome_clone_and_debug() {
2005        let outcome = TwiddleOutcome {
2006            amendments_yaml: "x".to_string(),
2007            applied: true,
2008            amendment_count: 2,
2009        };
2010        let cloned = outcome.clone();
2011        assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
2012    }
2013
2014    /// Exercises the apply-amendments path (`dry_run = false`). The amendment
2015    /// targets the repo's HEAD, so `AmendmentHandler` takes the fast
2016    /// `amend_head_commit` branch rather than interactive rebase.
2017    #[tokio::test]
2018    async fn run_twiddle_with_client_applies_head_amendment() {
2019        let (temp_dir, hash) = init_test_repo_with_commit();
2020        let _guard = super::super::CwdGuard::enter(temp_dir.path())
2021            .await
2022            .unwrap();
2023
2024        let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
2025            &hash,
2026            "feat(cli): much better subject",
2027        ))]);
2028        let client = ClaudeClient::new(Box::new(mock));
2029
2030        let outcome = run_twiddle_with_client(Some("HEAD"), false, &client)
2031            .await
2032            .unwrap();
2033        assert!(outcome.applied, "dry_run=false must apply amendments");
2034        assert_eq!(outcome.amendment_count, 1);
2035
2036        // Confirm the commit message was actually rewritten on HEAD.
2037        let repo = git2::Repository::open(temp_dir.path()).unwrap();
2038        let head_msg = repo
2039            .head()
2040            .unwrap()
2041            .peel_to_commit()
2042            .unwrap()
2043            .message()
2044            .unwrap()
2045            .to_string();
2046        assert!(
2047            head_msg.contains("much better subject"),
2048            "HEAD message should be rewritten: {head_msg}"
2049        );
2050    }
2051}
2052
2053// --- Extracted pure functions ---
2054
2055/// Formats a work pattern as a display label with emoji.
2056///
2057/// Returns `None` for `WorkPattern::Unknown` since it should not be displayed.
2058fn format_work_pattern(pattern: &crate::data::context::WorkPattern) -> Option<&'static str> {
2059    use crate::data::context::WorkPattern;
2060    match pattern {
2061        WorkPattern::Sequential => Some("\u{1f504} Pattern: Sequential development"),
2062        WorkPattern::Refactoring => Some("\u{1f9f9} Pattern: Refactoring work"),
2063        WorkPattern::BugHunt => Some("\u{1f41b} Pattern: Bug investigation"),
2064        WorkPattern::Documentation => Some("\u{1f4d6} Pattern: Documentation updates"),
2065        WorkPattern::Configuration => Some("\u{2699}\u{fe0f}  Pattern: Configuration changes"),
2066        WorkPattern::Unknown => None,
2067    }
2068}
2069
2070/// Formats a verbosity level as a display label with emoji.
2071fn format_verbosity_level(level: crate::data::context::VerbosityLevel) -> &'static str {
2072    use crate::data::context::VerbosityLevel;
2073    match level {
2074        VerbosityLevel::Comprehensive => {
2075            "\u{1f4dd} Detail level: Comprehensive (significant changes detected)"
2076        }
2077        VerbosityLevel::Detailed => "\u{1f4dd} Detail level: Detailed",
2078        VerbosityLevel::Concise => "\u{1f4dd} Detail level: Concise",
2079    }
2080}
2081
2082/// Formats a list of scope definitions as a comma-separated string of names.
2083fn format_scope_list(scopes: &[crate::data::context::ScopeDefinition]) -> String {
2084    scopes
2085        .iter()
2086        .map(|s| s.name.as_str())
2087        .collect::<Vec<_>>()
2088        .join(", ")
2089}
2090
2091/// Refine scopes in generated amendment messages using the same deterministic
2092/// file-pattern logic the checker uses, so generator and checker agree.
2093fn refine_amendment_scopes(
2094    amendments: &mut AmendmentFile,
2095    repo_view: &RepositoryView,
2096    scope_defs: &[crate::data::context::ScopeDefinition],
2097) {
2098    for amendment in &mut amendments.amendments {
2099        if let Some(commit) = repo_view
2100            .commits
2101            .iter()
2102            .find(|c| c.hash == amendment.commit)
2103        {
2104            let files: Vec<&str> = commit
2105                .analysis
2106                .file_changes
2107                .file_list
2108                .iter()
2109                .map(|f| f.file.as_str())
2110                .collect();
2111            amendment.message =
2112                crate::git::refine_message_scope(&amendment.message, &files, scope_defs);
2113        }
2114    }
2115}
2116
2117#[cfg(test)]
2118#[allow(clippy::unwrap_used, clippy::expect_used)]
2119mod tests {
2120    use super::*;
2121    use crate::data::context::{ScopeDefinition, VerbosityLevel, WorkPattern};
2122
2123    // --- format_work_pattern ---
2124
2125    #[test]
2126    fn work_pattern_sequential() {
2127        let result = format_work_pattern(&WorkPattern::Sequential);
2128        assert!(result.is_some());
2129        assert!(result.unwrap().contains("Sequential development"));
2130    }
2131
2132    #[test]
2133    fn work_pattern_refactoring() {
2134        let result = format_work_pattern(&WorkPattern::Refactoring);
2135        assert!(result.is_some());
2136        assert!(result.unwrap().contains("Refactoring work"));
2137    }
2138
2139    #[test]
2140    fn work_pattern_bug_hunt() {
2141        let result = format_work_pattern(&WorkPattern::BugHunt);
2142        assert!(result.is_some());
2143        assert!(result.unwrap().contains("Bug investigation"));
2144    }
2145
2146    #[test]
2147    fn work_pattern_docs() {
2148        let result = format_work_pattern(&WorkPattern::Documentation);
2149        assert!(result.is_some());
2150        assert!(result.unwrap().contains("Documentation updates"));
2151    }
2152
2153    #[test]
2154    fn work_pattern_config() {
2155        let result = format_work_pattern(&WorkPattern::Configuration);
2156        assert!(result.is_some());
2157        assert!(result.unwrap().contains("Configuration changes"));
2158    }
2159
2160    #[test]
2161    fn work_pattern_unknown() {
2162        assert!(format_work_pattern(&WorkPattern::Unknown).is_none());
2163    }
2164
2165    // --- format_verbosity_level ---
2166
2167    #[test]
2168    fn verbosity_comprehensive() {
2169        let label = format_verbosity_level(VerbosityLevel::Comprehensive);
2170        assert!(label.contains("Comprehensive"));
2171        assert!(label.contains("significant changes"));
2172    }
2173
2174    #[test]
2175    fn verbosity_detailed() {
2176        let label = format_verbosity_level(VerbosityLevel::Detailed);
2177        assert!(label.contains("Detailed"));
2178    }
2179
2180    #[test]
2181    fn verbosity_concise() {
2182        let label = format_verbosity_level(VerbosityLevel::Concise);
2183        assert!(label.contains("Concise"));
2184    }
2185
2186    // --- format_scope_list ---
2187
2188    #[test]
2189    fn scope_list_single() {
2190        let scopes = vec![ScopeDefinition {
2191            name: "cli".to_string(),
2192            description: String::new(),
2193            examples: vec![],
2194            file_patterns: vec![],
2195        }];
2196        assert_eq!(format_scope_list(&scopes), "cli");
2197    }
2198
2199    #[test]
2200    fn scope_list_multiple() {
2201        let scopes = vec![
2202            ScopeDefinition {
2203                name: "cli".to_string(),
2204                description: String::new(),
2205                examples: vec![],
2206                file_patterns: vec![],
2207            },
2208            ScopeDefinition {
2209                name: "git".to_string(),
2210                description: String::new(),
2211                examples: vec![],
2212                file_patterns: vec![],
2213            },
2214            ScopeDefinition {
2215                name: "docs".to_string(),
2216                description: String::new(),
2217                examples: vec![],
2218                file_patterns: vec![],
2219            },
2220        ];
2221        assert_eq!(format_scope_list(&scopes), "cli, git, docs");
2222    }
2223
2224    // --- resolve_context_dir ---
2225
2226    #[test]
2227    fn context_dir_default() {
2228        let result = crate::claude::context::resolve_context_dir(None);
2229        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
2230        assert!(
2231            result.ends_with(".omni-dev"),
2232            "expected path ending in .omni-dev, got {result:?}"
2233        );
2234    }
2235
2236    #[test]
2237    fn context_dir_override() {
2238        let custom = std::path::PathBuf::from("custom-dir");
2239        let result = crate::claude::context::resolve_context_dir(Some(&custom));
2240        assert_eq!(result, custom);
2241    }
2242
2243    // --- is_fresh ---
2244
2245    fn parse_twiddle(args: &[&str]) -> TwiddleCommand {
2246        let mut full_args = vec!["twiddle"];
2247        full_args.extend_from_slice(args);
2248        TwiddleCommand::try_parse_from(full_args).unwrap()
2249    }
2250
2251    #[test]
2252    fn default_is_fresh() {
2253        let cmd = parse_twiddle(&[]);
2254        assert!(cmd.is_fresh(), "default should be fresh mode");
2255    }
2256
2257    #[test]
2258    fn refine_disables_fresh() {
2259        let cmd = parse_twiddle(&["--refine"]);
2260        assert!(!cmd.is_fresh(), "--refine should disable fresh mode");
2261    }
2262
2263    #[test]
2264    fn explicit_fresh_is_fresh() {
2265        let cmd = parse_twiddle(&["--fresh"]);
2266        assert!(cmd.is_fresh(), "--fresh should be fresh mode");
2267    }
2268
2269    #[test]
2270    fn fresh_and_refine_conflict() {
2271        let result = TwiddleCommand::try_parse_from(["twiddle", "--fresh", "--refine"]);
2272        assert!(result.is_err(), "--fresh and --refine should conflict");
2273    }
2274
2275    // --- check_commits_map_reduce (success paths via mock client) ---
2276
2277    fn make_twiddle_cmd() -> TwiddleCommand {
2278        TwiddleCommand {
2279            commit_range: None,
2280            model: None,
2281            beta_header: None,
2282            auto_apply: false,
2283            save_only: None,
2284            use_context: false,
2285            context_dir: None,
2286            work_context: None,
2287            branch_context: None,
2288            no_context: true,
2289            concurrency: 4,
2290            batch_size: None,
2291            no_coherence: true,
2292            no_ai: false,
2293            fresh: false,
2294            refine: false,
2295            check: false,
2296            quiet: false,
2297        }
2298    }
2299
2300    fn make_twiddle_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
2301        use crate::git::commit::FileChanges;
2302        use crate::git::{CommitAnalysis, CommitInfo};
2303        let tmp = tempfile::NamedTempFile::new().unwrap();
2304        let commit = CommitInfo {
2305            hash: hash.to_string(),
2306            author: "Test <test@test.com>".to_string(),
2307            date: chrono::Utc::now().fixed_offset(),
2308            original_message: format!("feat: commit {hash}"),
2309            in_main_branches: vec![],
2310            analysis: CommitAnalysis {
2311                detected_type: "feat".to_string(),
2312                detected_scope: String::new(),
2313                proposed_message: format!("feat: commit {hash}"),
2314                file_changes: FileChanges {
2315                    total_files: 0,
2316                    files_added: 0,
2317                    files_deleted: 0,
2318                    file_list: vec![],
2319                },
2320                diff_summary: String::new(),
2321                diff_file: tmp.path().to_string_lossy().to_string(),
2322                file_diffs: Vec::new(),
2323            },
2324        };
2325        (commit, tmp)
2326    }
2327
2328    fn make_twiddle_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
2329        use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
2330        RepositoryView {
2331            versions: None,
2332            explanation: FieldExplanation::default(),
2333            working_directory: WorkingDirectoryInfo {
2334                clean: true,
2335                untracked_changes: vec![],
2336            },
2337            remotes: vec![],
2338            ai: AiInfo {
2339                scratch: String::new(),
2340            },
2341            branch_info: None,
2342            pr_template: None,
2343            pr_template_location: None,
2344            branch_prs: None,
2345            commits,
2346        }
2347    }
2348
2349    fn twiddle_check_yaml(hash: &str) -> String {
2350        format!("checks:\n  - commit: {hash}\n    passes: true\n    issues: []\n")
2351    }
2352
2353    fn make_mock_client(
2354        responses: Vec<anyhow::Result<String>>,
2355    ) -> crate::claude::client::ClaudeClient {
2356        crate::claude::client::ClaudeClient::new(Box::new(
2357            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2358        ))
2359    }
2360
2361    #[tokio::test]
2362    async fn check_commits_map_reduce_single_commit_succeeds() {
2363        // Happy path: one commit, batch succeeds on first attempt.
2364        let (commit, _tmp) = make_twiddle_commit("abc00000");
2365        let cmd = make_twiddle_cmd();
2366        let repo_view = make_twiddle_repo_view(vec![commit]);
2367        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2368        let result = cmd
2369            .check_commits_map_reduce(&client, &repo_view, None, &[])
2370            .await;
2371        assert!(result.is_ok());
2372        assert_eq!(result.unwrap().commits.len(), 1);
2373    }
2374
2375    #[tokio::test]
2376    async fn check_commits_map_reduce_batch_fails_split_retry_both_succeed() {
2377        // Two commits in one batch. Batch fails (3 retries), then each commit
2378        // succeeds individually via split-and-retry. No stdin interaction since
2379        // failed_indices stays empty after both retries succeed.
2380        let (c1, _t1) = make_twiddle_commit("abc00000");
2381        let (c2, _t2) = make_twiddle_commit("def00000");
2382        let cmd = make_twiddle_cmd();
2383        let repo_view = make_twiddle_repo_view(vec![c1, c2]);
2384        let mut responses: Vec<anyhow::Result<String>> =
2385            (0..3).map(|_| Err(anyhow::anyhow!("batch fail"))).collect();
2386        responses.push(Ok(twiddle_check_yaml("abc00000")));
2387        responses.push(Ok(twiddle_check_yaml("def00000")));
2388        let client = make_mock_client(responses);
2389        let result = cmd
2390            .check_commits_map_reduce(&client, &repo_view, None, &[])
2391            .await;
2392        assert!(result.is_ok());
2393        assert_eq!(result.unwrap().commits.len(), 2);
2394    }
2395
2396    // --- run_interactive_retry_twiddle_check ---
2397
2398    #[tokio::test]
2399    async fn interactive_retry_twiddle_skip_immediately() {
2400        // "s" input → loop exits without calling the AI client at all.
2401        let (commit, _tmp) = make_twiddle_commit("abc00000");
2402        let cmd = make_twiddle_cmd();
2403        let repo_view = make_twiddle_repo_view(vec![commit]);
2404        let client = make_mock_client(vec![]);
2405        let mut failed = vec![0usize];
2406        let mut successes = vec![];
2407        let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
2408        cmd.run_interactive_retry_twiddle_check(
2409            &mut failed,
2410            &repo_view,
2411            &client,
2412            None,
2413            &[],
2414            &mut successes,
2415            &mut stdin,
2416        )
2417        .await
2418        .unwrap();
2419        assert_eq!(
2420            failed,
2421            vec![0],
2422            "skip should leave failed_indices unchanged"
2423        );
2424        assert!(successes.is_empty());
2425    }
2426
2427    #[tokio::test]
2428    async fn interactive_retry_twiddle_retry_succeeds() {
2429        // "r" input → retries the failed commit, which succeeds.
2430        let (commit, _tmp) = make_twiddle_commit("abc00000");
2431        let cmd = make_twiddle_cmd();
2432        let repo_view = make_twiddle_repo_view(vec![commit]);
2433        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2434        let mut failed = vec![0usize];
2435        let mut successes = vec![];
2436        let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
2437        cmd.run_interactive_retry_twiddle_check(
2438            &mut failed,
2439            &repo_view,
2440            &client,
2441            None,
2442            &[],
2443            &mut successes,
2444            &mut stdin,
2445        )
2446        .await
2447        .unwrap();
2448        assert!(
2449            failed.is_empty(),
2450            "retry succeeded → failed_indices cleared"
2451        );
2452        assert_eq!(successes.len(), 1);
2453    }
2454
2455    #[tokio::test]
2456    async fn interactive_retry_twiddle_default_input_retries() {
2457        // Empty input (just Enter) is treated as "r" (retry).
2458        let (commit, _tmp) = make_twiddle_commit("abc00000");
2459        let cmd = make_twiddle_cmd();
2460        let repo_view = make_twiddle_repo_view(vec![commit]);
2461        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2462        let mut failed = vec![0usize];
2463        let mut successes = vec![];
2464        let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
2465        cmd.run_interactive_retry_twiddle_check(
2466            &mut failed,
2467            &repo_view,
2468            &client,
2469            None,
2470            &[],
2471            &mut successes,
2472            &mut stdin,
2473        )
2474        .await
2475        .unwrap();
2476        assert!(failed.is_empty());
2477        assert_eq!(successes.len(), 1);
2478    }
2479
2480    #[tokio::test]
2481    async fn interactive_retry_twiddle_still_fails_then_skip() {
2482        // "r" → retry fails → still in failed_indices → "s" → skip.
2483        let (commit, _tmp) = make_twiddle_commit("abc00000");
2484        let cmd = make_twiddle_cmd();
2485        let repo_view = make_twiddle_repo_view(vec![commit]);
2486        // Retry attempt hits max_retries=2 (3 total attempts).
2487        let responses = (0..3).map(|_| Err(anyhow::anyhow!("mock fail"))).collect();
2488        let client = make_mock_client(responses);
2489        let mut failed = vec![0usize];
2490        let mut successes = vec![];
2491        let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
2492        cmd.run_interactive_retry_twiddle_check(
2493            &mut failed,
2494            &repo_view,
2495            &client,
2496            None,
2497            &[],
2498            &mut successes,
2499            &mut stdin,
2500        )
2501        .await
2502        .unwrap();
2503        assert_eq!(failed, vec![0], "commit still failed after retry");
2504        assert!(successes.is_empty());
2505    }
2506
2507    #[tokio::test]
2508    async fn interactive_retry_twiddle_invalid_input_then_skip() {
2509        // Unrecognised input → "please enter r or s" message → "s" exits.
2510        let (commit, _tmp) = make_twiddle_commit("abc00000");
2511        let cmd = make_twiddle_cmd();
2512        let repo_view = make_twiddle_repo_view(vec![commit]);
2513        let client = make_mock_client(vec![]);
2514        let mut failed = vec![0usize];
2515        let mut successes = vec![];
2516        let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
2517        cmd.run_interactive_retry_twiddle_check(
2518            &mut failed,
2519            &repo_view,
2520            &client,
2521            None,
2522            &[],
2523            &mut successes,
2524            &mut stdin,
2525        )
2526        .await
2527        .unwrap();
2528        assert_eq!(failed, vec![0]);
2529        assert!(successes.is_empty());
2530    }
2531
2532    #[tokio::test]
2533    async fn interactive_retry_twiddle_eof_breaks_immediately() {
2534        // EOF (empty reader) → read_line returns Ok(0) → loop breaks without
2535        // calling the AI client. failed_indices stays unchanged.
2536        let (commit, _tmp) = make_twiddle_commit("abc00000");
2537        let cmd = make_twiddle_cmd();
2538        let repo_view = make_twiddle_repo_view(vec![commit]);
2539        let client = make_mock_client(vec![]); // no responses consumed
2540        let mut failed = vec![0usize];
2541        let mut successes = vec![];
2542        let mut stdin = std::io::Cursor::new(b"" as &[u8]);
2543        cmd.run_interactive_retry_twiddle_check(
2544            &mut failed,
2545            &repo_view,
2546            &client,
2547            None,
2548            &[],
2549            &mut successes,
2550            &mut stdin,
2551        )
2552        .await
2553        .unwrap();
2554        assert_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
2555        assert!(successes.is_empty());
2556    }
2557
2558    // --- handle_amendments_file ---
2559
2560    fn make_amendment_file() -> crate::data::amendments::AmendmentFile {
2561        crate::data::amendments::AmendmentFile {
2562            amendments: vec![crate::data::amendments::Amendment {
2563                commit: "abc0000000000000000000000000000000000001".to_string(),
2564                message: "feat: improved commit message".to_string(),
2565                summary: None,
2566            }],
2567        }
2568    }
2569
2570    #[test]
2571    fn handle_amendments_file_non_terminal_returns_false() {
2572        // is_terminal=false → non-interactive warning, returns Ok(false) immediately.
2573        let cmd = make_twiddle_cmd();
2574        let amendments = make_amendment_file();
2575        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2576        let mut reader = std::io::Cursor::new(b"" as &[u8]);
2577        let result = cmd
2578            .handle_amendments_file(dummy_path, &amendments, false, &mut reader)
2579            .unwrap();
2580        assert!(!result, "non-terminal should return false");
2581    }
2582
2583    #[test]
2584    fn handle_amendments_file_eof_returns_false() {
2585        // is_terminal=true, EOF reader → read_line returns 0, returns Ok(false).
2586        let cmd = make_twiddle_cmd();
2587        let amendments = make_amendment_file();
2588        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2589        let mut reader = std::io::Cursor::new(b"" as &[u8]);
2590        let result = cmd
2591            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2592            .unwrap();
2593        assert!(!result, "EOF should return false");
2594    }
2595
2596    #[test]
2597    fn handle_amendments_file_quit_returns_false() {
2598        // is_terminal=true, "q\n" → user quits, returns Ok(false).
2599        let cmd = make_twiddle_cmd();
2600        let amendments = make_amendment_file();
2601        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2602        let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
2603        let result = cmd
2604            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2605            .unwrap();
2606        assert!(!result, "quit should return false");
2607    }
2608
2609    #[test]
2610    fn handle_amendments_file_apply_returns_true() {
2611        // is_terminal=true, "a\n" → user applies, returns Ok(true).
2612        let cmd = make_twiddle_cmd();
2613        let amendments = make_amendment_file();
2614        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2615        let mut reader = std::io::Cursor::new(b"a\n" as &[u8]);
2616        let result = cmd
2617            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2618            .unwrap();
2619        assert!(result, "apply should return true");
2620    }
2621
2622    #[test]
2623    fn handle_amendments_file_invalid_then_quit_returns_false() {
2624        // is_terminal=true, invalid input then "q\n" → prints error, then user quits.
2625        let cmd = make_twiddle_cmd();
2626        let amendments = make_amendment_file();
2627        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2628        let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
2629        let result = cmd
2630            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2631            .unwrap();
2632        assert!(!result, "invalid then quit should return false");
2633    }
2634
2635    // --- run_interactive_retry_generate_amendments ---
2636
2637    /// Full 40-char hex hash used for amendment retry tests (validation requires ≥40 chars).
2638    const HASH_40: &str = "abc0000000000000000000000000000000000000";
2639
2640    fn twiddle_amendment_yaml(hash: &str) -> String {
2641        format!("amendments:\n  - commit: \"{hash}\"\n    message: \"feat: improved message\"\n")
2642    }
2643
2644    #[tokio::test]
2645    async fn retry_generate_amendments_non_terminal_returns_immediately() {
2646        // is_terminal=false → warning printed, returns Ok(()) without prompting.
2647        let (commit, _tmp) = make_twiddle_commit("abc00000");
2648        let cmd = make_twiddle_cmd();
2649        let repo_view = make_twiddle_repo_view(vec![commit]);
2650        let client = make_mock_client(vec![]); // no calls expected
2651        let mut failed = vec![0usize];
2652        let mut successes = vec![];
2653        let mut reader = std::io::Cursor::new(b"" as &[u8]);
2654        cmd.run_interactive_retry_generate_amendments(
2655            &mut failed,
2656            &repo_view,
2657            &client,
2658            None,
2659            false,
2660            &mut successes,
2661            false, // is_terminal
2662            &mut reader,
2663        )
2664        .await
2665        .unwrap();
2666        assert_eq!(
2667            failed,
2668            vec![0],
2669            "non-terminal should leave failed unchanged"
2670        );
2671        assert!(successes.is_empty());
2672    }
2673
2674    #[tokio::test]
2675    async fn retry_generate_amendments_eof_breaks_immediately() {
2676        // is_terminal=true, EOF → read_line returns 0 → breaks without AI calls.
2677        let (commit, _tmp) = make_twiddle_commit("abc00000");
2678        let cmd = make_twiddle_cmd();
2679        let repo_view = make_twiddle_repo_view(vec![commit]);
2680        let client = make_mock_client(vec![]); // no calls expected
2681        let mut failed = vec![0usize];
2682        let mut successes = vec![];
2683        let mut reader = std::io::Cursor::new(b"" as &[u8]);
2684        cmd.run_interactive_retry_generate_amendments(
2685            &mut failed,
2686            &repo_view,
2687            &client,
2688            None,
2689            false,
2690            &mut successes,
2691            true, // is_terminal
2692            &mut reader,
2693        )
2694        .await
2695        .unwrap();
2696        assert_eq!(failed, vec![0], "EOF should leave failed unchanged");
2697        assert!(successes.is_empty());
2698    }
2699
2700    #[tokio::test]
2701    async fn retry_generate_amendments_skip_breaks_immediately() {
2702        // is_terminal=true, "s\n" → user skips, failed stays unchanged.
2703        let (commit, _tmp) = make_twiddle_commit("abc00000");
2704        let cmd = make_twiddle_cmd();
2705        let repo_view = make_twiddle_repo_view(vec![commit]);
2706        let client = make_mock_client(vec![]); // no calls expected
2707        let mut failed = vec![0usize];
2708        let mut successes = vec![];
2709        let mut reader = std::io::Cursor::new(b"s\n" as &[u8]);
2710        cmd.run_interactive_retry_generate_amendments(
2711            &mut failed,
2712            &repo_view,
2713            &client,
2714            None,
2715            false,
2716            &mut successes,
2717            true,
2718            &mut reader,
2719        )
2720        .await
2721        .unwrap();
2722        assert_eq!(failed, vec![0], "skip should leave failed unchanged");
2723        assert!(successes.is_empty());
2724    }
2725
2726    #[tokio::test]
2727    async fn retry_generate_amendments_invalid_then_skip() {
2728        // Unrecognised input → "please enter r or s" message → "s" exits.
2729        let (commit, _tmp) = make_twiddle_commit("abc00000");
2730        let cmd = make_twiddle_cmd();
2731        let repo_view = make_twiddle_repo_view(vec![commit]);
2732        let client = make_mock_client(vec![]);
2733        let mut failed = vec![0usize];
2734        let mut successes = vec![];
2735        let mut reader = std::io::Cursor::new(b"x\ns\n" as &[u8]);
2736        cmd.run_interactive_retry_generate_amendments(
2737            &mut failed,
2738            &repo_view,
2739            &client,
2740            None,
2741            false,
2742            &mut successes,
2743            true,
2744            &mut reader,
2745        )
2746        .await
2747        .unwrap();
2748        assert_eq!(failed, vec![0]);
2749        assert!(successes.is_empty());
2750    }
2751
2752    #[tokio::test]
2753    async fn retry_generate_amendments_retry_fails_then_skip() {
2754        // "r" → AI call fails → still in failed → "s" → skips.
2755        let (commit, _tmp) = make_twiddle_commit("abc00000");
2756        let cmd = make_twiddle_cmd();
2757        let repo_view = make_twiddle_repo_view(vec![commit]);
2758        let client = make_mock_client(vec![Err(anyhow::anyhow!("mock fail"))]);
2759        let mut failed = vec![0usize];
2760        let mut successes = vec![];
2761        let mut reader = std::io::Cursor::new(b"r\ns\n" as &[u8]);
2762        cmd.run_interactive_retry_generate_amendments(
2763            &mut failed,
2764            &repo_view,
2765            &client,
2766            None,
2767            false,
2768            &mut successes,
2769            true,
2770            &mut reader,
2771        )
2772        .await
2773        .unwrap();
2774        assert_eq!(failed, vec![0], "commit still failed after retry");
2775        assert!(successes.is_empty());
2776    }
2777
2778    #[tokio::test]
2779    async fn retry_generate_amendments_retry_succeeds() {
2780        // "r" → AI returns valid amendment → failed cleared, success recorded.
2781        let (commit, _tmp) = make_twiddle_commit(HASH_40);
2782        let cmd = make_twiddle_cmd();
2783        let repo_view = make_twiddle_repo_view(vec![commit]);
2784        let client = make_mock_client(vec![Ok(twiddle_amendment_yaml(HASH_40))]);
2785        let mut failed = vec![0usize];
2786        let mut successes = vec![];
2787        let mut reader = std::io::Cursor::new(b"r\n" as &[u8]);
2788        cmd.run_interactive_retry_generate_amendments(
2789            &mut failed,
2790            &repo_view,
2791            &client,
2792            None,
2793            false,
2794            &mut successes,
2795            true,
2796            &mut reader,
2797        )
2798        .await
2799        .unwrap();
2800        assert!(failed.is_empty(), "retry succeeded → failed cleared");
2801        assert_eq!(successes.len(), 1);
2802    }
2803
2804    #[test]
2805    fn refine_amendment_scopes_replaces_scope_from_file_patterns() {
2806        use crate::data::amendments::Amendment;
2807        use crate::data::context::ScopeDefinition;
2808        use crate::git::commit::FileChange;
2809
2810        // Build a commit whose files match the "cli" scope pattern.
2811        let (mut commit, _tmp) = make_twiddle_commit("aaa00000");
2812        commit.analysis.file_changes.file_list = vec![FileChange {
2813            status: "M".to_string(),
2814            file: "src/cli/git/twiddle.rs".to_string(),
2815        }];
2816
2817        let repo_view = make_twiddle_repo_view(vec![commit]);
2818
2819        let scope_defs = vec![ScopeDefinition {
2820            name: "cli".to_string(),
2821            description: "CLI commands".to_string(),
2822            examples: vec![],
2823            file_patterns: vec!["src/cli/**".to_string()],
2824        }];
2825
2826        let mut amendments = AmendmentFile {
2827            amendments: vec![Amendment {
2828                commit: "aaa00000".to_string(),
2829                message: "fix(wrong-scope): tweak something".to_string(),
2830                summary: None,
2831            }],
2832        };
2833
2834        refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
2835
2836        assert_eq!(
2837            amendments.amendments[0].message,
2838            "fix(cli): tweak something",
2839        );
2840    }
2841
2842    #[test]
2843    fn refine_amendment_scopes_no_match_leaves_message_unchanged() {
2844        use crate::data::amendments::Amendment;
2845
2846        let (commit, _tmp) = make_twiddle_commit("bbb00000");
2847        let repo_view = make_twiddle_repo_view(vec![commit]);
2848
2849        let mut amendments = AmendmentFile {
2850            amendments: vec![Amendment {
2851                commit: "bbb00000".to_string(),
2852                message: "feat(stuff): add feature".to_string(),
2853                summary: None,
2854            }],
2855        };
2856
2857        // No scope defs → no refinement.
2858        refine_amendment_scopes(&mut amendments, &repo_view, &[]);
2859
2860        assert_eq!(amendments.amendments[0].message, "feat(stuff): add feature",);
2861    }
2862
2863    #[test]
2864    fn refine_amendment_scopes_skips_unknown_commits() {
2865        use crate::data::amendments::Amendment;
2866        use crate::data::context::ScopeDefinition;
2867
2868        let (commit, _tmp) = make_twiddle_commit("ccc00000");
2869        let repo_view = make_twiddle_repo_view(vec![commit]);
2870
2871        let scope_defs = vec![ScopeDefinition {
2872            name: "cli".to_string(),
2873            description: "CLI".to_string(),
2874            examples: vec![],
2875            file_patterns: vec!["src/cli/**".to_string()],
2876        }];
2877
2878        let mut amendments = AmendmentFile {
2879            amendments: vec![Amendment {
2880                commit: "unknown_hash".to_string(),
2881                message: "fix(wrong): something".to_string(),
2882                summary: None,
2883            }],
2884        };
2885
2886        refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
2887
2888        // Message unchanged because commit wasn't found in repo_view.
2889        assert_eq!(amendments.amendments[0].message, "fix(wrong): something",);
2890    }
2891}