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