1use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::debug;
6
7use super::parse_beta_header;
8
9#[derive(Parser)]
11pub struct TwiddleCommand {
12 #[arg(value_name = "COMMIT_RANGE")]
14 pub commit_range: Option<String>,
15
16 #[arg(long)]
18 pub model: Option<String>,
19
20 #[arg(long, value_name = "KEY:VALUE")]
23 pub beta_header: Option<String>,
24
25 #[arg(long)]
27 pub auto_apply: bool,
28
29 #[arg(long, value_name = "FILE")]
31 pub save_only: Option<String>,
32
33 #[arg(long, default_value = "true")]
35 pub use_context: bool,
36
37 #[arg(long)]
39 pub context_dir: Option<std::path::PathBuf>,
40
41 #[arg(long)]
43 pub work_context: Option<String>,
44
45 #[arg(long)]
47 pub branch_context: Option<String>,
48
49 #[arg(long)]
51 pub no_context: bool,
52
53 #[arg(long, default_value = "4")]
55 pub concurrency: usize,
56
57 #[arg(long, hide = true)]
59 pub batch_size: Option<usize>,
60
61 #[arg(long)]
63 pub no_coherence: bool,
64
65 #[arg(long)]
67 pub no_ai: bool,
68
69 #[arg(long, conflicts_with = "refine")]
72 pub fresh: bool,
73
74 #[arg(long, conflicts_with = "fresh")]
77 pub refine: bool,
78
79 #[arg(long)]
81 pub check: bool,
82
83 #[arg(long)]
85 pub quiet: bool,
86}
87
88impl TwiddleCommand {
89 fn is_fresh(&self) -> bool {
92 !self.refine
93 }
94
95 pub async fn execute(mut self) -> Result<()> {
97 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 self.no_ai {
105 return self.execute_no_ai().await;
106 }
107
108 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 crate::utils::preflight::check_working_directory_clean()?;
117 println!("✓ Working directory is clean");
118
119 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 let mut full_repo_view = self.generate_repository_view().await?;
132
133 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 let context = if use_contextual {
142 Some(self.collect_context(&full_repo_view).await?)
143 } else {
144 None
145 };
146
147 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 if let Some(ref ctx) = context {
158 self.show_context_summary(ctx)?;
159 }
160
161 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 self.show_model_info_from_client(&claude_client)?;
171
172 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 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 if !amendments.amendments.is_empty() {
201 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 {
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 self.apply_amendments_from_file(&amendments_file).await?;
224 println!("✅ Commit messages improved successfully!");
225
226 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 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 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 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 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 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 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 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 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 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 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 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 let idx = batch_indices[0];
432 eprintln!("warning: failed to process commit: {e}");
433 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 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 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 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 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 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 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 let repo = GitRepository::open()
576 .context("Failed to open git repository. Make sure you're in a git repository.")?;
577
578 let current_branch = repo
580 .get_current_branch()
581 .unwrap_or_else(|_| "HEAD".to_string());
582
583 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 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
599
600 let commits = repo.get_commits_in_range(commit_range)?;
602
603 let versions = Some(VersionInfo {
605 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
606 });
607
608 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 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 repo_view.update_field_presence();
633
634 Ok(repo_view)
635 }
636
637 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 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 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 let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
715 e
716 } else {
717 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 async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
764 use crate::git::AmendmentHandler;
765
766 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 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 let (context_dir, dir_source) =
789 crate::claude::context::resolve_context_dir_with_source(self.context_dir.as_deref());
790
791 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 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 if let Some(branch_info) = &repo_view.branch_info {
812 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
813 } else {
814 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(¤t_branch).unwrap_or_default();
821 }
822
823 if !repo_view.commits.is_empty() {
825 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
826 }
827
828 if !repo_view.commits.is_empty() {
830 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
831 }
832
833 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 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
847 println!("🔍 Context Analysis:");
848
849 if !context.project.valid_scopes.is_empty() {
851 println!(
852 " 📁 Valid scopes: {}",
853 format_scope_list(&context.project.valid_scopes)
854 );
855 }
856
857 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 if let Some(label) = format_work_pattern(&context.range.work_pattern) {
870 println!(" {label}");
871 }
872
873 if let Some(label) = super::formatting::format_file_analysis(&context.files) {
875 println!(" {label}");
876 }
877
878 println!(
880 " {}",
881 format_verbosity_level(context.suggested_verbosity())
882 );
883
884 if let Some(ref user_ctx) = context.user_provided {
886 println!(" 👤 User context: {user_ctx}");
887 }
888
889 println!();
890 Ok(())
891 }
892
893 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 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 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 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 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 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 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 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 let repo_view = self.generate_repository_view().await?;
998
999 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 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 if !amendment_file.amendments.is_empty() {
1021 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 {
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 self.apply_amendments_from_file(&amendments_file).await?;
1044 println!("✅ Commit messages applied successfully!");
1045
1046 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 async fn run_post_twiddle_check(&self) -> Result<()> {
1061 use crate::data::amendments::AmendmentFile;
1062
1063 const MAX_CHECK_RETRIES: u32 = 3;
1064
1065 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 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 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 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 self.output_check_text_report(&report)?;
1129
1130 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1652 println!();
1653
1654 for result in &report.commits {
1655 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 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 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 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
1703fn 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
1720fn 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
1732fn 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 #[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 #[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 #[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 #[test]
1851 fn context_dir_default() {
1852 let result = crate::claude::context::resolve_context_dir(None);
1853 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 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 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 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 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 #[tokio::test]
2023 async fn interactive_retry_twiddle_skip_immediately() {
2024 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 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 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 let (commit, _tmp) = make_twiddle_commit("abc00000");
2108 let cmd = make_twiddle_cmd();
2109 let repo_view = make_twiddle_repo_view(vec![commit]);
2110 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 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 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![]); 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 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 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 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 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 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 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 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 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![]); 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, &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 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![]); 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, &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 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![]); 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 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 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 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}