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