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