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        // Initialize Claude client
122        let beta = self
123            .beta_header
124            .as_deref()
125            .map(parse_beta_header)
126            .transpose()?;
127        let claude_client =
128            crate::claude::create_default_claude_client(self.model.clone(), beta).await?;
129
130        self.execute_with_client(claude_client).await
131    }
132
133    /// Test-injectable inner core of [`Self::execute`].
134    ///
135    /// Caller is responsible for any preflight (AI credentials, clean
136    /// working directory) and for resolving the deprecated `--batch-size`
137    /// alias into `--concurrency`. The `--no-ai` branch is handled by the
138    /// outer [`Self::execute`] before this is reached.
139    pub(crate) async fn execute_with_client(
140        self,
141        claude_client: crate::claude::client::ClaudeClient,
142    ) -> Result<()> {
143        // Determine if contextual analysis should be used
144        let use_contextual = self.use_context && !self.no_context;
145
146        if use_contextual {
147            println!(
148                "🪄 Starting AI-powered commit message improvement with contextual intelligence..."
149            );
150        } else {
151            println!("🪄 Starting AI-powered commit message improvement...");
152        }
153
154        // 1. Generate repository view to get all commits
155        let mut full_repo_view = self.generate_repository_view().await?;
156
157        // 2. Use parallel map-reduce for multiple commits
158        if full_repo_view.commits.len() > 1 {
159            return self
160                .execute_with_map_reduce(use_contextual, full_repo_view, claude_client)
161                .await;
162        }
163
164        // 3. Collect contextual information (Phase 3)
165        let context = if use_contextual {
166            Some(self.collect_context(&full_repo_view).await?)
167        } else {
168            None
169        };
170
171        // Refine detected scopes using file_patterns from scope definitions
172        let scope_defs = match &context {
173            Some(ctx) => ctx.project.valid_scopes.clone(),
174            None => self.load_check_scopes(),
175        };
176        for commit in &mut full_repo_view.commits {
177            commit.analysis.refine_scope(&scope_defs);
178        }
179
180        // 4. Show context summary if available
181        if let Some(ref ctx) = context {
182            self.show_context_summary(ctx)?;
183        }
184
185        // Show model information
186        self.show_model_info_from_client(&claude_client)?;
187
188        // 6. Generate amendments via Claude API with context
189        if self.refine {
190            println!("🔄 Refine mode: using existing commit messages as starting point...");
191        }
192        if use_contextual && context.is_some() {
193            println!("🤖 Analyzing commits with enhanced contextual intelligence...");
194        } else {
195            println!("🤖 Analyzing commits with Claude AI...");
196        }
197
198        let mut amendments = if let Some(ctx) = context {
199            claude_client
200                .generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.is_fresh())
201                .await?
202        } else {
203            claude_client
204                .generate_amendments_with_options(&full_repo_view, self.is_fresh())
205                .await?
206        };
207
208        refine_amendment_scopes(&mut amendments, &full_repo_view, &scope_defs);
209        {
210            use std::io::IsTerminal;
211            resolve_duplicate_amendments(
212                &mut amendments,
213                self.auto_apply,
214                std::io::stdin().is_terminal(),
215                &mut std::io::BufReader::new(std::io::stdin()),
216            )?;
217        }
218
219        // 6. Handle different output modes
220        if let Some(save_path) = self.save_only {
221            amendments.save_to_file(save_path)?;
222            println!("💾 Amendments saved to file");
223            return Ok(());
224        }
225
226        // 7. Handle amendments
227        if !amendments.amendments.is_empty() {
228            // Create temporary file for amendments
229            let temp_dir = tempfile::tempdir()?;
230            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
231            amendments.save_to_file(&amendments_file)?;
232
233            // Show file path and get user choice
234            {
235                use std::io::IsTerminal;
236                if !self.auto_apply
237                    && !self.handle_amendments_file(
238                        &amendments_file,
239                        &amendments,
240                        std::io::stdin().is_terminal(),
241                        &mut std::io::BufReader::new(std::io::stdin()),
242                    )?
243                {
244                    println!("❌ Amendment cancelled by user");
245                    return Ok(());
246                }
247            }
248
249            // 8. Apply amendments (re-read from file to capture any user edits)
250            self.apply_amendments_from_file(&amendments_file).await?;
251            println!("✅ Commit messages improved successfully!");
252
253            // 9. Run post-twiddle check if --check flag is set
254            if self.check {
255                self.run_post_twiddle_check().await?;
256            }
257        } else {
258            println!("✨ No commits found to process!");
259        }
260
261        Ok(())
262    }
263
264    /// Executes the twiddle command with batched parallel map-reduce for multiple commits.
265    ///
266    /// Commits are grouped into token-budget-aware batches (map phase),
267    /// then an optional coherence pass refines results across all commits
268    /// (reduce phase). Coherence is skipped when all commits fit in a
269    /// single batch since the AI already saw them together.
270    async fn execute_with_map_reduce(
271        &self,
272        use_contextual: bool,
273        mut full_repo_view: crate::data::RepositoryView,
274        claude_client: crate::claude::client::ClaudeClient,
275    ) -> Result<()> {
276        use std::sync::atomic::{AtomicUsize, Ordering};
277        use std::sync::Arc;
278
279        use crate::claude::batch;
280        use crate::claude::token_budget;
281
282        let concurrency = self.concurrency;
283
284        // Show model information
285        self.show_model_info_from_client(&claude_client)?;
286
287        if self.refine {
288            println!("🔄 Refine mode: using existing commit messages as starting point...");
289        }
290
291        let total_commits = full_repo_view.commits.len();
292        println!(
293            "🔄 Processing {total_commits} commits in parallel (concurrency: {concurrency})..."
294        );
295
296        // Collect context once (shared across all commits)
297        let context = if use_contextual {
298            Some(self.collect_context(&full_repo_view).await?)
299        } else {
300            None
301        };
302
303        if let Some(ref ctx) = context {
304            self.show_context_summary(ctx)?;
305        }
306
307        // Refine scopes on all commits upfront
308        let scope_defs = match &context {
309            Some(ctx) => ctx.project.valid_scopes.clone(),
310            None => self.load_check_scopes(),
311        };
312        for commit in &mut full_repo_view.commits {
313            commit.analysis.refine_scope(&scope_defs);
314        }
315
316        // Plan batches based on token budget
317        let metadata = claude_client.get_ai_client_metadata();
318        let system_prompt_tokens = if let Some(ref ctx) = context {
319            let prompt_style = metadata.prompt_style();
320            let system_prompt =
321                crate::claude::prompts::generate_contextual_system_prompt_for_provider(
322                    ctx,
323                    prompt_style,
324                );
325            token_budget::estimate_tokens(&system_prompt)
326        } else {
327            token_budget::estimate_tokens(crate::claude::prompts::SYSTEM_PROMPT)
328        };
329        let batch_plan =
330            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
331
332        if batch_plan.batches.len() < total_commits {
333            println!(
334                "   📦 Grouped {} commits into {} batches by token budget",
335                total_commits,
336                batch_plan.batches.len()
337            );
338        }
339
340        // Map phase: process batches in parallel
341        let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
342        let completed = Arc::new(AtomicUsize::new(0));
343
344        let repo_ref = &full_repo_view;
345        let client_ref = &claude_client;
346        let context_ref = &context;
347        let fresh = self.is_fresh();
348
349        let futs: Vec<_> = batch_plan
350            .batches
351            .iter()
352            .map(|batch| {
353                let sem = semaphore.clone();
354                let completed = completed.clone();
355                let batch_indices = &batch.commit_indices;
356
357                async move {
358                    let _permit = sem
359                        .acquire()
360                        .await
361                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
362
363                    let batch_size = batch_indices.len();
364
365                    // Create view for this batch
366                    let batch_view = if batch_size == 1 {
367                        repo_ref.single_commit_view(&repo_ref.commits[batch_indices[0]])
368                    } else {
369                        let commits: Vec<_> = batch_indices
370                            .iter()
371                            .map(|&i| &repo_ref.commits[i])
372                            .collect();
373                        repo_ref.multi_commit_view(&commits)
374                    };
375
376                    // Generate amendments for the batch
377                    let result = if let Some(ref ctx) = context_ref {
378                        client_ref
379                            .generate_contextual_amendments_with_options(&batch_view, ctx, fresh)
380                            .await
381                    } else {
382                        client_ref
383                            .generate_amendments_with_options(&batch_view, fresh)
384                            .await
385                    };
386
387                    match result {
388                        Ok(amendment_file) => {
389                            let done =
390                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
391                            println!("   ✅ {done}/{total_commits} commits processed");
392
393                            let items: Vec<_> = amendment_file
394                                .amendments
395                                .into_iter()
396                                .map(|a| {
397                                    let summary = a.summary.clone();
398                                    (a, summary)
399                                })
400                                .collect();
401                            Ok::<_, anyhow::Error>((items, vec![]))
402                        }
403                        Err(e) if batch_size > 1 => {
404                            // Split-and-retry: fall back to individual commits
405                            eprintln!(
406                                "warning: batch of {batch_size} failed, retrying individually: {e}"
407                            );
408                            let mut items = Vec::new();
409                            let mut failed_indices = Vec::new();
410                            for &idx in batch_indices {
411                                let single_view =
412                                    repo_ref.single_commit_view(&repo_ref.commits[idx]);
413                                let single_result = if let Some(ref ctx) = context_ref {
414                                    client_ref
415                                        .generate_contextual_amendments_with_options(
416                                            &single_view,
417                                            ctx,
418                                            fresh,
419                                        )
420                                        .await
421                                } else {
422                                    client_ref
423                                        .generate_amendments_with_options(&single_view, fresh)
424                                        .await
425                                };
426                                match single_result {
427                                    Ok(af) => {
428                                        if let Some(a) = af.amendments.into_iter().next() {
429                                            let summary = a.summary.clone();
430                                            items.push((a, summary));
431                                        }
432                                        let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
433                                        println!("   ✅ {done}/{total_commits} commits processed");
434                                    }
435                                    Err(e) => {
436                                        eprintln!("warning: failed to process commit: {e}");
437                                        // Print the full error chain for debugging using anyhow's chain()
438                                        for (i, cause) in e.chain().skip(1).enumerate() {
439                                            eprintln!("  caused by [{i}]: {cause}");
440                                        }
441                                        failed_indices.push(idx);
442                                        println!("   ❌ commit processing failed");
443                                    }
444                                }
445                            }
446                            Ok((items, failed_indices))
447                        }
448                        Err(e) => {
449                            // Single-commit batch failed; record the index so the user can retry
450                            let idx = batch_indices[0];
451                            eprintln!("warning: failed to process commit: {e}");
452                            // Print the full error chain for debugging using anyhow's chain()
453                            for (i, cause) in e.chain().skip(1).enumerate() {
454                                eprintln!("  caused by [{i}]: {cause}");
455                            }
456                            let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
457                            println!("   ❌ {done}/{total_commits} commits processed (failed)");
458                            Ok((vec![], vec![idx]))
459                        }
460                    }
461                }
462            })
463            .collect();
464
465        let results = futures::future::join_all(futs).await;
466
467        // Flatten batch results
468        let mut successes: Vec<(crate::data::amendments::Amendment, String)> = Vec::new();
469        let mut failed_indices: Vec<usize> = Vec::new();
470
471        for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
472            match result {
473                Ok((items, failed)) => {
474                    successes.extend(items);
475                    failed_indices.extend(failed);
476                }
477                Err(e) => {
478                    eprintln!("warning: batch processing error: {e}");
479                    failed_indices.extend(&batch.commit_indices);
480                }
481            }
482        }
483
484        // Offer interactive retry for commits that failed
485        if !failed_indices.is_empty() && !self.quiet {
486            use std::io::IsTerminal;
487            self.run_interactive_retry_generate_amendments(
488                &mut failed_indices,
489                &full_repo_view,
490                &claude_client,
491                context.as_ref(),
492                fresh,
493                &mut successes,
494                std::io::stdin().is_terminal(),
495                &mut std::io::BufReader::new(std::io::stdin()),
496            )
497            .await?;
498        } else if !failed_indices.is_empty() {
499            eprintln!(
500                "warning: {} commit(s) failed to process",
501                failed_indices.len()
502            );
503        }
504
505        if !failed_indices.is_empty() {
506            eprintln!(
507                "warning: {} commit(s) ultimately failed to process",
508                failed_indices.len()
509            );
510        }
511
512        if successes.is_empty() {
513            anyhow::bail!("All commits failed to process");
514        }
515
516        // Reduce phase: optional coherence pass
517        // Skip when all commits were in a single batch (AI already saw them together)
518        let single_batch = batch_plan.batches.len() <= 1;
519        let mut all_amendments = if !self.no_coherence && !single_batch && successes.len() >= 2 {
520            println!("🔗 Running cross-commit coherence pass...");
521            match claude_client.refine_amendments_coherence(&successes).await {
522                Ok(refined) => refined,
523                Err(e) => {
524                    eprintln!("warning: coherence pass failed, using individual results: {e}");
525                    AmendmentFile {
526                        amendments: successes.into_iter().map(|(a, _)| a).collect(),
527                    }
528                }
529            }
530        } else {
531            AmendmentFile {
532                amendments: successes.into_iter().map(|(a, _)| a).collect(),
533            }
534        };
535
536        refine_amendment_scopes(&mut all_amendments, &full_repo_view, &scope_defs);
537        {
538            use std::io::IsTerminal;
539            resolve_duplicate_amendments(
540                &mut all_amendments,
541                self.auto_apply,
542                std::io::stdin().is_terminal(),
543                &mut std::io::BufReader::new(std::io::stdin()),
544            )?;
545        }
546
547        println!(
548            "✅ All commits processed! Found {} amendments.",
549            all_amendments.amendments.len()
550        );
551
552        // Handle different output modes
553        if let Some(save_path) = &self.save_only {
554            all_amendments.save_to_file(save_path)?;
555            println!("💾 Amendments saved to file");
556            return Ok(());
557        }
558
559        // Handle amendments
560        if !all_amendments.amendments.is_empty() {
561            let temp_dir = tempfile::tempdir()?;
562            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
563            all_amendments.save_to_file(&amendments_file)?;
564
565            {
566                use std::io::IsTerminal;
567                if !self.auto_apply
568                    && !self.handle_amendments_file(
569                        &amendments_file,
570                        &all_amendments,
571                        std::io::stdin().is_terminal(),
572                        &mut std::io::BufReader::new(std::io::stdin()),
573                    )?
574                {
575                    println!("❌ Amendment cancelled by user");
576                    return Ok(());
577                }
578            }
579
580            self.apply_amendments_from_file(&amendments_file).await?;
581            println!("✅ Commit messages improved successfully!");
582
583            if self.check {
584                self.run_post_twiddle_check().await?;
585            }
586        } else {
587            println!("✨ No commits found to process!");
588        }
589
590        Ok(())
591    }
592
593    /// Generates the repository view (reuses ViewCommand logic).
594    async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
595        use crate::data::{
596            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
597            WorkingDirectoryInfo,
598        };
599        use crate::git::{GitRepository, RemoteInfo};
600        use crate::utils::ai_scratch;
601
602        let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
603
604        // Open git repository
605        let repo = GitRepository::open()
606            .context("Failed to open git repository. Make sure you're in a git repository.")?;
607
608        // Get current branch name
609        let current_branch = repo
610            .get_current_branch()
611            .unwrap_or_else(|_| "HEAD".to_string());
612
613        // Get working directory status
614        let wd_status = repo.get_working_directory_status()?;
615        let working_directory = WorkingDirectoryInfo {
616            clean: wd_status.clean,
617            untracked_changes: wd_status
618                .untracked_changes
619                .into_iter()
620                .map(|fs| FileStatusInfo {
621                    status: fs.status,
622                    file: fs.file,
623                })
624                .collect(),
625        };
626
627        // Get remote information
628        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
629
630        // Parse commit range and get commits
631        let commits = repo.get_commits_in_range(commit_range)?;
632
633        // Create version information
634        let versions = Some(VersionInfo {
635            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
636        });
637
638        // Get AI scratch directory
639        let ai_scratch_path =
640            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
641        let ai_info = AiInfo {
642            scratch: ai_scratch_path.to_string_lossy().to_string(),
643        };
644
645        // Build repository view with branch info
646        let mut repo_view = RepositoryView {
647            versions,
648            explanation: FieldExplanation::default(),
649            working_directory,
650            remotes,
651            ai: ai_info,
652            branch_info: Some(BranchInfo {
653                branch: current_branch,
654            }),
655            pr_template: None,
656            pr_template_location: None,
657            branch_prs: None,
658            commits,
659        };
660
661        // Update field presence based on actual data
662        repo_view.update_field_presence();
663
664        Ok(repo_view)
665    }
666
667    /// Handles the amendments file by showing the path and getting the user choice.
668    ///
669    /// `is_terminal` and `reader` are injected so tests can drive the function
670    /// without blocking on real stdin.
671    fn handle_amendments_file(
672        &self,
673        amendments_file: &std::path::Path,
674        amendments: &crate::data::amendments::AmendmentFile,
675        is_terminal: bool,
676        reader: &mut (dyn std::io::BufRead + Send),
677    ) -> Result<bool> {
678        use std::io::{self, Write};
679
680        println!(
681            "\n📝 Found {} commits that could be improved.",
682            amendments.amendments.len()
683        );
684        println!("💾 Amendments saved to: {}", amendments_file.display());
685        println!();
686
687        if !is_terminal {
688            eprintln!("warning: stdin is not interactive, cannot prompt for amendments");
689            return Ok(false);
690        }
691
692        loop {
693            print!("❓ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
694            io::stdout().flush()?;
695
696            let Some(input) = super::read_interactive_line(reader)? else {
697                eprintln!("warning: stdin closed, cancelling amendments");
698                return Ok(false);
699            };
700
701            match input.trim().to_lowercase().as_str() {
702                "a" | "apply" | "" => return Ok(true),
703                "s" | "show" => {
704                    self.show_amendments_file(amendments_file)?;
705                    println!();
706                }
707                "e" | "edit" => {
708                    self.edit_amendments_file(amendments_file)?;
709                    println!();
710                }
711                "q" | "quit" => return Ok(false),
712                _ => {
713                    println!(
714                        "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
715                    );
716                }
717            }
718        }
719    }
720
721    /// Shows the contents of the amendments file.
722    fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
723        use std::fs;
724
725        println!("\n📄 Amendments file contents:");
726        println!("─────────────────────────────");
727
728        let contents =
729            fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
730
731        println!("{contents}");
732        println!("─────────────────────────────");
733
734        Ok(())
735    }
736
737    /// Opens the amendments file in an external editor.
738    fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
739        use std::env;
740        use std::io::{self, Write};
741        use std::process::Command;
742
743        // Try to get editor from environment variables
744        let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
745            e
746        } else {
747            // Prompt user for editor if neither environment variable is set
748            println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
749            print!("Please enter the command to use as your editor: ");
750            io::stdout().flush().context("Failed to flush stdout")?;
751
752            let mut input = String::new();
753            io::stdin()
754                .read_line(&mut input)
755                .context("Failed to read user input")?;
756            input.trim().to_string()
757        };
758
759        if editor.is_empty() {
760            println!("❌ No editor specified. Returning to menu.");
761            return Ok(());
762        }
763
764        println!("📝 Opening amendments file in editor: {editor}");
765
766        let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
767
768        let mut command = Command::new(editor_cmd);
769        command.args(args);
770        command.arg(amendments_file.to_string_lossy().as_ref());
771
772        match command.status() {
773            Ok(status) => {
774                if status.success() {
775                    println!("✅ Editor session completed.");
776                } else {
777                    println!(
778                        "⚠️  Editor exited with non-zero status: {:?}",
779                        status.code()
780                    );
781                }
782            }
783            Err(e) => {
784                println!("❌ Failed to execute editor '{editor}': {e}");
785                println!("   Please check that the editor command is correct and available in your PATH.");
786            }
787        }
788
789        Ok(())
790    }
791
792    /// Applies amendments from a file path (re-reads from disk to capture user edits).
793    async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
794        use crate::git::AmendmentHandler;
795
796        // Use AmendmentHandler to apply amendments directly from file
797        let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
798        handler
799            .apply_amendments(&amendments_file.to_string_lossy())
800            .context("Failed to apply amendments")?;
801
802        Ok(())
803    }
804
805    /// Collects contextual information for enhanced commit message generation.
806    async fn collect_context(
807        &self,
808        repo_view: &crate::data::RepositoryView,
809    ) -> Result<crate::data::context::CommitContext> {
810        use crate::claude::context::{
811            BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
812        };
813        use crate::data::context::CommitContext;
814
815        let mut context = CommitContext::new();
816
817        // 1. Discover project context
818        let (context_dir, dir_source) =
819            crate::claude::context::resolve_context_dir_with_source(self.context_dir.as_deref());
820
821        // ProjectDiscovery takes repo root and context directory
822        let repo_root = std::path::PathBuf::from(".");
823        let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
824        debug!(context_dir = ?context_dir, "Using context directory");
825        match discovery.discover() {
826            Ok(project_context) => {
827                debug!("Discovery successful");
828
829                // Show diagnostic information about loaded guidance files
830                self.show_guidance_files_status(&project_context, &context_dir, &dir_source)?;
831
832                context.project = project_context;
833            }
834            Err(e) => {
835                debug!(error = %e, "Discovery failed");
836                context.project = crate::data::context::ProjectContext::default();
837            }
838        }
839
840        // 2. Analyze current branch from repository view
841        if let Some(branch_info) = &repo_view.branch_info {
842            context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
843        } else {
844            // Fallback to getting current branch directly if not in repo view
845            use crate::git::GitRepository;
846            let repo = GitRepository::open()?;
847            let current_branch = repo
848                .get_current_branch()
849                .unwrap_or_else(|_| "HEAD".to_string());
850            context.branch = BranchAnalyzer::analyze(&current_branch).unwrap_or_default();
851        }
852
853        // 3. Analyze commit range patterns
854        if !repo_view.commits.is_empty() {
855            context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
856        }
857
858        // 3.5. Analyze file-level context
859        if !repo_view.commits.is_empty() {
860            context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
861        }
862
863        // 4. Apply user-provided context overrides
864        if let Some(ref work_ctx) = self.work_context {
865            context.user_provided = Some(work_ctx.clone());
866        }
867
868        if let Some(ref branch_ctx) = self.branch_context {
869            context.branch.description.clone_from(branch_ctx);
870        }
871
872        Ok(context)
873    }
874
875    /// Shows the context summary to the user.
876    fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
877        println!("🔍 Context Analysis:");
878
879        // Project context
880        if !context.project.valid_scopes.is_empty() {
881            println!(
882                "   📁 Valid scopes: {}",
883                format_scope_list(&context.project.valid_scopes)
884            );
885        }
886
887        // Branch context
888        if context.branch.is_feature_branch {
889            println!(
890                "   🌿 Branch: {} ({})",
891                context.branch.description, context.branch.work_type
892            );
893            if let Some(ref ticket) = context.branch.ticket_id {
894                println!("   🎫 Ticket: {ticket}");
895            }
896        }
897
898        // Work pattern
899        if let Some(label) = format_work_pattern(&context.range.work_pattern) {
900            println!("   {label}");
901        }
902
903        // File analysis
904        if let Some(label) = super::formatting::format_file_analysis(&context.files) {
905            println!("   {label}");
906        }
907
908        // Verbosity level
909        println!(
910            "   {}",
911            format_verbosity_level(context.suggested_verbosity())
912        );
913
914        // User context
915        if let Some(ref user_ctx) = context.user_provided {
916            println!("   👤 User context: {user_ctx}");
917        }
918
919        println!();
920        Ok(())
921    }
922
923    /// Shows model information from the actual AI client.
924    fn show_model_info_from_client(
925        &self,
926        client: &crate::claude::client::ClaudeClient,
927    ) -> Result<()> {
928        use crate::claude::model_config::get_model_registry;
929
930        println!("🤖 AI Model Configuration:");
931
932        // Get actual metadata from the client
933        let metadata = client.get_ai_client_metadata();
934        let registry = get_model_registry();
935
936        if let Some(spec) = registry.get_model_spec(&metadata.model) {
937            // Highlight the API identifier portion in yellow
938            if metadata.model != spec.api_identifier {
939                println!(
940                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
941                    metadata.model, spec.api_identifier
942                );
943            } else {
944                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
945            }
946
947            println!("   🏷️  Provider: {}", spec.provider);
948            println!("   📊 Generation: {}", spec.generation);
949            println!("   ⭐ Tier: {} ({})", spec.tier, {
950                if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
951                    &tier_info.description
952                } else {
953                    "No description available"
954                }
955            });
956            println!("   📤 Max output tokens: {}", metadata.max_response_length);
957            println!("   📥 Input context: {}", metadata.max_context_length);
958
959            if let Some((ref key, ref value)) = metadata.active_beta {
960                println!("   🔬 Beta header: {key}: {value}");
961            }
962
963            if spec.legacy {
964                println!("   ⚠️  Legacy model (consider upgrading to newer version)");
965            }
966        } else {
967            // Fallback to client metadata if not in registry
968            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
969            println!("   🏷️  Provider: {}", metadata.provider);
970            println!("   ⚠️  Model not found in registry, using client metadata:");
971            println!("   📤 Max output tokens: {}", metadata.max_response_length);
972            println!("   📥 Input context: {}", metadata.max_context_length);
973        }
974
975        println!();
976        Ok(())
977    }
978
979    /// Shows diagnostic information about loaded guidance files.
980    fn show_guidance_files_status(
981        &self,
982        project_context: &crate::data::context::ProjectContext,
983        context_dir: &std::path::Path,
984        dir_source: &crate::claude::context::ConfigDirSource,
985    ) -> Result<()> {
986        use crate::claude::context::{config_source_label, ConfigSourceLabel};
987
988        println!("📋 Project guidance files status:");
989        println!("   📂 Config dir: {} ({dir_source})", context_dir.display());
990
991        // Check commit guidelines
992        let guidelines_source = if project_context.commit_guidelines.is_some() {
993            match config_source_label(context_dir, "commit-guidelines.md") {
994                ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
995                label => format!("✅ {label}"),
996            }
997        } else {
998            "❌ None found".to_string()
999        };
1000        println!("   📝 Commit guidelines: {guidelines_source}");
1001
1002        // Check scopes
1003        let scopes_count = project_context.valid_scopes.len();
1004        let scopes_source = if scopes_count > 0 {
1005            match config_source_label(context_dir, "scopes.yaml") {
1006                ConfigSourceLabel::NotFound => {
1007                    format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
1008                }
1009                label => format!("✅ {label} ({scopes_count} scopes)"),
1010            }
1011        } else {
1012            "❌ None found".to_string()
1013        };
1014        println!("   🎯 Valid scopes: {scopes_source}");
1015
1016        println!();
1017        Ok(())
1018    }
1019
1020    /// Executes the twiddle command without AI, creating amendments with original messages.
1021    async fn execute_no_ai(&self) -> Result<()> {
1022        use crate::data::amendments::{Amendment, AmendmentFile};
1023
1024        println!("📋 Generating amendments YAML without AI processing...");
1025
1026        // Generate repository view to get all commits
1027        let repo_view = self.generate_repository_view().await?;
1028
1029        // Create amendments with original commit messages (no AI improvements)
1030        let amendments: Vec<Amendment> = repo_view
1031            .commits
1032            .iter()
1033            .map(|commit| Amendment {
1034                commit: commit.hash.clone(),
1035                message: commit.original_message.clone(),
1036                summary: String::new(),
1037            })
1038            .collect();
1039
1040        let amendment_file = AmendmentFile { amendments };
1041
1042        // Handle different output modes
1043        if let Some(save_path) = &self.save_only {
1044            amendment_file.save_to_file(save_path)?;
1045            println!("💾 Amendments saved to file");
1046            return Ok(());
1047        }
1048
1049        // Handle amendments using the same flow as the AI-powered version
1050        if !amendment_file.amendments.is_empty() {
1051            // Create temporary file for amendments
1052            let temp_dir = tempfile::tempdir()?;
1053            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1054            amendment_file.save_to_file(&amendments_file)?;
1055
1056            // Show file path and get user choice
1057            {
1058                use std::io::IsTerminal;
1059                if !self.auto_apply
1060                    && !self.handle_amendments_file(
1061                        &amendments_file,
1062                        &amendment_file,
1063                        std::io::stdin().is_terminal(),
1064                        &mut std::io::BufReader::new(std::io::stdin()),
1065                    )?
1066                {
1067                    println!("❌ Amendment cancelled by user");
1068                    return Ok(());
1069                }
1070            }
1071
1072            // Apply amendments (re-read from file to capture any user edits)
1073            self.apply_amendments_from_file(&amendments_file).await?;
1074            println!("✅ Commit messages applied successfully!");
1075
1076            // Run post-twiddle check if --check flag is set
1077            if self.check {
1078                self.run_post_twiddle_check().await?;
1079            }
1080        } else {
1081            println!("✨ No commits found to process!");
1082        }
1083
1084        Ok(())
1085    }
1086
1087    /// Runs commit message validation after twiddle amendments are applied.
1088    /// If the check finds errors with suggestions, automatically applies the
1089    /// suggestions and re-checks, up to 3 retries.
1090    async fn run_post_twiddle_check(&self) -> Result<()> {
1091        const MAX_CHECK_RETRIES: u32 = 3;
1092
1093        // Load guidelines, scopes, and Claude client once (they don't change between retries)
1094        let guidelines = self.load_check_guidelines()?;
1095        let valid_scopes = self.load_check_scopes();
1096        let beta = self
1097            .beta_header
1098            .as_deref()
1099            .map(parse_beta_header)
1100            .transpose()?;
1101        let claude_client =
1102            crate::claude::create_default_claude_client(self.model.clone(), beta).await?;
1103
1104        for attempt in 0..=MAX_CHECK_RETRIES {
1105            println!();
1106            if attempt == 0 {
1107                println!("🔍 Running commit message validation...");
1108            } else {
1109                println!("🔍 Re-checking commit messages (retry {attempt}/{MAX_CHECK_RETRIES})...");
1110            }
1111
1112            // Generate fresh repository view to get updated commit messages
1113            let mut repo_view = self.generate_repository_view().await?;
1114
1115            if repo_view.commits.is_empty() {
1116                println!("⚠️  No commits to check");
1117                return Ok(());
1118            }
1119
1120            println!("📊 Checking {} commits", repo_view.commits.len());
1121
1122            // Refine detected scopes using file_patterns from scope definitions
1123            for commit in &mut repo_view.commits {
1124                commit.analysis.refine_scope(&valid_scopes);
1125            }
1126
1127            if attempt == 0 {
1128                self.show_check_guidance_files_status(&guidelines, &valid_scopes);
1129            }
1130
1131            // Run check
1132            let report = if repo_view.commits.len() > 1 {
1133                println!(
1134                    "🔄 Checking {} commits in parallel...",
1135                    repo_view.commits.len()
1136                );
1137                self.check_commits_map_reduce(
1138                    &claude_client,
1139                    &repo_view,
1140                    guidelines.as_deref(),
1141                    &valid_scopes,
1142                )
1143                .await?
1144            } else {
1145                println!("🤖 Analyzing commits with AI...");
1146                claude_client
1147                    .check_commits_with_scopes(
1148                        &repo_view,
1149                        guidelines.as_deref(),
1150                        &valid_scopes,
1151                        true,
1152                    )
1153                    .await?
1154            };
1155
1156            // Output text report
1157            self.output_check_text_report(&report)?;
1158
1159            // If no errors, we're done
1160            if !report.has_errors() {
1161                if report.has_warnings() {
1162                    println!("ℹ️  Some commit messages have minor warnings");
1163                } else {
1164                    println!("✅ All commit messages pass validation");
1165                }
1166                return Ok(());
1167            }
1168
1169            // If we've exhausted retries, report and stop
1170            if attempt == MAX_CHECK_RETRIES {
1171                println!(
1172                    "⚠️  Some commit messages still have issues after {MAX_CHECK_RETRIES} retries"
1173                );
1174                return Ok(());
1175            }
1176
1177            // Build amendments from suggestions for failing commits
1178            let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
1179
1180            if amendments.is_empty() {
1181                println!(
1182                    "⚠️  Some commit messages have issues but no suggestions available to retry"
1183                );
1184                return Ok(());
1185            }
1186
1187            // Apply the suggested amendments
1188            println!(
1189                "🔄 Applying {} suggested fix(es) and re-checking...",
1190                amendments.len()
1191            );
1192            let amendment_file = AmendmentFile { amendments };
1193            let temp_file = tempfile::NamedTempFile::new()
1194                .context("Failed to create temp file for retry amendments")?;
1195            amendment_file
1196                .save_to_file(temp_file.path())
1197                .context("Failed to save retry amendments")?;
1198            self.apply_amendments_from_file(temp_file.path()).await?;
1199        }
1200
1201        Ok(())
1202    }
1203
1204    /// Builds amendments from check report suggestions for failing commits.
1205    /// Resolves short hashes from the AI response to full 40-char hashes
1206    /// from the repository view.
1207    fn build_amendments_from_suggestions(
1208        &self,
1209        report: &crate::data::check::CheckReport,
1210        repo_view: &crate::data::RepositoryView,
1211    ) -> Vec<crate::data::amendments::Amendment> {
1212        use crate::data::amendments::Amendment;
1213
1214        let candidate_hashes: Vec<String> =
1215            repo_view.commits.iter().map(|c| c.hash.clone()).collect();
1216
1217        report
1218            .commits
1219            .iter()
1220            .filter(|r| !r.passes)
1221            .filter_map(|r| {
1222                let suggestion = r.suggestion.as_ref()?;
1223                let full_hash = super::formatting::resolve_short_hash(&r.hash, &candidate_hashes)?;
1224                Some(Amendment::new(
1225                    full_hash.to_string(),
1226                    suggestion.message.clone(),
1227                ))
1228            })
1229            .collect()
1230    }
1231
1232    /// Loads commit guidelines for check via the standard resolution chain.
1233    fn load_check_guidelines(&self) -> Result<Option<String>> {
1234        let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
1235        crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")
1236    }
1237
1238    /// Loads valid scopes for check with ecosystem defaults.
1239    fn load_check_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
1240        let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
1241        crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."))
1242    }
1243
1244    /// Shows guidance files status for check.
1245    fn show_check_guidance_files_status(
1246        &self,
1247        guidelines: &Option<String>,
1248        valid_scopes: &[crate::data::context::ScopeDefinition],
1249    ) {
1250        use crate::claude::context::{
1251            config_source_label, resolve_context_dir_with_source, ConfigSourceLabel,
1252        };
1253
1254        let (context_dir, dir_source) =
1255            resolve_context_dir_with_source(self.context_dir.as_deref());
1256
1257        println!("📋 Project guidance files status:");
1258        println!("   📂 Config dir: {} ({dir_source})", context_dir.display());
1259
1260        // Check commit guidelines
1261        let guidelines_source = if guidelines.is_some() {
1262            match config_source_label(&context_dir, "commit-guidelines.md") {
1263                ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
1264                label => format!("✅ {label}"),
1265            }
1266        } else {
1267            "⚪ Using defaults".to_string()
1268        };
1269        println!("   📝 Commit guidelines: {guidelines_source}");
1270
1271        // Check scopes
1272        let scopes_count = valid_scopes.len();
1273        let scopes_source = if scopes_count > 0 {
1274            match config_source_label(&context_dir, "scopes.yaml") {
1275                ConfigSourceLabel::NotFound => {
1276                    format!("✅ (source unknown) ({scopes_count} scopes)")
1277                }
1278                label => format!("✅ {label} ({scopes_count} scopes)"),
1279            }
1280        } else {
1281            "⚪ None found (any scope accepted)".to_string()
1282        };
1283        println!("   🎯 Valid scopes: {scopes_source}");
1284
1285        println!();
1286    }
1287
1288    /// Checks commits using batched parallel map-reduce.
1289    async fn check_commits_map_reduce(
1290        &self,
1291        claude_client: &crate::claude::client::ClaudeClient,
1292        full_repo_view: &crate::data::RepositoryView,
1293        guidelines: Option<&str>,
1294        valid_scopes: &[crate::data::context::ScopeDefinition],
1295    ) -> Result<crate::data::check::CheckReport> {
1296        use std::sync::atomic::{AtomicUsize, Ordering};
1297        use std::sync::Arc;
1298
1299        use crate::claude::batch;
1300        use crate::claude::token_budget;
1301        use crate::data::check::{CheckReport, CommitCheckResult};
1302
1303        let total_commits = full_repo_view.commits.len();
1304
1305        // Plan batches based on token budget
1306        let metadata = claude_client.get_ai_client_metadata();
1307        let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
1308            guidelines,
1309            valid_scopes,
1310        );
1311        let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
1312        let batch_plan =
1313            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
1314
1315        if batch_plan.batches.len() < total_commits {
1316            println!(
1317                "   📦 Grouped {} commits into {} batches by token budget",
1318                total_commits,
1319                batch_plan.batches.len()
1320            );
1321        }
1322
1323        let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
1324        let completed = Arc::new(AtomicUsize::new(0));
1325
1326        let futs: Vec<_> = batch_plan
1327            .batches
1328            .iter()
1329            .map(|batch| {
1330                let sem = semaphore.clone();
1331                let completed = completed.clone();
1332                let batch_indices = &batch.commit_indices;
1333
1334                async move {
1335                    let _permit = sem
1336                        .acquire()
1337                        .await
1338                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
1339
1340                    let batch_size = batch_indices.len();
1341
1342                    let batch_view = if batch_size == 1 {
1343                        full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
1344                    } else {
1345                        let commits: Vec<_> = batch_indices
1346                            .iter()
1347                            .map(|&i| &full_repo_view.commits[i])
1348                            .collect();
1349                        full_repo_view.multi_commit_view(&commits)
1350                    };
1351
1352                    let result = claude_client
1353                        .check_commits_with_scopes(&batch_view, guidelines, valid_scopes, true)
1354                        .await;
1355
1356                    match result {
1357                        Ok(report) => {
1358                            let done =
1359                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
1360                            println!("   ✅ {done}/{total_commits} commits checked");
1361
1362                            let items: Vec<_> = report
1363                                .commits
1364                                .into_iter()
1365                                .map(|r| {
1366                                    let summary = r.summary.clone().unwrap_or_default();
1367                                    (r, summary)
1368                                })
1369                                .collect();
1370                            Ok::<_, anyhow::Error>((items, vec![]))
1371                        }
1372                        Err(e) if batch_size > 1 => {
1373                            eprintln!(
1374                                "warning: batch of {batch_size} failed, retrying individually: {e}"
1375                            );
1376                            let mut items = Vec::new();
1377                            let mut failed_indices = Vec::new();
1378                            for &idx in batch_indices {
1379                                let single_view =
1380                                    full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1381                                let single_result = claude_client
1382                                    .check_commits_with_scopes(
1383                                        &single_view,
1384                                        guidelines,
1385                                        valid_scopes,
1386                                        true,
1387                                    )
1388                                    .await;
1389                                match single_result {
1390                                    Ok(report) => {
1391                                        if let Some(r) = report.commits.into_iter().next() {
1392                                            let summary = r.summary.clone().unwrap_or_default();
1393                                            items.push((r, summary));
1394                                        }
1395                                        let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1396                                        println!("   ✅ {done}/{total_commits} commits checked");
1397                                    }
1398                                    Err(e) => {
1399                                        eprintln!("warning: failed to check commit: {e}");
1400                                        failed_indices.push(idx);
1401                                        println!("   ❌ commit check failed");
1402                                    }
1403                                }
1404                            }
1405                            Ok((items, failed_indices))
1406                        }
1407                        Err(e) => {
1408                            // Single-commit batch failed; record the index so the user can retry
1409                            let idx = batch_indices[0];
1410                            eprintln!("warning: failed to check commit: {e}");
1411                            let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1412                            println!("   ❌ {done}/{total_commits} commits checked (failed)");
1413                            Ok((vec![], vec![idx]))
1414                        }
1415                    }
1416                }
1417            })
1418            .collect();
1419
1420        let results = futures::future::join_all(futs).await;
1421
1422        let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
1423        let mut failed_indices: Vec<usize> = Vec::new();
1424
1425        for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
1426            match result {
1427                Ok((items, failed)) => {
1428                    successes.extend(items);
1429                    failed_indices.extend(failed);
1430                }
1431                Err(e) => {
1432                    eprintln!("warning: batch processing error: {e}");
1433                    failed_indices.extend(&batch.commit_indices);
1434                }
1435            }
1436        }
1437
1438        // Offer interactive retry for commits that failed
1439        if !failed_indices.is_empty() && !self.quiet {
1440            use std::io::IsTerminal;
1441            if std::io::stdin().is_terminal() {
1442                self.run_interactive_retry_twiddle_check(
1443                    &mut failed_indices,
1444                    full_repo_view,
1445                    claude_client,
1446                    guidelines,
1447                    valid_scopes,
1448                    &mut successes,
1449                    &mut std::io::BufReader::new(std::io::stdin()),
1450                )
1451                .await?;
1452            } else {
1453                eprintln!(
1454                    "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1455                    failed_indices.len()
1456                );
1457            }
1458        } else if !failed_indices.is_empty() {
1459            eprintln!(
1460                "warning: {} commit(s) failed to check",
1461                failed_indices.len()
1462            );
1463        }
1464
1465        if !failed_indices.is_empty() {
1466            eprintln!(
1467                "warning: {} commit(s) ultimately failed to check",
1468                failed_indices.len()
1469            );
1470        }
1471
1472        if successes.is_empty() {
1473            anyhow::bail!("All commits failed to check");
1474        }
1475
1476        // Coherence pass: skip when all commits were in a single batch
1477        let single_batch = batch_plan.batches.len() <= 1;
1478        if !self.no_coherence && !single_batch && successes.len() >= 2 {
1479            println!("🔗 Running cross-commit coherence pass...");
1480            match claude_client
1481                .refine_checks_coherence(&successes, full_repo_view)
1482                .await
1483            {
1484                Ok(refined) => return Ok(refined),
1485                Err(e) => {
1486                    eprintln!("warning: coherence pass failed, using individual results: {e}");
1487                }
1488            }
1489        }
1490
1491        Ok(CheckReport::new(
1492            successes.into_iter().map(|(r, _)| r).collect(),
1493        ))
1494    }
1495
1496    /// Prompts the user to retry or skip failed commits, updating `failed_indices` and `successes`.
1497    ///
1498    /// Accepts `reader` for stdin injection so the interactive loop can be unit-tested.
1499    #[allow(clippy::too_many_arguments)]
1500    async fn run_interactive_retry_twiddle_check(
1501        &self,
1502        failed_indices: &mut Vec<usize>,
1503        full_repo_view: &crate::data::RepositoryView,
1504        claude_client: &crate::claude::client::ClaudeClient,
1505        guidelines: Option<&str>,
1506        valid_scopes: &[crate::data::context::ScopeDefinition],
1507        successes: &mut Vec<(crate::data::check::CommitCheckResult, String)>,
1508        reader: &mut (dyn std::io::BufRead + Send),
1509    ) -> Result<()> {
1510        use std::io::Write as _;
1511        println!("\n⚠️  {} commit(s) failed to check:", failed_indices.len());
1512        for &idx in failed_indices.iter() {
1513            let commit = &full_repo_view.commits[idx];
1514            let subject = commit
1515                .original_message
1516                .lines()
1517                .next()
1518                .unwrap_or("(no message)");
1519            println!("  - {}: {}", &commit.hash[..8], subject);
1520        }
1521        loop {
1522            print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1523            std::io::stdout().flush()?;
1524            let Some(input) = super::read_interactive_line(reader)? else {
1525                eprintln!("warning: stdin closed, skipping failed commit(s)");
1526                break;
1527            };
1528            match input.trim().to_lowercase().as_str() {
1529                "r" | "retry" | "" => {
1530                    let mut still_failed = Vec::new();
1531                    for &idx in failed_indices.iter() {
1532                        let single_view =
1533                            full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1534                        match claude_client
1535                            .check_commits_with_scopes(&single_view, guidelines, valid_scopes, true)
1536                            .await
1537                        {
1538                            Ok(report) => {
1539                                if let Some(r) = report.commits.into_iter().next() {
1540                                    let summary = r.summary.clone().unwrap_or_default();
1541                                    successes.push((r, summary));
1542                                }
1543                            }
1544                            Err(e) => {
1545                                eprintln!("warning: still failed: {e}");
1546                                still_failed.push(idx);
1547                            }
1548                        }
1549                    }
1550                    *failed_indices = still_failed;
1551                    if failed_indices.is_empty() {
1552                        println!("✅ All retried commits succeeded.");
1553                        break;
1554                    }
1555                    println!("\n⚠️  {} commit(s) still failed:", failed_indices.len());
1556                    for &idx in failed_indices.iter() {
1557                        let commit = &full_repo_view.commits[idx];
1558                        let subject = commit
1559                            .original_message
1560                            .lines()
1561                            .next()
1562                            .unwrap_or("(no message)");
1563                        println!("  - {}: {}", &commit.hash[..8], subject);
1564                    }
1565                }
1566                "s" | "skip" => {
1567                    println!("Skipping {} failed commit(s).", failed_indices.len());
1568                    break;
1569                }
1570                _ => println!("Please enter 'r' to retry or 's' to skip."),
1571            }
1572        }
1573        Ok(())
1574    }
1575
1576    /// Prompts the user to retry or skip commits that failed amendment generation,
1577    /// updating `failed_indices` and `successes` in place.
1578    ///
1579    /// `is_terminal` and `reader` are injected so tests can drive the function
1580    /// without blocking on real stdin.
1581    #[allow(clippy::too_many_arguments)]
1582    async fn run_interactive_retry_generate_amendments(
1583        &self,
1584        failed_indices: &mut Vec<usize>,
1585        full_repo_view: &crate::data::RepositoryView,
1586        claude_client: &crate::claude::client::ClaudeClient,
1587        context: Option<&crate::data::context::CommitContext>,
1588        fresh: bool,
1589        successes: &mut Vec<(crate::data::amendments::Amendment, String)>,
1590        is_terminal: bool,
1591        reader: &mut (dyn std::io::BufRead + Send),
1592    ) -> Result<()> {
1593        use std::io::Write as _;
1594        println!(
1595            "\n⚠️  {} commit(s) failed to process:",
1596            failed_indices.len()
1597        );
1598        for &idx in failed_indices.iter() {
1599            let commit = &full_repo_view.commits[idx];
1600            let subject = commit
1601                .original_message
1602                .lines()
1603                .next()
1604                .unwrap_or("(no message)");
1605            println!("  - {}: {}", &commit.hash[..8], subject);
1606        }
1607        if !is_terminal {
1608            eprintln!(
1609                "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1610                failed_indices.len()
1611            );
1612            return Ok(());
1613        }
1614        loop {
1615            print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1616            std::io::stdout().flush()?;
1617            let Some(input) = super::read_interactive_line(reader)? else {
1618                eprintln!("warning: stdin closed, skipping failed commit(s)");
1619                break;
1620            };
1621            match input.trim().to_lowercase().as_str() {
1622                "r" | "retry" | "" => {
1623                    let mut still_failed = Vec::new();
1624                    for &idx in failed_indices.iter() {
1625                        let single_view =
1626                            full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1627                        let result = if let Some(ctx) = context {
1628                            claude_client
1629                                .generate_contextual_amendments_with_options(
1630                                    &single_view,
1631                                    ctx,
1632                                    fresh,
1633                                )
1634                                .await
1635                        } else {
1636                            claude_client
1637                                .generate_amendments_with_options(&single_view, fresh)
1638                                .await
1639                        };
1640                        match result {
1641                            Ok(af) => {
1642                                if let Some(a) = af.amendments.into_iter().next() {
1643                                    let summary = a.summary.clone();
1644                                    successes.push((a, summary));
1645                                }
1646                            }
1647                            Err(e) => {
1648                                eprintln!("warning: still failed: {e}");
1649                                still_failed.push(idx);
1650                            }
1651                        }
1652                    }
1653                    *failed_indices = still_failed;
1654                    if failed_indices.is_empty() {
1655                        println!("✅ All retried commits succeeded.");
1656                        break;
1657                    }
1658                    println!("\n⚠️  {} commit(s) still failed:", failed_indices.len());
1659                    for &idx in failed_indices.iter() {
1660                        let commit = &full_repo_view.commits[idx];
1661                        let subject = commit
1662                            .original_message
1663                            .lines()
1664                            .next()
1665                            .unwrap_or("(no message)");
1666                        println!("  - {}: {}", &commit.hash[..8], subject);
1667                    }
1668                }
1669                "s" | "skip" => {
1670                    println!("Skipping {} failed commit(s).", failed_indices.len());
1671                    break;
1672                }
1673                _ => println!("Please enter 'r' to retry or 's' to skip."),
1674            }
1675        }
1676        Ok(())
1677    }
1678
1679    /// Outputs the text format check report (mirrors `CheckCommand::output_text_report`).
1680    fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1681        println!();
1682
1683        for result in &report.commits {
1684            // Skip passing commits
1685            if result.passes {
1686                continue;
1687            }
1688
1689            let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
1690            let short_hash = super::formatting::truncate_hash(&result.hash);
1691
1692            println!("{} {} - \"{}\"", icon, short_hash, result.message);
1693
1694            // Print issues
1695            for issue in &result.issues {
1696                let severity_str = super::formatting::format_severity_label(issue.severity);
1697
1698                println!(
1699                    "   {} [{}] {}",
1700                    severity_str, issue.section, issue.explanation
1701                );
1702            }
1703
1704            // Print suggestion if available
1705            if let Some(suggestion) = &result.suggestion {
1706                println!();
1707                println!("   Suggested message:");
1708                for line in suggestion.message.lines() {
1709                    println!("      {line}");
1710                }
1711            }
1712
1713            println!();
1714        }
1715
1716        // Print summary
1717        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1718        println!("Summary: {} commits checked", report.summary.total_commits);
1719        println!(
1720            "  {} errors, {} warnings",
1721            report.summary.error_count, report.summary.warning_count
1722        );
1723        println!(
1724            "  {} passed, {} with issues",
1725            report.summary.passing_commits, report.summary.failing_commits
1726        );
1727
1728        Ok(())
1729    }
1730}
1731
1732/// Structured output from [`run_twiddle`] for programmatic consumers (MCP).
1733#[derive(Debug, Clone)]
1734pub struct TwiddleOutcome {
1735    /// YAML serialisation of the generated [`AmendmentFile`].
1736    pub amendments_yaml: String,
1737    /// `true` when amendments were applied to the repository; `false` for a
1738    /// dry-run or when no amendments were generated.
1739    pub applied: bool,
1740    /// Number of amendments generated.
1741    pub amendment_count: usize,
1742}
1743
1744/// Non-interactive core for `omni-dev git commit message twiddle`.
1745///
1746/// Shared by the CLI (wrapped by [`TwiddleCommand::execute`] for the
1747/// interactive flow) and the MCP server. The MCP tool boundary is
1748/// non-interactive, so this entry point forces `--auto-apply` semantics when
1749/// `dry_run` is false and never opens an editor. When `dry_run` is true,
1750/// proposed amendments are returned as YAML without being applied.
1751///
1752/// Like [`super::run_check`], a `Some` `repo_path` pins the process CWD for
1753/// the duration of the call.
1754pub async fn run_twiddle(
1755    range: Option<&str>,
1756    model: Option<String>,
1757    dry_run: bool,
1758    repo_path: Option<&std::path::Path>,
1759) -> Result<TwiddleOutcome> {
1760    let _cwd_guard = match repo_path {
1761        Some(p) => Some(super::CwdGuard::enter(p).await?),
1762        None => None,
1763    };
1764
1765    crate::utils::check_ai_command_prerequisites(model.as_deref())?;
1766
1767    if !dry_run {
1768        crate::utils::preflight::check_working_directory_clean()?;
1769    }
1770
1771    let claude_client = crate::claude::create_default_claude_client(model, None).await?;
1772    run_twiddle_with_client(range, dry_run, &claude_client).await
1773}
1774
1775/// Non-credential-gated inner core of [`run_twiddle`] for unit tests.
1776///
1777/// Extracted so tests can inject a [`crate::claude::client::ClaudeClient`]
1778/// backed by the in-crate mock AI client and exercise the full flow without
1779/// real credentials. Callers are responsible for holding any
1780/// [`super::CwdGuard`] they need and for running preflight themselves.
1781pub(crate) async fn run_twiddle_with_client(
1782    range: Option<&str>,
1783    dry_run: bool,
1784    claude_client: &crate::claude::client::ClaudeClient,
1785) -> Result<TwiddleOutcome> {
1786    use crate::data::{
1787        AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1788        WorkingDirectoryInfo,
1789    };
1790    use crate::git::{GitRepository, RemoteInfo};
1791    use crate::utils::ai_scratch;
1792
1793    let resolved_range = range.unwrap_or("HEAD~5..HEAD");
1794
1795    let repo = GitRepository::open()
1796        .context("Failed to open git repository. Make sure you're in a git repository.")?;
1797
1798    let current_branch = repo
1799        .get_current_branch()
1800        .unwrap_or_else(|_| "HEAD".to_string());
1801
1802    let wd_status = repo.get_working_directory_status()?;
1803    let working_directory = WorkingDirectoryInfo {
1804        clean: wd_status.clean,
1805        untracked_changes: wd_status
1806            .untracked_changes
1807            .into_iter()
1808            .map(|fs| FileStatusInfo {
1809                status: fs.status,
1810                file: fs.file,
1811            })
1812            .collect(),
1813    };
1814
1815    let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1816    let commits = repo.get_commits_in_range(resolved_range)?;
1817
1818    if commits.is_empty() {
1819        let empty_file = AmendmentFile { amendments: vec![] };
1820        let yaml =
1821            crate::data::to_yaml(&empty_file).context("Failed to serialise empty AmendmentFile")?;
1822        return Ok(TwiddleOutcome {
1823            amendments_yaml: yaml,
1824            applied: false,
1825            amendment_count: 0,
1826        });
1827    }
1828
1829    let ai_scratch_path =
1830        ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1831    let ai_info = AiInfo {
1832        scratch: ai_scratch_path.to_string_lossy().to_string(),
1833    };
1834
1835    let mut repo_view = RepositoryView {
1836        versions: Some(VersionInfo {
1837            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1838        }),
1839        explanation: FieldExplanation::default(),
1840        working_directory,
1841        remotes,
1842        ai: ai_info,
1843        branch_info: Some(BranchInfo {
1844            branch: current_branch,
1845        }),
1846        pr_template: None,
1847        pr_template_location: None,
1848        branch_prs: None,
1849        commits,
1850    };
1851    repo_view.update_field_presence();
1852
1853    let mut amendments = claude_client
1854        .generate_amendments_with_options(&repo_view, true)
1855        .await?;
1856
1857    let context_dir = crate::claude::context::resolve_context_dir(None);
1858    let scope_defs =
1859        crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."));
1860    refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
1861
1862    let amendments_yaml =
1863        crate::data::to_yaml(&amendments).context("Failed to serialise AmendmentFile")?;
1864    let amendment_count = amendments.amendments.len();
1865
1866    if dry_run || amendment_count == 0 {
1867        return Ok(TwiddleOutcome {
1868            amendments_yaml,
1869            applied: false,
1870            amendment_count,
1871        });
1872    }
1873
1874    let temp_dir = tempfile::tempdir().context("Failed to create temp dir")?;
1875    let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1876    amendments
1877        .save_to_file(&amendments_file)
1878        .context("Failed to save amendments")?;
1879    let handler =
1880        crate::git::AmendmentHandler::new().context("Failed to initialise amendment handler")?;
1881    handler
1882        .apply_amendments(&amendments_file.to_string_lossy())
1883        .context("Failed to apply amendments")?;
1884
1885    Ok(TwiddleOutcome {
1886        amendments_yaml,
1887        applied: true,
1888        amendment_count,
1889    })
1890}
1891
1892#[cfg(test)]
1893#[allow(clippy::unwrap_used, clippy::expect_used)]
1894mod run_twiddle_tests {
1895    use super::*;
1896    use crate::claude::client::ClaudeClient;
1897    use crate::claude::test_utils::ConfigurableMockAiClient;
1898    use git2::{Repository, Signature};
1899
1900    #[tokio::test]
1901    async fn run_twiddle_invalid_repo_path_errors_before_ai() {
1902        let err = run_twiddle(
1903            None,
1904            None,
1905            true,
1906            Some(std::path::Path::new("/no/such/path/exists")),
1907        )
1908        .await
1909        .unwrap_err();
1910        let msg = format!("{err:#}");
1911        assert!(
1912            msg.to_lowercase().contains("set_current_dir")
1913                || msg.to_lowercase().contains("no such")
1914                || msg.to_lowercase().contains("directory"),
1915            "expected cwd-related error, got: {msg}"
1916        );
1917    }
1918
1919    fn init_test_repo_with_commit() -> (tempfile::TempDir, String) {
1920        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1921        std::fs::create_dir_all(&tmp_root).unwrap();
1922        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1923        let repo = Repository::init(temp_dir.path()).unwrap();
1924        {
1925            let mut cfg = repo.config().unwrap();
1926            cfg.set_str("user.name", "Test").unwrap();
1927            cfg.set_str("user.email", "test@example.com").unwrap();
1928        }
1929        let signature = Signature::now("Test", "test@example.com").unwrap();
1930        std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
1931        let mut idx = repo.index().unwrap();
1932        idx.add_path(std::path::Path::new("f.txt")).unwrap();
1933        idx.write().unwrap();
1934        let tree_id = idx.write_tree().unwrap();
1935        let tree = repo.find_tree(tree_id).unwrap();
1936        let oid = repo
1937            .commit(
1938                Some("HEAD"),
1939                &signature,
1940                &signature,
1941                "feat: original",
1942                &tree,
1943                &[],
1944            )
1945            .unwrap();
1946        (temp_dir, oid.to_string())
1947    }
1948
1949    fn amendment_yaml(hash: &str, msg: &str) -> String {
1950        format!("amendments:\n  - commit: {hash}\n    message: '{msg}'\n")
1951    }
1952
1953    #[tokio::test]
1954    async fn run_twiddle_with_client_dry_run_returns_amendments() {
1955        let (temp_dir, hash) = init_test_repo_with_commit();
1956        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1957            .await
1958            .unwrap();
1959
1960        let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
1961            &hash,
1962            "feat(cli): better subject",
1963        ))]);
1964        let client = ClaudeClient::new(Box::new(mock));
1965
1966        let outcome = run_twiddle_with_client(Some("HEAD"), true, &client)
1967            .await
1968            .unwrap();
1969        assert!(!outcome.applied, "dry_run must not apply");
1970        assert_eq!(outcome.amendment_count, 1);
1971        assert!(outcome.amendments_yaml.contains("amendments:"));
1972    }
1973
1974    #[tokio::test]
1975    async fn run_twiddle_with_client_empty_range_returns_empty() {
1976        let (temp_dir, _hash) = init_test_repo_with_commit();
1977        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1978            .await
1979            .unwrap();
1980
1981        let mock = ConfigurableMockAiClient::new(vec![]);
1982        let client = ClaudeClient::new(Box::new(mock));
1983
1984        let outcome = run_twiddle_with_client(Some("HEAD..HEAD"), true, &client)
1985            .await
1986            .unwrap();
1987        assert_eq!(outcome.amendment_count, 0);
1988        assert!(!outcome.applied);
1989    }
1990
1991    #[tokio::test]
1992    async fn run_twiddle_with_client_ai_failure_errors() {
1993        let (temp_dir, _hash) = init_test_repo_with_commit();
1994        let _guard = super::super::CwdGuard::enter(temp_dir.path())
1995            .await
1996            .unwrap();
1997
1998        let mock = ConfigurableMockAiClient::new(vec![]);
1999        let client = ClaudeClient::new(Box::new(mock));
2000        let err = run_twiddle_with_client(Some("HEAD"), true, &client)
2001            .await
2002            .unwrap_err();
2003        let _ = err;
2004    }
2005
2006    #[tokio::test]
2007    async fn run_twiddle_with_client_default_range_errors_on_sparse_repo() {
2008        let (temp_dir, _hash) = init_test_repo_with_commit();
2009        let _guard = super::super::CwdGuard::enter(temp_dir.path())
2010            .await
2011            .unwrap();
2012
2013        // Default range HEAD~5..HEAD cannot resolve HEAD~5 in a repo with
2014        // only one commit — get_commits_in_range returns an error, which
2015        // propagates. This still exercises the default-range code path.
2016        let mock = ConfigurableMockAiClient::new(vec![]);
2017        let client = ClaudeClient::new(Box::new(mock));
2018
2019        let err = run_twiddle_with_client(None, true, &client)
2020            .await
2021            .unwrap_err();
2022        assert!(
2023            format!("{err:#}").contains("HEAD~5")
2024                || format!("{err:#}").to_lowercase().contains("not found"),
2025            "expected HEAD~5 resolution error"
2026        );
2027    }
2028
2029    #[test]
2030    fn twiddle_outcome_clone_and_debug() {
2031        let outcome = TwiddleOutcome {
2032            amendments_yaml: "x".to_string(),
2033            applied: true,
2034            amendment_count: 2,
2035        };
2036        let cloned = outcome.clone();
2037        assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
2038    }
2039
2040    /// Exercises the apply-amendments path (`dry_run = false`). The amendment
2041    /// targets the repo's HEAD, so `AmendmentHandler` takes the fast
2042    /// `amend_head_commit` branch rather than interactive rebase.
2043    #[tokio::test]
2044    async fn run_twiddle_with_client_applies_head_amendment() {
2045        let (temp_dir, hash) = init_test_repo_with_commit();
2046        let _guard = super::super::CwdGuard::enter(temp_dir.path())
2047            .await
2048            .unwrap();
2049
2050        let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
2051            &hash,
2052            "feat(cli): much better subject",
2053        ))]);
2054        let client = ClaudeClient::new(Box::new(mock));
2055
2056        let outcome = run_twiddle_with_client(Some("HEAD"), false, &client)
2057            .await
2058            .unwrap();
2059        assert!(outcome.applied, "dry_run=false must apply amendments");
2060        assert_eq!(outcome.amendment_count, 1);
2061
2062        // Confirm the commit message was actually rewritten on HEAD.
2063        let repo = git2::Repository::open(temp_dir.path()).unwrap();
2064        let head_msg = repo
2065            .head()
2066            .unwrap()
2067            .peel_to_commit()
2068            .unwrap()
2069            .message()
2070            .unwrap()
2071            .to_string();
2072        assert!(
2073            head_msg.contains("much better subject"),
2074            "HEAD message should be rewritten: {head_msg}"
2075        );
2076    }
2077}
2078
2079#[cfg(test)]
2080#[allow(clippy::unwrap_used, clippy::expect_used)]
2081mod execute_tests {
2082    use super::*;
2083    use crate::claude::client::ClaudeClient;
2084    use crate::claude::test_utils::ConfigurableMockAiClient;
2085    use git2::{Repository, Signature};
2086
2087    /// Creates a tempdir-backed git repo with `n` commits on a linear
2088    /// history. Each commit writes a distinct file so diffs are non-empty.
2089    /// Returns the tempdir and the list of commit hashes (oldest-first).
2090    fn init_test_repo_with_n_commits(n: usize) -> (tempfile::TempDir, Vec<String>) {
2091        assert!(n >= 1);
2092        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
2093        std::fs::create_dir_all(&tmp_root).unwrap();
2094        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
2095        let repo = Repository::init(temp_dir.path()).unwrap();
2096        {
2097            let mut cfg = repo.config().unwrap();
2098            cfg.set_str("user.name", "Test").unwrap();
2099            cfg.set_str("user.email", "test@example.com").unwrap();
2100        }
2101        let signature = Signature::now("Test", "test@example.com").unwrap();
2102
2103        let mut hashes = Vec::with_capacity(n);
2104        let mut parent_oid: Option<git2::Oid> = None;
2105
2106        for i in 0..n {
2107            let file = format!("f{i}.txt");
2108            std::fs::write(temp_dir.path().join(&file), format!("contents {i}")).unwrap();
2109            let mut idx = repo.index().unwrap();
2110            idx.add_path(std::path::Path::new(&file)).unwrap();
2111            idx.write().unwrap();
2112            let tree_id = idx.write_tree().unwrap();
2113            let tree = repo.find_tree(tree_id).unwrap();
2114            let msg = format!("feat: original commit {i}");
2115
2116            let oid = if let Some(parent) = parent_oid {
2117                let parent_commit = repo.find_commit(parent).unwrap();
2118                repo.commit(
2119                    Some("HEAD"),
2120                    &signature,
2121                    &signature,
2122                    &msg,
2123                    &tree,
2124                    &[&parent_commit],
2125                )
2126                .unwrap()
2127            } else {
2128                repo.commit(Some("HEAD"), &signature, &signature, &msg, &tree, &[])
2129                    .unwrap()
2130            };
2131            parent_oid = Some(oid);
2132            hashes.push(oid.to_string());
2133        }
2134
2135        (temp_dir, hashes)
2136    }
2137
2138    /// Creates a single-commit repo for the `execute_no_ai` test. Mirrors
2139    /// the helper in `run_twiddle_tests` so the two test modules stay
2140    /// self-contained.
2141    fn init_test_repo_with_commit() -> (tempfile::TempDir, String) {
2142        let (temp_dir, hashes) = init_test_repo_with_n_commits(1);
2143        (temp_dir, hashes.into_iter().next().unwrap())
2144    }
2145
2146    /// Builds a `TwiddleCommand` with all flags at sensible defaults for
2147    /// the AI-dispatch tests: non-contextual, save-only output, quiet.
2148    fn make_cmd(commit_range: &str, save_path: std::path::PathBuf) -> TwiddleCommand {
2149        TwiddleCommand {
2150            commit_range: Some(commit_range.to_string()),
2151            model: None,
2152            beta_header: None,
2153            auto_apply: false,
2154            save_only: Some(save_path.to_string_lossy().into_owned()),
2155            use_context: false,
2156            context_dir: None,
2157            work_context: None,
2158            branch_context: None,
2159            no_context: true,
2160            concurrency: 1,
2161            batch_size: None,
2162            no_coherence: true,
2163            no_ai: false,
2164            fresh: false,
2165            refine: false,
2166            check: false,
2167            quiet: true,
2168        }
2169    }
2170
2171    /// Builds an amendments YAML block containing one entry per (hash, message, summary)
2172    /// triple. Mirrors what a successful AI batch response looks like.
2173    fn batch_amendment_yaml(entries: &[(&str, &str, &str)]) -> String {
2174        let mut out = String::from("amendments:\n");
2175        for (hash, msg, summary) in entries {
2176            out.push_str(&format!(
2177                "  - commit: {hash}\n    message: '{msg}'\n    summary: '{summary}'\n"
2178            ));
2179        }
2180        out
2181    }
2182
2183    /// Test 1 — happy path: a single batch response succeeds for a
2184    /// 2-commit range, exercising the success branch of
2185    /// `execute_with_map_reduce` (the `let summary = a.summary.clone();`
2186    /// at line 392 in the original `twiddle.rs`).
2187    #[tokio::test]
2188    async fn execute_with_client_multi_commit_batch_success_covers_line_392() {
2189        let (temp_dir, hashes) = init_test_repo_with_n_commits(3);
2190        let _guard = super::super::CwdGuard::enter(temp_dir.path())
2191            .await
2192            .unwrap();
2193
2194        // Range yields the 2 most recent commits (oldest excluded).
2195        let h_mid = &hashes[1];
2196        let h_new = &hashes[2];
2197
2198        let yaml = batch_amendment_yaml(&[
2199            (h_mid, "feat: improved mid", "improved mid summary"),
2200            (h_new, "feat: improved new", "improved new summary"),
2201        ]);
2202        let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
2203        let response_handle = mock.response_handle();
2204        let prompt_handle = mock.prompt_handle();
2205        let client = ClaudeClient::new(Box::new(mock));
2206
2207        let save_path = temp_dir.path().join("amendments.yaml");
2208        let cmd = make_cmd("HEAD~2..HEAD", save_path.clone());
2209
2210        cmd.execute_with_client(client).await.unwrap();
2211
2212        // Single batch dispatch, single AI request.
2213        assert_eq!(response_handle.remaining(), 0);
2214        assert_eq!(prompt_handle.request_count(), 1);
2215
2216        // Verify the saved file contains both amendments with summaries
2217        // intact (proving line 392 ran for each amendment).
2218        let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2219        assert_eq!(saved.amendments.len(), 2);
2220        let summaries: Vec<&str> = saved
2221            .amendments
2222            .iter()
2223            .map(|a| a.summary.as_str())
2224            .collect();
2225        assert!(
2226            summaries.contains(&"improved mid summary"),
2227            "summaries: {summaries:?}"
2228        );
2229        assert!(
2230            summaries.contains(&"improved new summary"),
2231            "summaries: {summaries:?}"
2232        );
2233    }
2234
2235    /// Test 2 — split-and-retry: the initial multi-commit batch fails
2236    /// (consuming 3 mock responses to exhaust `AMENDMENT_PARSE_MAX_RETRIES`),
2237    /// then per-commit retries succeed. Exercises the `batch_size > 1`
2238    /// failure branch (the `let summary = a.summary.clone();` at line 424
2239    /// in the original `twiddle.rs`).
2240    #[tokio::test]
2241    async fn execute_with_client_multi_commit_split_retry_covers_line_424() {
2242        let (temp_dir, hashes) = init_test_repo_with_n_commits(3);
2243        let _guard = super::super::CwdGuard::enter(temp_dir.path())
2244            .await
2245            .unwrap();
2246
2247        let h_mid = &hashes[1];
2248        let h_new = &hashes[2];
2249
2250        // 3 Errs exhaust the batch retry loop, then one Ok per individual
2251        // retry. Each retry has its own retry budget but succeeds on first
2252        // attempt, so it consumes one response.
2253        let mock = ConfigurableMockAiClient::new(vec![
2254            Err(anyhow::anyhow!("simulated batch failure 1")),
2255            Err(anyhow::anyhow!("simulated batch failure 2")),
2256            Err(anyhow::anyhow!("simulated batch failure 3")),
2257            Ok(batch_amendment_yaml(&[(
2258                h_mid,
2259                "feat: solo mid",
2260                "solo mid summary",
2261            )])),
2262            Ok(batch_amendment_yaml(&[(
2263                h_new,
2264                "feat: solo new",
2265                "solo new summary",
2266            )])),
2267        ]);
2268        let response_handle = mock.response_handle();
2269        let prompt_handle = mock.prompt_handle();
2270        let client = ClaudeClient::new(Box::new(mock));
2271
2272        let save_path = temp_dir.path().join("amendments.yaml");
2273        let cmd = make_cmd("HEAD~2..HEAD", save_path.clone());
2274
2275        cmd.execute_with_client(client).await.unwrap();
2276
2277        // 3 batch attempts + 2 individual retries = 5 AI requests.
2278        assert_eq!(response_handle.remaining(), 0);
2279        assert_eq!(prompt_handle.request_count(), 5);
2280
2281        let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2282        assert_eq!(saved.amendments.len(), 2);
2283        let summaries: Vec<&str> = saved
2284            .amendments
2285            .iter()
2286            .map(|a| a.summary.as_str())
2287            .collect();
2288        assert!(
2289            summaries.contains(&"solo mid summary"),
2290            "summaries: {summaries:?}"
2291        );
2292        assert!(
2293            summaries.contains(&"solo new summary"),
2294            "summaries: {summaries:?}"
2295        );
2296    }
2297
2298    /// Test 3 — `--no-ai` save-only path: drives `execute()` with
2299    /// `--no-ai` (which short-circuits before preflight), exercising the
2300    /// `summary: String::new()` initialiser at line 1031 in
2301    /// `execute_no_ai`.
2302    #[tokio::test]
2303    async fn execute_no_ai_save_only_covers_line_1031() {
2304        let (temp_dir, hash) = init_test_repo_with_commit();
2305        let _guard = super::super::CwdGuard::enter(temp_dir.path())
2306            .await
2307            .unwrap();
2308
2309        let save_path = temp_dir.path().join("amendments.yaml");
2310        let cmd = TwiddleCommand {
2311            commit_range: Some("HEAD".to_string()),
2312            model: None,
2313            beta_header: None,
2314            auto_apply: false,
2315            save_only: Some(save_path.to_string_lossy().into_owned()),
2316            use_context: false,
2317            context_dir: None,
2318            work_context: None,
2319            branch_context: None,
2320            no_context: true,
2321            concurrency: 1,
2322            batch_size: None,
2323            no_coherence: true,
2324            no_ai: true,
2325            fresh: false,
2326            refine: false,
2327            check: false,
2328            quiet: true,
2329        };
2330
2331        cmd.execute().await.unwrap();
2332
2333        let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2334        assert_eq!(saved.amendments.len(), 1);
2335        let amendment = &saved.amendments[0];
2336        assert_eq!(amendment.commit, hash);
2337        assert!(
2338            amendment.message.contains("feat: original commit 0"),
2339            "message: {}",
2340            amendment.message
2341        );
2342        // The whole point of line 1031: `summary: String::new()`.
2343        assert_eq!(amendment.summary, "");
2344    }
2345}
2346
2347// --- Extracted pure functions ---
2348
2349/// Formats a work pattern as a display label with emoji.
2350///
2351/// Returns `None` for `WorkPattern::Unknown` since it should not be displayed.
2352fn format_work_pattern(pattern: &crate::data::context::WorkPattern) -> Option<&'static str> {
2353    use crate::data::context::WorkPattern;
2354    match pattern {
2355        WorkPattern::Sequential => Some("\u{1f504} Pattern: Sequential development"),
2356        WorkPattern::Refactoring => Some("\u{1f9f9} Pattern: Refactoring work"),
2357        WorkPattern::BugHunt => Some("\u{1f41b} Pattern: Bug investigation"),
2358        WorkPattern::Documentation => Some("\u{1f4d6} Pattern: Documentation updates"),
2359        WorkPattern::Configuration => Some("\u{2699}\u{fe0f}  Pattern: Configuration changes"),
2360        WorkPattern::Unknown => None,
2361    }
2362}
2363
2364/// Formats a verbosity level as a display label with emoji.
2365fn format_verbosity_level(level: crate::data::context::VerbosityLevel) -> &'static str {
2366    use crate::data::context::VerbosityLevel;
2367    match level {
2368        VerbosityLevel::Comprehensive => {
2369            "\u{1f4dd} Detail level: Comprehensive (significant changes detected)"
2370        }
2371        VerbosityLevel::Detailed => "\u{1f4dd} Detail level: Detailed",
2372        VerbosityLevel::Concise => "\u{1f4dd} Detail level: Concise",
2373    }
2374}
2375
2376/// Formats a list of scope definitions as a comma-separated string of names.
2377fn format_scope_list(scopes: &[crate::data::context::ScopeDefinition]) -> String {
2378    scopes
2379        .iter()
2380        .map(|s| s.name.as_str())
2381        .collect::<Vec<_>>()
2382        .join(", ")
2383}
2384
2385/// Resolves duplicate amendments (same commit hash) by prompting the user, or
2386/// silently picking the first occurrence when `auto_pick` is set.
2387///
2388/// Models occasionally return the same amendment twice with slight formatting
2389/// variations (issue #697). The apply path can't tolerate duplicates: the
2390/// first amendment rewrites the commit, leaving subsequent amendments
2391/// pointing at a hash that no longer exists in branch history.
2392fn resolve_duplicate_amendments(
2393    amendments: &mut AmendmentFile,
2394    auto_pick: bool,
2395    is_terminal: bool,
2396    reader: &mut (dyn std::io::BufRead + Send),
2397) -> Result<()> {
2398    use std::collections::hash_map::Entry;
2399    use std::collections::{HashMap, HashSet};
2400    use std::io::{self, Write};
2401
2402    if amendments.amendments.len() < 2 {
2403        return Ok(());
2404    }
2405
2406    let mut order: Vec<String> = Vec::new();
2407    let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
2408    for (i, a) in amendments.amendments.iter().enumerate() {
2409        match groups.entry(a.commit.clone()) {
2410            Entry::Vacant(slot) => {
2411                order.push(a.commit.clone());
2412                slot.insert(vec![i]);
2413            }
2414            Entry::Occupied(mut slot) => {
2415                slot.get_mut().push(i);
2416            }
2417        }
2418    }
2419
2420    if groups.values().all(|v| v.len() <= 1) {
2421        return Ok(());
2422    }
2423
2424    let mut drop_indices: HashSet<usize> = HashSet::new();
2425
2426    for hash in &order {
2427        let Some(idxs) = groups.get(hash) else {
2428            continue;
2429        };
2430        if idxs.len() <= 1 {
2431            continue;
2432        }
2433
2434        let short = &hash[..crate::git::SHORT_HASH_LEN.min(hash.len())];
2435
2436        let chosen = if auto_pick {
2437            eprintln!(
2438                "warning: model returned {} duplicate amendments for commit {short}; \
2439                 keeping the first.",
2440                idxs.len()
2441            );
2442            idxs[0]
2443        } else if !is_terminal {
2444            eprintln!(
2445                "warning: model returned {} duplicate amendments for commit {short}; \
2446                 stdin not interactive — keeping the first.",
2447                idxs.len()
2448            );
2449            idxs[0]
2450        } else {
2451            println!(
2452                "\n⚠️  Model returned {} duplicate amendments for commit {short}:",
2453                idxs.len()
2454            );
2455            for (n, &i) in idxs.iter().enumerate() {
2456                println!("\n  [{}] -----", n + 1);
2457                for line in amendments.amendments[i].message.lines() {
2458                    println!("      {line}");
2459                }
2460            }
2461            println!();
2462
2463            loop {
2464                print!(
2465                    "❓ Which amendment to apply? [1-{}] (default 1) ",
2466                    idxs.len()
2467                );
2468                io::stdout().flush()?;
2469
2470                let Some(input) = super::read_interactive_line(reader)? else {
2471                    eprintln!("warning: stdin closed; keeping the first amendment.");
2472                    break idxs[0];
2473                };
2474
2475                let trimmed = input.trim();
2476                if trimmed.is_empty() {
2477                    break idxs[0];
2478                }
2479                match trimmed.parse::<usize>() {
2480                    Ok(n) if (1..=idxs.len()).contains(&n) => break idxs[n - 1],
2481                    _ => println!(
2482                        "Invalid choice. Please enter a number between 1 and {}.",
2483                        idxs.len()
2484                    ),
2485                }
2486            }
2487        };
2488
2489        for &i in idxs {
2490            if i != chosen {
2491                drop_indices.insert(i);
2492            }
2493        }
2494    }
2495
2496    let kept: Vec<_> = std::mem::take(&mut amendments.amendments)
2497        .into_iter()
2498        .enumerate()
2499        .filter(|(i, _)| !drop_indices.contains(i))
2500        .map(|(_, a)| a)
2501        .collect();
2502    amendments.amendments = kept;
2503
2504    Ok(())
2505}
2506
2507/// Refine scopes in generated amendment messages using the same deterministic
2508/// file-pattern logic the checker uses, so generator and checker agree.
2509fn refine_amendment_scopes(
2510    amendments: &mut AmendmentFile,
2511    repo_view: &RepositoryView,
2512    scope_defs: &[crate::data::context::ScopeDefinition],
2513) {
2514    for amendment in &mut amendments.amendments {
2515        if let Some(commit) = repo_view
2516            .commits
2517            .iter()
2518            .find(|c| c.hash == amendment.commit)
2519        {
2520            let files: Vec<&str> = commit
2521                .analysis
2522                .file_changes
2523                .file_list
2524                .iter()
2525                .map(|f| f.file.as_str())
2526                .collect();
2527            amendment.message =
2528                crate::git::refine_message_scope(&amendment.message, &files, scope_defs);
2529        }
2530    }
2531}
2532
2533#[cfg(test)]
2534#[allow(clippy::unwrap_used, clippy::expect_used)]
2535mod tests {
2536    use super::*;
2537    use crate::data::context::{ScopeDefinition, VerbosityLevel, WorkPattern};
2538
2539    // --- format_work_pattern ---
2540
2541    #[test]
2542    fn work_pattern_sequential() {
2543        let result = format_work_pattern(&WorkPattern::Sequential);
2544        assert!(result.is_some());
2545        assert!(result.unwrap().contains("Sequential development"));
2546    }
2547
2548    #[test]
2549    fn work_pattern_refactoring() {
2550        let result = format_work_pattern(&WorkPattern::Refactoring);
2551        assert!(result.is_some());
2552        assert!(result.unwrap().contains("Refactoring work"));
2553    }
2554
2555    #[test]
2556    fn work_pattern_bug_hunt() {
2557        let result = format_work_pattern(&WorkPattern::BugHunt);
2558        assert!(result.is_some());
2559        assert!(result.unwrap().contains("Bug investigation"));
2560    }
2561
2562    #[test]
2563    fn work_pattern_docs() {
2564        let result = format_work_pattern(&WorkPattern::Documentation);
2565        assert!(result.is_some());
2566        assert!(result.unwrap().contains("Documentation updates"));
2567    }
2568
2569    #[test]
2570    fn work_pattern_config() {
2571        let result = format_work_pattern(&WorkPattern::Configuration);
2572        assert!(result.is_some());
2573        assert!(result.unwrap().contains("Configuration changes"));
2574    }
2575
2576    #[test]
2577    fn work_pattern_unknown() {
2578        assert!(format_work_pattern(&WorkPattern::Unknown).is_none());
2579    }
2580
2581    // --- format_verbosity_level ---
2582
2583    #[test]
2584    fn verbosity_comprehensive() {
2585        let label = format_verbosity_level(VerbosityLevel::Comprehensive);
2586        assert!(label.contains("Comprehensive"));
2587        assert!(label.contains("significant changes"));
2588    }
2589
2590    #[test]
2591    fn verbosity_detailed() {
2592        let label = format_verbosity_level(VerbosityLevel::Detailed);
2593        assert!(label.contains("Detailed"));
2594    }
2595
2596    #[test]
2597    fn verbosity_concise() {
2598        let label = format_verbosity_level(VerbosityLevel::Concise);
2599        assert!(label.contains("Concise"));
2600    }
2601
2602    // --- format_scope_list ---
2603
2604    #[test]
2605    fn scope_list_single() {
2606        let scopes = vec![ScopeDefinition {
2607            name: "cli".to_string(),
2608            description: String::new(),
2609            examples: vec![],
2610            file_patterns: vec![],
2611        }];
2612        assert_eq!(format_scope_list(&scopes), "cli");
2613    }
2614
2615    #[test]
2616    fn scope_list_multiple() {
2617        let scopes = vec![
2618            ScopeDefinition {
2619                name: "cli".to_string(),
2620                description: String::new(),
2621                examples: vec![],
2622                file_patterns: vec![],
2623            },
2624            ScopeDefinition {
2625                name: "git".to_string(),
2626                description: String::new(),
2627                examples: vec![],
2628                file_patterns: vec![],
2629            },
2630            ScopeDefinition {
2631                name: "docs".to_string(),
2632                description: String::new(),
2633                examples: vec![],
2634                file_patterns: vec![],
2635            },
2636        ];
2637        assert_eq!(format_scope_list(&scopes), "cli, git, docs");
2638    }
2639
2640    // --- resolve_context_dir ---
2641
2642    #[test]
2643    fn context_dir_default() {
2644        let result = crate::claude::context::resolve_context_dir(None);
2645        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
2646        assert!(
2647            result.ends_with(".omni-dev"),
2648            "expected path ending in .omni-dev, got {result:?}"
2649        );
2650    }
2651
2652    #[test]
2653    fn context_dir_override() {
2654        let custom = std::path::PathBuf::from("custom-dir");
2655        let result = crate::claude::context::resolve_context_dir(Some(&custom));
2656        assert_eq!(result, custom);
2657    }
2658
2659    // --- is_fresh ---
2660
2661    fn parse_twiddle(args: &[&str]) -> TwiddleCommand {
2662        let mut full_args = vec!["twiddle"];
2663        full_args.extend_from_slice(args);
2664        TwiddleCommand::try_parse_from(full_args).unwrap()
2665    }
2666
2667    #[test]
2668    fn default_is_fresh() {
2669        let cmd = parse_twiddle(&[]);
2670        assert!(cmd.is_fresh(), "default should be fresh mode");
2671    }
2672
2673    #[test]
2674    fn refine_disables_fresh() {
2675        let cmd = parse_twiddle(&["--refine"]);
2676        assert!(!cmd.is_fresh(), "--refine should disable fresh mode");
2677    }
2678
2679    #[test]
2680    fn explicit_fresh_is_fresh() {
2681        let cmd = parse_twiddle(&["--fresh"]);
2682        assert!(cmd.is_fresh(), "--fresh should be fresh mode");
2683    }
2684
2685    #[test]
2686    fn fresh_and_refine_conflict() {
2687        let result = TwiddleCommand::try_parse_from(["twiddle", "--fresh", "--refine"]);
2688        assert!(result.is_err(), "--fresh and --refine should conflict");
2689    }
2690
2691    // --- check_commits_map_reduce (success paths via mock client) ---
2692
2693    fn make_twiddle_cmd() -> TwiddleCommand {
2694        TwiddleCommand {
2695            commit_range: None,
2696            model: None,
2697            beta_header: None,
2698            auto_apply: false,
2699            save_only: None,
2700            use_context: false,
2701            context_dir: None,
2702            work_context: None,
2703            branch_context: None,
2704            no_context: true,
2705            concurrency: 4,
2706            batch_size: None,
2707            no_coherence: true,
2708            no_ai: false,
2709            fresh: false,
2710            refine: false,
2711            check: false,
2712            quiet: false,
2713        }
2714    }
2715
2716    fn make_twiddle_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
2717        use crate::git::commit::FileChanges;
2718        use crate::git::{CommitAnalysis, CommitInfo};
2719        let tmp = tempfile::NamedTempFile::new().unwrap();
2720        let commit = CommitInfo {
2721            hash: hash.to_string(),
2722            author: "Test <test@test.com>".to_string(),
2723            date: chrono::Utc::now().fixed_offset(),
2724            original_message: format!("feat: commit {hash}"),
2725            in_main_branches: vec![],
2726            analysis: CommitAnalysis {
2727                detected_type: "feat".to_string(),
2728                detected_scope: String::new(),
2729                proposed_message: format!("feat: commit {hash}"),
2730                file_changes: FileChanges {
2731                    total_files: 0,
2732                    files_added: 0,
2733                    files_deleted: 0,
2734                    file_list: vec![],
2735                },
2736                diff_summary: String::new(),
2737                diff_file: tmp.path().to_string_lossy().to_string(),
2738                file_diffs: Vec::new(),
2739            },
2740        };
2741        (commit, tmp)
2742    }
2743
2744    fn make_twiddle_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
2745        use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
2746        RepositoryView {
2747            versions: None,
2748            explanation: FieldExplanation::default(),
2749            working_directory: WorkingDirectoryInfo {
2750                clean: true,
2751                untracked_changes: vec![],
2752            },
2753            remotes: vec![],
2754            ai: AiInfo {
2755                scratch: String::new(),
2756            },
2757            branch_info: None,
2758            pr_template: None,
2759            pr_template_location: None,
2760            branch_prs: None,
2761            commits,
2762        }
2763    }
2764
2765    fn twiddle_check_yaml(hash: &str) -> String {
2766        format!("checks:\n  - commit: {hash}\n    passes: true\n    issues: []\n")
2767    }
2768
2769    fn make_mock_client(
2770        responses: Vec<anyhow::Result<String>>,
2771    ) -> crate::claude::client::ClaudeClient {
2772        crate::claude::client::ClaudeClient::new(Box::new(
2773            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2774        ))
2775    }
2776
2777    #[tokio::test]
2778    async fn check_commits_map_reduce_single_commit_succeeds() {
2779        // Happy path: one commit, batch succeeds on first attempt.
2780        let (commit, _tmp) = make_twiddle_commit("abc00000");
2781        let cmd = make_twiddle_cmd();
2782        let repo_view = make_twiddle_repo_view(vec![commit]);
2783        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2784        let result = cmd
2785            .check_commits_map_reduce(&client, &repo_view, None, &[])
2786            .await;
2787        assert!(result.is_ok());
2788        assert_eq!(result.unwrap().commits.len(), 1);
2789    }
2790
2791    #[tokio::test]
2792    async fn check_commits_map_reduce_batch_fails_split_retry_both_succeed() {
2793        // Two commits in one batch. Batch fails (3 retries), then each commit
2794        // succeeds individually via split-and-retry. No stdin interaction since
2795        // failed_indices stays empty after both retries succeed.
2796        let (c1, _t1) = make_twiddle_commit("abc00000");
2797        let (c2, _t2) = make_twiddle_commit("def00000");
2798        let cmd = make_twiddle_cmd();
2799        let repo_view = make_twiddle_repo_view(vec![c1, c2]);
2800        let mut responses: Vec<anyhow::Result<String>> =
2801            (0..3).map(|_| Err(anyhow::anyhow!("batch fail"))).collect();
2802        responses.push(Ok(twiddle_check_yaml("abc00000")));
2803        responses.push(Ok(twiddle_check_yaml("def00000")));
2804        let client = make_mock_client(responses);
2805        let result = cmd
2806            .check_commits_map_reduce(&client, &repo_view, None, &[])
2807            .await;
2808        assert!(result.is_ok());
2809        assert_eq!(result.unwrap().commits.len(), 2);
2810    }
2811
2812    // --- run_interactive_retry_twiddle_check ---
2813
2814    #[tokio::test]
2815    async fn interactive_retry_twiddle_skip_immediately() {
2816        // "s" input → loop exits without calling the AI client at all.
2817        let (commit, _tmp) = make_twiddle_commit("abc00000");
2818        let cmd = make_twiddle_cmd();
2819        let repo_view = make_twiddle_repo_view(vec![commit]);
2820        let client = make_mock_client(vec![]);
2821        let mut failed = vec![0usize];
2822        let mut successes = vec![];
2823        let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
2824        cmd.run_interactive_retry_twiddle_check(
2825            &mut failed,
2826            &repo_view,
2827            &client,
2828            None,
2829            &[],
2830            &mut successes,
2831            &mut stdin,
2832        )
2833        .await
2834        .unwrap();
2835        assert_eq!(
2836            failed,
2837            vec![0],
2838            "skip should leave failed_indices unchanged"
2839        );
2840        assert!(successes.is_empty());
2841    }
2842
2843    #[tokio::test]
2844    async fn interactive_retry_twiddle_retry_succeeds() {
2845        // "r" input → retries the failed commit, which succeeds.
2846        let (commit, _tmp) = make_twiddle_commit("abc00000");
2847        let cmd = make_twiddle_cmd();
2848        let repo_view = make_twiddle_repo_view(vec![commit]);
2849        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2850        let mut failed = vec![0usize];
2851        let mut successes = vec![];
2852        let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
2853        cmd.run_interactive_retry_twiddle_check(
2854            &mut failed,
2855            &repo_view,
2856            &client,
2857            None,
2858            &[],
2859            &mut successes,
2860            &mut stdin,
2861        )
2862        .await
2863        .unwrap();
2864        assert!(
2865            failed.is_empty(),
2866            "retry succeeded → failed_indices cleared"
2867        );
2868        assert_eq!(successes.len(), 1);
2869    }
2870
2871    #[tokio::test]
2872    async fn interactive_retry_twiddle_default_input_retries() {
2873        // Empty input (just Enter) is treated as "r" (retry).
2874        let (commit, _tmp) = make_twiddle_commit("abc00000");
2875        let cmd = make_twiddle_cmd();
2876        let repo_view = make_twiddle_repo_view(vec![commit]);
2877        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2878        let mut failed = vec![0usize];
2879        let mut successes = vec![];
2880        let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
2881        cmd.run_interactive_retry_twiddle_check(
2882            &mut failed,
2883            &repo_view,
2884            &client,
2885            None,
2886            &[],
2887            &mut successes,
2888            &mut stdin,
2889        )
2890        .await
2891        .unwrap();
2892        assert!(failed.is_empty());
2893        assert_eq!(successes.len(), 1);
2894    }
2895
2896    #[tokio::test]
2897    async fn interactive_retry_twiddle_still_fails_then_skip() {
2898        // "r" → retry fails → still in failed_indices → "s" → skip.
2899        let (commit, _tmp) = make_twiddle_commit("abc00000");
2900        let cmd = make_twiddle_cmd();
2901        let repo_view = make_twiddle_repo_view(vec![commit]);
2902        // Retry attempt hits max_retries=2 (3 total attempts).
2903        let responses = (0..3).map(|_| Err(anyhow::anyhow!("mock fail"))).collect();
2904        let client = make_mock_client(responses);
2905        let mut failed = vec![0usize];
2906        let mut successes = vec![];
2907        let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
2908        cmd.run_interactive_retry_twiddle_check(
2909            &mut failed,
2910            &repo_view,
2911            &client,
2912            None,
2913            &[],
2914            &mut successes,
2915            &mut stdin,
2916        )
2917        .await
2918        .unwrap();
2919        assert_eq!(failed, vec![0], "commit still failed after retry");
2920        assert!(successes.is_empty());
2921    }
2922
2923    #[tokio::test]
2924    async fn interactive_retry_twiddle_invalid_input_then_skip() {
2925        // Unrecognised input → "please enter r or s" message → "s" exits.
2926        let (commit, _tmp) = make_twiddle_commit("abc00000");
2927        let cmd = make_twiddle_cmd();
2928        let repo_view = make_twiddle_repo_view(vec![commit]);
2929        let client = make_mock_client(vec![]);
2930        let mut failed = vec![0usize];
2931        let mut successes = vec![];
2932        let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
2933        cmd.run_interactive_retry_twiddle_check(
2934            &mut failed,
2935            &repo_view,
2936            &client,
2937            None,
2938            &[],
2939            &mut successes,
2940            &mut stdin,
2941        )
2942        .await
2943        .unwrap();
2944        assert_eq!(failed, vec![0]);
2945        assert!(successes.is_empty());
2946    }
2947
2948    #[tokio::test]
2949    async fn interactive_retry_twiddle_eof_breaks_immediately() {
2950        // EOF (empty reader) → read_line returns Ok(0) → loop breaks without
2951        // calling the AI client. failed_indices stays unchanged.
2952        let (commit, _tmp) = make_twiddle_commit("abc00000");
2953        let cmd = make_twiddle_cmd();
2954        let repo_view = make_twiddle_repo_view(vec![commit]);
2955        let client = make_mock_client(vec![]); // no responses consumed
2956        let mut failed = vec![0usize];
2957        let mut successes = vec![];
2958        let mut stdin = std::io::Cursor::new(b"" as &[u8]);
2959        cmd.run_interactive_retry_twiddle_check(
2960            &mut failed,
2961            &repo_view,
2962            &client,
2963            None,
2964            &[],
2965            &mut successes,
2966            &mut stdin,
2967        )
2968        .await
2969        .unwrap();
2970        assert_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
2971        assert!(successes.is_empty());
2972    }
2973
2974    // --- handle_amendments_file ---
2975
2976    fn make_amendment_file() -> crate::data::amendments::AmendmentFile {
2977        crate::data::amendments::AmendmentFile {
2978            amendments: vec![crate::data::amendments::Amendment {
2979                commit: "abc0000000000000000000000000000000000001".to_string(),
2980                message: "feat: improved commit message".to_string(),
2981                summary: String::new(),
2982            }],
2983        }
2984    }
2985
2986    #[test]
2987    fn handle_amendments_file_non_terminal_returns_false() {
2988        // is_terminal=false → non-interactive warning, returns Ok(false) immediately.
2989        let cmd = make_twiddle_cmd();
2990        let amendments = make_amendment_file();
2991        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2992        let mut reader = std::io::Cursor::new(b"" as &[u8]);
2993        let result = cmd
2994            .handle_amendments_file(dummy_path, &amendments, false, &mut reader)
2995            .unwrap();
2996        assert!(!result, "non-terminal should return false");
2997    }
2998
2999    #[test]
3000    fn handle_amendments_file_eof_returns_false() {
3001        // is_terminal=true, EOF reader → read_line returns 0, returns Ok(false).
3002        let cmd = make_twiddle_cmd();
3003        let amendments = make_amendment_file();
3004        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3005        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3006        let result = cmd
3007            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3008            .unwrap();
3009        assert!(!result, "EOF should return false");
3010    }
3011
3012    #[test]
3013    fn handle_amendments_file_quit_returns_false() {
3014        // is_terminal=true, "q\n" → user quits, returns Ok(false).
3015        let cmd = make_twiddle_cmd();
3016        let amendments = make_amendment_file();
3017        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3018        let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
3019        let result = cmd
3020            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3021            .unwrap();
3022        assert!(!result, "quit should return false");
3023    }
3024
3025    #[test]
3026    fn handle_amendments_file_apply_returns_true() {
3027        // is_terminal=true, "a\n" → user applies, returns Ok(true).
3028        let cmd = make_twiddle_cmd();
3029        let amendments = make_amendment_file();
3030        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3031        let mut reader = std::io::Cursor::new(b"a\n" as &[u8]);
3032        let result = cmd
3033            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3034            .unwrap();
3035        assert!(result, "apply should return true");
3036    }
3037
3038    #[test]
3039    fn handle_amendments_file_invalid_then_quit_returns_false() {
3040        // is_terminal=true, invalid input then "q\n" → prints error, then user quits.
3041        let cmd = make_twiddle_cmd();
3042        let amendments = make_amendment_file();
3043        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3044        let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
3045        let result = cmd
3046            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3047            .unwrap();
3048        assert!(!result, "invalid then quit should return false");
3049    }
3050
3051    // --- run_interactive_retry_generate_amendments ---
3052
3053    /// Full 40-char hex hash used for amendment retry tests (validation requires ≥40 chars).
3054    const HASH_40: &str = "abc0000000000000000000000000000000000000";
3055
3056    fn twiddle_amendment_yaml(hash: &str) -> String {
3057        format!("amendments:\n  - commit: \"{hash}\"\n    message: \"feat: improved message\"\n")
3058    }
3059
3060    #[tokio::test]
3061    async fn retry_generate_amendments_non_terminal_returns_immediately() {
3062        // is_terminal=false → warning printed, returns Ok(()) without prompting.
3063        let (commit, _tmp) = make_twiddle_commit("abc00000");
3064        let cmd = make_twiddle_cmd();
3065        let repo_view = make_twiddle_repo_view(vec![commit]);
3066        let client = make_mock_client(vec![]); // no calls expected
3067        let mut failed = vec![0usize];
3068        let mut successes = vec![];
3069        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3070        cmd.run_interactive_retry_generate_amendments(
3071            &mut failed,
3072            &repo_view,
3073            &client,
3074            None,
3075            false,
3076            &mut successes,
3077            false, // is_terminal
3078            &mut reader,
3079        )
3080        .await
3081        .unwrap();
3082        assert_eq!(
3083            failed,
3084            vec![0],
3085            "non-terminal should leave failed unchanged"
3086        );
3087        assert!(successes.is_empty());
3088    }
3089
3090    #[tokio::test]
3091    async fn retry_generate_amendments_eof_breaks_immediately() {
3092        // is_terminal=true, EOF → read_line returns 0 → breaks without AI calls.
3093        let (commit, _tmp) = make_twiddle_commit("abc00000");
3094        let cmd = make_twiddle_cmd();
3095        let repo_view = make_twiddle_repo_view(vec![commit]);
3096        let client = make_mock_client(vec![]); // no calls expected
3097        let mut failed = vec![0usize];
3098        let mut successes = vec![];
3099        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3100        cmd.run_interactive_retry_generate_amendments(
3101            &mut failed,
3102            &repo_view,
3103            &client,
3104            None,
3105            false,
3106            &mut successes,
3107            true, // is_terminal
3108            &mut reader,
3109        )
3110        .await
3111        .unwrap();
3112        assert_eq!(failed, vec![0], "EOF should leave failed unchanged");
3113        assert!(successes.is_empty());
3114    }
3115
3116    #[tokio::test]
3117    async fn retry_generate_amendments_skip_breaks_immediately() {
3118        // is_terminal=true, "s\n" → user skips, failed stays unchanged.
3119        let (commit, _tmp) = make_twiddle_commit("abc00000");
3120        let cmd = make_twiddle_cmd();
3121        let repo_view = make_twiddle_repo_view(vec![commit]);
3122        let client = make_mock_client(vec![]); // no calls expected
3123        let mut failed = vec![0usize];
3124        let mut successes = vec![];
3125        let mut reader = std::io::Cursor::new(b"s\n" as &[u8]);
3126        cmd.run_interactive_retry_generate_amendments(
3127            &mut failed,
3128            &repo_view,
3129            &client,
3130            None,
3131            false,
3132            &mut successes,
3133            true,
3134            &mut reader,
3135        )
3136        .await
3137        .unwrap();
3138        assert_eq!(failed, vec![0], "skip should leave failed unchanged");
3139        assert!(successes.is_empty());
3140    }
3141
3142    #[tokio::test]
3143    async fn retry_generate_amendments_invalid_then_skip() {
3144        // Unrecognised input → "please enter r or s" message → "s" exits.
3145        let (commit, _tmp) = make_twiddle_commit("abc00000");
3146        let cmd = make_twiddle_cmd();
3147        let repo_view = make_twiddle_repo_view(vec![commit]);
3148        let client = make_mock_client(vec![]);
3149        let mut failed = vec![0usize];
3150        let mut successes = vec![];
3151        let mut reader = std::io::Cursor::new(b"x\ns\n" as &[u8]);
3152        cmd.run_interactive_retry_generate_amendments(
3153            &mut failed,
3154            &repo_view,
3155            &client,
3156            None,
3157            false,
3158            &mut successes,
3159            true,
3160            &mut reader,
3161        )
3162        .await
3163        .unwrap();
3164        assert_eq!(failed, vec![0]);
3165        assert!(successes.is_empty());
3166    }
3167
3168    #[tokio::test]
3169    async fn retry_generate_amendments_retry_fails_then_skip() {
3170        // "r" → AI call fails → still in failed → "s" → skips.
3171        let (commit, _tmp) = make_twiddle_commit("abc00000");
3172        let cmd = make_twiddle_cmd();
3173        let repo_view = make_twiddle_repo_view(vec![commit]);
3174        let client = make_mock_client(vec![Err(anyhow::anyhow!("mock fail"))]);
3175        let mut failed = vec![0usize];
3176        let mut successes = vec![];
3177        let mut reader = std::io::Cursor::new(b"r\ns\n" as &[u8]);
3178        cmd.run_interactive_retry_generate_amendments(
3179            &mut failed,
3180            &repo_view,
3181            &client,
3182            None,
3183            false,
3184            &mut successes,
3185            true,
3186            &mut reader,
3187        )
3188        .await
3189        .unwrap();
3190        assert_eq!(failed, vec![0], "commit still failed after retry");
3191        assert!(successes.is_empty());
3192    }
3193
3194    #[tokio::test]
3195    async fn retry_generate_amendments_retry_succeeds() {
3196        // "r" → AI returns valid amendment → failed cleared, success recorded.
3197        let (commit, _tmp) = make_twiddle_commit(HASH_40);
3198        let cmd = make_twiddle_cmd();
3199        let repo_view = make_twiddle_repo_view(vec![commit]);
3200        let client = make_mock_client(vec![Ok(twiddle_amendment_yaml(HASH_40))]);
3201        let mut failed = vec![0usize];
3202        let mut successes = vec![];
3203        let mut reader = std::io::Cursor::new(b"r\n" as &[u8]);
3204        cmd.run_interactive_retry_generate_amendments(
3205            &mut failed,
3206            &repo_view,
3207            &client,
3208            None,
3209            false,
3210            &mut successes,
3211            true,
3212            &mut reader,
3213        )
3214        .await
3215        .unwrap();
3216        assert!(failed.is_empty(), "retry succeeded → failed cleared");
3217        assert_eq!(successes.len(), 1);
3218    }
3219
3220    #[test]
3221    fn refine_amendment_scopes_replaces_scope_from_file_patterns() {
3222        use crate::data::amendments::Amendment;
3223        use crate::data::context::ScopeDefinition;
3224        use crate::git::commit::FileChange;
3225
3226        // Build a commit whose files match the "cli" scope pattern.
3227        let (mut commit, _tmp) = make_twiddle_commit("aaa00000");
3228        commit.analysis.file_changes.file_list = vec![FileChange {
3229            status: "M".to_string(),
3230            file: "src/cli/git/twiddle.rs".to_string(),
3231        }];
3232
3233        let repo_view = make_twiddle_repo_view(vec![commit]);
3234
3235        let scope_defs = vec![ScopeDefinition {
3236            name: "cli".to_string(),
3237            description: "CLI commands".to_string(),
3238            examples: vec![],
3239            file_patterns: vec!["src/cli/**".to_string()],
3240        }];
3241
3242        let mut amendments = AmendmentFile {
3243            amendments: vec![Amendment {
3244                commit: "aaa00000".to_string(),
3245                message: "fix(wrong-scope): tweak something".to_string(),
3246                summary: String::new(),
3247            }],
3248        };
3249
3250        refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
3251
3252        assert_eq!(
3253            amendments.amendments[0].message,
3254            "fix(cli): tweak something",
3255        );
3256    }
3257
3258    #[test]
3259    fn refine_amendment_scopes_no_match_leaves_message_unchanged() {
3260        use crate::data::amendments::Amendment;
3261
3262        let (commit, _tmp) = make_twiddle_commit("bbb00000");
3263        let repo_view = make_twiddle_repo_view(vec![commit]);
3264
3265        let mut amendments = AmendmentFile {
3266            amendments: vec![Amendment {
3267                commit: "bbb00000".to_string(),
3268                message: "feat(stuff): add feature".to_string(),
3269                summary: String::new(),
3270            }],
3271        };
3272
3273        // No scope defs → no refinement.
3274        refine_amendment_scopes(&mut amendments, &repo_view, &[]);
3275
3276        assert_eq!(amendments.amendments[0].message, "feat(stuff): add feature",);
3277    }
3278
3279    // --- resolve_duplicate_amendments ---
3280
3281    fn dup_hash(byte: char) -> String {
3282        std::iter::repeat(byte).take(40).collect()
3283    }
3284
3285    fn dup_amendments(items: &[(&str, &str)]) -> AmendmentFile {
3286        use crate::data::amendments::Amendment;
3287        AmendmentFile {
3288            amendments: items
3289                .iter()
3290                .map(|(hash, msg)| Amendment {
3291                    commit: (*hash).to_string(),
3292                    message: (*msg).to_string(),
3293                    summary: String::new(),
3294                })
3295                .collect(),
3296        }
3297    }
3298
3299    #[test]
3300    fn resolve_duplicates_empty_is_noop() {
3301        let mut af = AmendmentFile { amendments: vec![] };
3302        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3303        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3304        assert!(af.amendments.is_empty());
3305    }
3306
3307    #[test]
3308    fn resolve_duplicates_single_is_noop() {
3309        let h = dup_hash('a');
3310        let mut af = dup_amendments(&[(&h, "feat: only")]);
3311        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3312        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3313        assert_eq!(af.amendments.len(), 1);
3314        assert_eq!(af.amendments[0].message, "feat: only");
3315    }
3316
3317    #[test]
3318    fn resolve_duplicates_no_dups_unchanged() {
3319        let h_a = dup_hash('a');
3320        let h_b = dup_hash('b');
3321        let mut af = dup_amendments(&[(&h_a, "feat: a"), (&h_b, "feat: b")]);
3322        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3323        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3324        assert_eq!(af.amendments.len(), 2);
3325        assert_eq!(af.amendments[0].message, "feat: a");
3326        assert_eq!(af.amendments[1].message, "feat: b");
3327    }
3328
3329    #[test]
3330    fn resolve_duplicates_auto_pick_keeps_first() {
3331        let h = dup_hash('a');
3332        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3333        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3334        resolve_duplicate_amendments(&mut af, true, true, &mut reader).unwrap();
3335        assert_eq!(af.amendments.len(), 1);
3336        assert_eq!(af.amendments[0].message, "feat: first");
3337    }
3338
3339    #[test]
3340    fn resolve_duplicates_non_terminal_keeps_first() {
3341        let h = dup_hash('a');
3342        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3343        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3344        resolve_duplicate_amendments(&mut af, false, false, &mut reader).unwrap();
3345        assert_eq!(af.amendments.len(), 1);
3346        assert_eq!(af.amendments[0].message, "feat: first");
3347    }
3348
3349    #[test]
3350    fn resolve_duplicates_prompt_picks_second() {
3351        let h = dup_hash('a');
3352        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3353        let mut reader = std::io::Cursor::new(b"2\n" as &[u8]);
3354        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3355        assert_eq!(af.amendments.len(), 1);
3356        assert_eq!(af.amendments[0].message, "feat: second");
3357    }
3358
3359    #[test]
3360    fn resolve_duplicates_prompt_default_picks_first() {
3361        let h = dup_hash('a');
3362        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3363        let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
3364        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3365        assert_eq!(af.amendments.len(), 1);
3366        assert_eq!(af.amendments[0].message, "feat: first");
3367    }
3368
3369    #[test]
3370    fn resolve_duplicates_prompt_invalid_then_valid() {
3371        let h = dup_hash('a');
3372        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3373        let mut reader = std::io::Cursor::new(b"x\n9\n2\n" as &[u8]);
3374        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3375        assert_eq!(af.amendments.len(), 1);
3376        assert_eq!(af.amendments[0].message, "feat: second");
3377    }
3378
3379    #[test]
3380    fn resolve_duplicates_prompt_eof_keeps_first() {
3381        let h = dup_hash('a');
3382        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3383        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3384        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3385        assert_eq!(af.amendments.len(), 1);
3386        assert_eq!(af.amendments[0].message, "feat: first");
3387    }
3388
3389    #[test]
3390    fn resolve_duplicates_preserves_unique_amendments_order() {
3391        let h_a = dup_hash('a');
3392        let h_b = dup_hash('b');
3393        let h_c = dup_hash('c');
3394        let mut af = dup_amendments(&[
3395            (&h_a, "feat: a1"),
3396            (&h_b, "feat: b"),
3397            (&h_a, "feat: a2"),
3398            (&h_c, "feat: c"),
3399        ]);
3400        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3401        resolve_duplicate_amendments(&mut af, true, true, &mut reader).unwrap();
3402        assert_eq!(af.amendments.len(), 3);
3403        assert_eq!(af.amendments[0].commit, h_a);
3404        assert_eq!(af.amendments[0].message, "feat: a1");
3405        assert_eq!(af.amendments[1].commit, h_b);
3406        assert_eq!(af.amendments[2].commit, h_c);
3407    }
3408
3409    #[test]
3410    fn resolve_duplicates_three_way_picks_third() {
3411        let h = dup_hash('a');
3412        let mut af = dup_amendments(&[
3413            (&h, "feat: first"),
3414            (&h, "feat: second"),
3415            (&h, "feat: third"),
3416        ]);
3417        let mut reader = std::io::Cursor::new(b"3\n" as &[u8]);
3418        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3419        assert_eq!(af.amendments.len(), 1);
3420        assert_eq!(af.amendments[0].message, "feat: third");
3421    }
3422
3423    #[test]
3424    fn refine_amendment_scopes_skips_unknown_commits() {
3425        use crate::data::amendments::Amendment;
3426        use crate::data::context::ScopeDefinition;
3427
3428        let (commit, _tmp) = make_twiddle_commit("ccc00000");
3429        let repo_view = make_twiddle_repo_view(vec![commit]);
3430
3431        let scope_defs = vec![ScopeDefinition {
3432            name: "cli".to_string(),
3433            description: "CLI".to_string(),
3434            examples: vec![],
3435            file_patterns: vec!["src/cli/**".to_string()],
3436        }];
3437
3438        let mut amendments = AmendmentFile {
3439            amendments: vec![Amendment {
3440                commit: "unknown_hash".to_string(),
3441                message: "fix(wrong): something".to_string(),
3442                summary: String::new(),
3443            }],
3444        };
3445
3446        refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
3447
3448        // Message unchanged because commit wasn't found in repo_view.
3449        assert_eq!(amendments.amendments[0].message, "fix(wrong): something",);
3450    }
3451}