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