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