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