1use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::debug;
6
7use super::parse_beta_header;
8use crate::data::amendments::AmendmentFile;
9use crate::data::RepositoryView;
10
11#[derive(Parser)]
13pub struct TwiddleCommand {
14 #[arg(value_name = "COMMIT_RANGE")]
16 pub commit_range: Option<String>,
17
18 #[arg(long)]
20 pub model: Option<String>,
21
22 #[arg(long, value_name = "KEY:VALUE")]
25 pub beta_header: Option<String>,
26
27 #[arg(long)]
29 pub auto_apply: bool,
30
31 #[arg(long, value_name = "FILE")]
33 pub save_only: Option<String>,
34
35 #[arg(long, default_value = "true")]
37 pub use_context: bool,
38
39 #[arg(long)]
41 pub context_dir: Option<std::path::PathBuf>,
42
43 #[arg(long)]
45 pub work_context: Option<String>,
46
47 #[arg(long)]
49 pub branch_context: Option<String>,
50
51 #[arg(long)]
53 pub no_context: bool,
54
55 #[arg(long, default_value = "4")]
57 pub concurrency: usize,
58
59 #[arg(long, hide = true)]
61 pub batch_size: Option<usize>,
62
63 #[arg(long)]
65 pub no_coherence: bool,
66
67 #[arg(long)]
69 pub no_ai: bool,
70
71 #[arg(long, conflicts_with = "refine")]
74 pub fresh: bool,
75
76 #[arg(long, conflicts_with = "fresh")]
79 pub refine: bool,
80
81 #[arg(long)]
83 pub check: bool,
84
85 #[arg(long)]
87 pub quiet: bool,
88}
89
90impl TwiddleCommand {
91 fn is_fresh(&self) -> bool {
94 !self.refine
95 }
96
97 pub async fn execute(mut self) -> Result<()> {
99 if let Some(bs) = self.batch_size {
101 eprintln!("warning: --batch-size is deprecated; use --concurrency instead");
102 self.concurrency = bs;
103 }
104
105 if self.no_ai {
107 return self.execute_no_ai().await;
108 }
109
110 let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
112 println!(
113 "✓ {} credentials verified (model: {})",
114 ai_info.provider, ai_info.model
115 );
116
117 crate::utils::preflight::check_working_directory_clean()?;
119 println!("✓ Working directory is clean");
120
121 let use_contextual = self.use_context && !self.no_context;
123
124 if use_contextual {
125 println!(
126 "🪄 Starting AI-powered commit message improvement with contextual intelligence..."
127 );
128 } else {
129 println!("🪄 Starting AI-powered commit message improvement...");
130 }
131
132 let mut full_repo_view = self.generate_repository_view().await?;
134
135 if full_repo_view.commits.len() > 1 {
137 return self
138 .execute_with_map_reduce(use_contextual, full_repo_view)
139 .await;
140 }
141
142 let context = if use_contextual {
144 Some(self.collect_context(&full_repo_view).await?)
145 } else {
146 None
147 };
148
149 let scope_defs = match &context {
151 Some(ctx) => ctx.project.valid_scopes.clone(),
152 None => self.load_check_scopes(),
153 };
154 for commit in &mut full_repo_view.commits {
155 commit.analysis.refine_scope(&scope_defs);
156 }
157
158 if let Some(ref ctx) = context {
160 self.show_context_summary(ctx)?;
161 }
162
163 let beta = self
165 .beta_header
166 .as_deref()
167 .map(parse_beta_header)
168 .transpose()?;
169 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
170
171 self.show_model_info_from_client(&claude_client)?;
173
174 if self.refine {
176 println!("🔄 Refine mode: using existing commit messages as starting point...");
177 }
178 if use_contextual && context.is_some() {
179 println!("🤖 Analyzing commits with enhanced contextual intelligence...");
180 } else {
181 println!("🤖 Analyzing commits with Claude AI...");
182 }
183
184 let mut amendments = if let Some(ctx) = context {
185 claude_client
186 .generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.is_fresh())
187 .await?
188 } else {
189 claude_client
190 .generate_amendments_with_options(&full_repo_view, self.is_fresh())
191 .await?
192 };
193
194 refine_amendment_scopes(&mut amendments, &full_repo_view, &scope_defs);
195
196 if let Some(save_path) = self.save_only {
198 amendments.save_to_file(save_path)?;
199 println!("💾 Amendments saved to file");
200 return Ok(());
201 }
202
203 if !amendments.amendments.is_empty() {
205 let temp_dir = tempfile::tempdir()?;
207 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
208 amendments.save_to_file(&amendments_file)?;
209
210 {
212 use std::io::IsTerminal;
213 if !self.auto_apply
214 && !self.handle_amendments_file(
215 &amendments_file,
216 &amendments,
217 std::io::stdin().is_terminal(),
218 &mut std::io::BufReader::new(std::io::stdin()),
219 )?
220 {
221 println!("❌ Amendment cancelled by user");
222 return Ok(());
223 }
224 }
225
226 self.apply_amendments_from_file(&amendments_file).await?;
228 println!("✅ Commit messages improved successfully!");
229
230 if self.check {
232 self.run_post_twiddle_check().await?;
233 }
234 } else {
235 println!("✨ No commits found to process!");
236 }
237
238 Ok(())
239 }
240
241 async fn execute_with_map_reduce(
248 &self,
249 use_contextual: bool,
250 mut full_repo_view: crate::data::RepositoryView,
251 ) -> Result<()> {
252 use std::sync::atomic::{AtomicUsize, Ordering};
253 use std::sync::Arc;
254
255 use crate::claude::batch;
256 use crate::claude::token_budget;
257
258 let concurrency = self.concurrency;
259
260 let beta = self
262 .beta_header
263 .as_deref()
264 .map(parse_beta_header)
265 .transpose()?;
266 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
267
268 self.show_model_info_from_client(&claude_client)?;
270
271 if self.refine {
272 println!("🔄 Refine mode: using existing commit messages as starting point...");
273 }
274
275 let total_commits = full_repo_view.commits.len();
276 println!(
277 "🔄 Processing {total_commits} commits in parallel (concurrency: {concurrency})..."
278 );
279
280 let context = if use_contextual {
282 Some(self.collect_context(&full_repo_view).await?)
283 } else {
284 None
285 };
286
287 if let Some(ref ctx) = context {
288 self.show_context_summary(ctx)?;
289 }
290
291 let scope_defs = match &context {
293 Some(ctx) => ctx.project.valid_scopes.clone(),
294 None => self.load_check_scopes(),
295 };
296 for commit in &mut full_repo_view.commits {
297 commit.analysis.refine_scope(&scope_defs);
298 }
299
300 let metadata = claude_client.get_ai_client_metadata();
302 let system_prompt_tokens = if let Some(ref ctx) = context {
303 let prompt_style = metadata.prompt_style();
304 let system_prompt =
305 crate::claude::prompts::generate_contextual_system_prompt_for_provider(
306 ctx,
307 prompt_style,
308 );
309 token_budget::estimate_tokens(&system_prompt)
310 } else {
311 token_budget::estimate_tokens(crate::claude::prompts::SYSTEM_PROMPT)
312 };
313 let batch_plan =
314 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
315
316 if batch_plan.batches.len() < total_commits {
317 println!(
318 " 📦 Grouped {} commits into {} batches by token budget",
319 total_commits,
320 batch_plan.batches.len()
321 );
322 }
323
324 let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
326 let completed = Arc::new(AtomicUsize::new(0));
327
328 let repo_ref = &full_repo_view;
329 let client_ref = &claude_client;
330 let context_ref = &context;
331 let fresh = self.is_fresh();
332
333 let futs: Vec<_> = batch_plan
334 .batches
335 .iter()
336 .map(|batch| {
337 let sem = semaphore.clone();
338 let completed = completed.clone();
339 let batch_indices = &batch.commit_indices;
340
341 async move {
342 let _permit = sem
343 .acquire()
344 .await
345 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
346
347 let batch_size = batch_indices.len();
348
349 let batch_view = if batch_size == 1 {
351 repo_ref.single_commit_view(&repo_ref.commits[batch_indices[0]])
352 } else {
353 let commits: Vec<_> = batch_indices
354 .iter()
355 .map(|&i| &repo_ref.commits[i])
356 .collect();
357 repo_ref.multi_commit_view(&commits)
358 };
359
360 let result = if let Some(ref ctx) = context_ref {
362 client_ref
363 .generate_contextual_amendments_with_options(&batch_view, ctx, fresh)
364 .await
365 } else {
366 client_ref
367 .generate_amendments_with_options(&batch_view, fresh)
368 .await
369 };
370
371 match result {
372 Ok(amendment_file) => {
373 let done =
374 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
375 println!(" ✅ {done}/{total_commits} commits processed");
376
377 let items: Vec<_> = amendment_file
378 .amendments
379 .into_iter()
380 .map(|a| {
381 let summary = a.summary.clone().unwrap_or_default();
382 (a, summary)
383 })
384 .collect();
385 Ok::<_, anyhow::Error>((items, vec![]))
386 }
387 Err(e) if batch_size > 1 => {
388 eprintln!(
390 "warning: batch of {batch_size} failed, retrying individually: {e}"
391 );
392 let mut items = Vec::new();
393 let mut failed_indices = Vec::new();
394 for &idx in batch_indices {
395 let single_view =
396 repo_ref.single_commit_view(&repo_ref.commits[idx]);
397 let single_result = if let Some(ref ctx) = context_ref {
398 client_ref
399 .generate_contextual_amendments_with_options(
400 &single_view,
401 ctx,
402 fresh,
403 )
404 .await
405 } else {
406 client_ref
407 .generate_amendments_with_options(&single_view, fresh)
408 .await
409 };
410 match single_result {
411 Ok(af) => {
412 if let Some(a) = af.amendments.into_iter().next() {
413 let summary = a.summary.clone().unwrap_or_default();
414 items.push((a, summary));
415 }
416 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
417 println!(" ✅ {done}/{total_commits} commits processed");
418 }
419 Err(e) => {
420 eprintln!("warning: failed to process commit: {e}");
421 for (i, cause) in e.chain().skip(1).enumerate() {
423 eprintln!(" caused by [{i}]: {cause}");
424 }
425 failed_indices.push(idx);
426 println!(" ❌ commit processing failed");
427 }
428 }
429 }
430 Ok((items, failed_indices))
431 }
432 Err(e) => {
433 let idx = batch_indices[0];
435 eprintln!("warning: failed to process commit: {e}");
436 for (i, cause) in e.chain().skip(1).enumerate() {
438 eprintln!(" caused by [{i}]: {cause}");
439 }
440 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
441 println!(" ❌ {done}/{total_commits} commits processed (failed)");
442 Ok((vec![], vec![idx]))
443 }
444 }
445 }
446 })
447 .collect();
448
449 let results = futures::future::join_all(futs).await;
450
451 let mut successes: Vec<(crate::data::amendments::Amendment, String)> = Vec::new();
453 let mut failed_indices: Vec<usize> = Vec::new();
454
455 for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
456 match result {
457 Ok((items, failed)) => {
458 successes.extend(items);
459 failed_indices.extend(failed);
460 }
461 Err(e) => {
462 eprintln!("warning: batch processing error: {e}");
463 failed_indices.extend(&batch.commit_indices);
464 }
465 }
466 }
467
468 if !failed_indices.is_empty() && !self.quiet {
470 use std::io::IsTerminal;
471 self.run_interactive_retry_generate_amendments(
472 &mut failed_indices,
473 &full_repo_view,
474 &claude_client,
475 context.as_ref(),
476 fresh,
477 &mut successes,
478 std::io::stdin().is_terminal(),
479 &mut std::io::BufReader::new(std::io::stdin()),
480 )
481 .await?;
482 } else if !failed_indices.is_empty() {
483 eprintln!(
484 "warning: {} commit(s) failed to process",
485 failed_indices.len()
486 );
487 }
488
489 if !failed_indices.is_empty() {
490 eprintln!(
491 "warning: {} commit(s) ultimately failed to process",
492 failed_indices.len()
493 );
494 }
495
496 if successes.is_empty() {
497 anyhow::bail!("All commits failed to process");
498 }
499
500 let single_batch = batch_plan.batches.len() <= 1;
503 let mut all_amendments = if !self.no_coherence && !single_batch && successes.len() >= 2 {
504 println!("🔗 Running cross-commit coherence pass...");
505 match claude_client.refine_amendments_coherence(&successes).await {
506 Ok(refined) => refined,
507 Err(e) => {
508 eprintln!("warning: coherence pass failed, using individual results: {e}");
509 AmendmentFile {
510 amendments: successes.into_iter().map(|(a, _)| a).collect(),
511 }
512 }
513 }
514 } else {
515 AmendmentFile {
516 amendments: successes.into_iter().map(|(a, _)| a).collect(),
517 }
518 };
519
520 refine_amendment_scopes(&mut all_amendments, &full_repo_view, &scope_defs);
521
522 println!(
523 "✅ All commits processed! Found {} amendments.",
524 all_amendments.amendments.len()
525 );
526
527 if let Some(save_path) = &self.save_only {
529 all_amendments.save_to_file(save_path)?;
530 println!("💾 Amendments saved to file");
531 return Ok(());
532 }
533
534 if !all_amendments.amendments.is_empty() {
536 let temp_dir = tempfile::tempdir()?;
537 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
538 all_amendments.save_to_file(&amendments_file)?;
539
540 {
541 use std::io::IsTerminal;
542 if !self.auto_apply
543 && !self.handle_amendments_file(
544 &amendments_file,
545 &all_amendments,
546 std::io::stdin().is_terminal(),
547 &mut std::io::BufReader::new(std::io::stdin()),
548 )?
549 {
550 println!("❌ Amendment cancelled by user");
551 return Ok(());
552 }
553 }
554
555 self.apply_amendments_from_file(&amendments_file).await?;
556 println!("✅ Commit messages improved successfully!");
557
558 if self.check {
559 self.run_post_twiddle_check().await?;
560 }
561 } else {
562 println!("✨ No commits found to process!");
563 }
564
565 Ok(())
566 }
567
568 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
570 use crate::data::{
571 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
572 WorkingDirectoryInfo,
573 };
574 use crate::git::{GitRepository, RemoteInfo};
575 use crate::utils::ai_scratch;
576
577 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
578
579 let repo = GitRepository::open()
581 .context("Failed to open git repository. Make sure you're in a git repository.")?;
582
583 let current_branch = repo
585 .get_current_branch()
586 .unwrap_or_else(|_| "HEAD".to_string());
587
588 let wd_status = repo.get_working_directory_status()?;
590 let working_directory = WorkingDirectoryInfo {
591 clean: wd_status.clean,
592 untracked_changes: wd_status
593 .untracked_changes
594 .into_iter()
595 .map(|fs| FileStatusInfo {
596 status: fs.status,
597 file: fs.file,
598 })
599 .collect(),
600 };
601
602 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
604
605 let commits = repo.get_commits_in_range(commit_range)?;
607
608 let versions = Some(VersionInfo {
610 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
611 });
612
613 let ai_scratch_path =
615 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
616 let ai_info = AiInfo {
617 scratch: ai_scratch_path.to_string_lossy().to_string(),
618 };
619
620 let mut repo_view = RepositoryView {
622 versions,
623 explanation: FieldExplanation::default(),
624 working_directory,
625 remotes,
626 ai: ai_info,
627 branch_info: Some(BranchInfo {
628 branch: current_branch,
629 }),
630 pr_template: None,
631 pr_template_location: None,
632 branch_prs: None,
633 commits,
634 };
635
636 repo_view.update_field_presence();
638
639 Ok(repo_view)
640 }
641
642 fn handle_amendments_file(
647 &self,
648 amendments_file: &std::path::Path,
649 amendments: &crate::data::amendments::AmendmentFile,
650 is_terminal: bool,
651 reader: &mut (dyn std::io::BufRead + Send),
652 ) -> Result<bool> {
653 use std::io::{self, Write};
654
655 println!(
656 "\n📝 Found {} commits that could be improved.",
657 amendments.amendments.len()
658 );
659 println!("💾 Amendments saved to: {}", amendments_file.display());
660 println!();
661
662 if !is_terminal {
663 eprintln!("warning: stdin is not interactive, cannot prompt for amendments");
664 return Ok(false);
665 }
666
667 loop {
668 print!("❓ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
669 io::stdout().flush()?;
670
671 let Some(input) = super::read_interactive_line(reader)? else {
672 eprintln!("warning: stdin closed, cancelling amendments");
673 return Ok(false);
674 };
675
676 match input.trim().to_lowercase().as_str() {
677 "a" | "apply" | "" => return Ok(true),
678 "s" | "show" => {
679 self.show_amendments_file(amendments_file)?;
680 println!();
681 }
682 "e" | "edit" => {
683 self.edit_amendments_file(amendments_file)?;
684 println!();
685 }
686 "q" | "quit" => return Ok(false),
687 _ => {
688 println!(
689 "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
690 );
691 }
692 }
693 }
694 }
695
696 fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
698 use std::fs;
699
700 println!("\n📄 Amendments file contents:");
701 println!("─────────────────────────────");
702
703 let contents =
704 fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
705
706 println!("{contents}");
707 println!("─────────────────────────────");
708
709 Ok(())
710 }
711
712 fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
714 use std::env;
715 use std::io::{self, Write};
716 use std::process::Command;
717
718 let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
720 e
721 } else {
722 println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
724 print!("Please enter the command to use as your editor: ");
725 io::stdout().flush().context("Failed to flush stdout")?;
726
727 let mut input = String::new();
728 io::stdin()
729 .read_line(&mut input)
730 .context("Failed to read user input")?;
731 input.trim().to_string()
732 };
733
734 if editor.is_empty() {
735 println!("❌ No editor specified. Returning to menu.");
736 return Ok(());
737 }
738
739 println!("📝 Opening amendments file in editor: {editor}");
740
741 let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
742
743 let mut command = Command::new(editor_cmd);
744 command.args(args);
745 command.arg(amendments_file.to_string_lossy().as_ref());
746
747 match command.status() {
748 Ok(status) => {
749 if status.success() {
750 println!("✅ Editor session completed.");
751 } else {
752 println!(
753 "⚠️ Editor exited with non-zero status: {:?}",
754 status.code()
755 );
756 }
757 }
758 Err(e) => {
759 println!("❌ Failed to execute editor '{editor}': {e}");
760 println!(" Please check that the editor command is correct and available in your PATH.");
761 }
762 }
763
764 Ok(())
765 }
766
767 async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
769 use crate::git::AmendmentHandler;
770
771 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
773 handler
774 .apply_amendments(&amendments_file.to_string_lossy())
775 .context("Failed to apply amendments")?;
776
777 Ok(())
778 }
779
780 async fn collect_context(
782 &self,
783 repo_view: &crate::data::RepositoryView,
784 ) -> Result<crate::data::context::CommitContext> {
785 use crate::claude::context::{
786 BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
787 };
788 use crate::data::context::CommitContext;
789
790 let mut context = CommitContext::new();
791
792 let (context_dir, dir_source) =
794 crate::claude::context::resolve_context_dir_with_source(self.context_dir.as_deref());
795
796 let repo_root = std::path::PathBuf::from(".");
798 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
799 debug!(context_dir = ?context_dir, "Using context directory");
800 match discovery.discover() {
801 Ok(project_context) => {
802 debug!("Discovery successful");
803
804 self.show_guidance_files_status(&project_context, &context_dir, &dir_source)?;
806
807 context.project = project_context;
808 }
809 Err(e) => {
810 debug!(error = %e, "Discovery failed");
811 context.project = crate::data::context::ProjectContext::default();
812 }
813 }
814
815 if let Some(branch_info) = &repo_view.branch_info {
817 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
818 } else {
819 use crate::git::GitRepository;
821 let repo = GitRepository::open()?;
822 let current_branch = repo
823 .get_current_branch()
824 .unwrap_or_else(|_| "HEAD".to_string());
825 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
826 }
827
828 if !repo_view.commits.is_empty() {
830 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
831 }
832
833 if !repo_view.commits.is_empty() {
835 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
836 }
837
838 if let Some(ref work_ctx) = self.work_context {
840 context.user_provided = Some(work_ctx.clone());
841 }
842
843 if let Some(ref branch_ctx) = self.branch_context {
844 context.branch.description.clone_from(branch_ctx);
845 }
846
847 Ok(context)
848 }
849
850 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
852 println!("🔍 Context Analysis:");
853
854 if !context.project.valid_scopes.is_empty() {
856 println!(
857 " 📁 Valid scopes: {}",
858 format_scope_list(&context.project.valid_scopes)
859 );
860 }
861
862 if context.branch.is_feature_branch {
864 println!(
865 " 🌿 Branch: {} ({})",
866 context.branch.description, context.branch.work_type
867 );
868 if let Some(ref ticket) = context.branch.ticket_id {
869 println!(" 🎫 Ticket: {ticket}");
870 }
871 }
872
873 if let Some(label) = format_work_pattern(&context.range.work_pattern) {
875 println!(" {label}");
876 }
877
878 if let Some(label) = super::formatting::format_file_analysis(&context.files) {
880 println!(" {label}");
881 }
882
883 println!(
885 " {}",
886 format_verbosity_level(context.suggested_verbosity())
887 );
888
889 if let Some(ref user_ctx) = context.user_provided {
891 println!(" 👤 User context: {user_ctx}");
892 }
893
894 println!();
895 Ok(())
896 }
897
898 fn show_model_info_from_client(
900 &self,
901 client: &crate::claude::client::ClaudeClient,
902 ) -> Result<()> {
903 use crate::claude::model_config::get_model_registry;
904
905 println!("🤖 AI Model Configuration:");
906
907 let metadata = client.get_ai_client_metadata();
909 let registry = get_model_registry();
910
911 if let Some(spec) = registry.get_model_spec(&metadata.model) {
912 if metadata.model != spec.api_identifier {
914 println!(
915 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
916 metadata.model, spec.api_identifier
917 );
918 } else {
919 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
920 }
921
922 println!(" 🏷️ Provider: {}", spec.provider);
923 println!(" 📊 Generation: {}", spec.generation);
924 println!(" ⭐ Tier: {} ({})", spec.tier, {
925 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
926 &tier_info.description
927 } else {
928 "No description available"
929 }
930 });
931 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
932 println!(" 📥 Input context: {}", metadata.max_context_length);
933
934 if let Some((ref key, ref value)) = metadata.active_beta {
935 println!(" 🔬 Beta header: {key}: {value}");
936 }
937
938 if spec.legacy {
939 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
940 }
941 } else {
942 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
944 println!(" 🏷️ Provider: {}", metadata.provider);
945 println!(" ⚠️ Model not found in registry, using client metadata:");
946 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
947 println!(" 📥 Input context: {}", metadata.max_context_length);
948 }
949
950 println!();
951 Ok(())
952 }
953
954 fn show_guidance_files_status(
956 &self,
957 project_context: &crate::data::context::ProjectContext,
958 context_dir: &std::path::Path,
959 dir_source: &crate::claude::context::ConfigDirSource,
960 ) -> Result<()> {
961 use crate::claude::context::{config_source_label, ConfigSourceLabel};
962
963 println!("📋 Project guidance files status:");
964 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
965
966 let guidelines_source = if project_context.commit_guidelines.is_some() {
968 match config_source_label(context_dir, "commit-guidelines.md") {
969 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
970 label => format!("✅ {label}"),
971 }
972 } else {
973 "❌ None found".to_string()
974 };
975 println!(" 📝 Commit guidelines: {guidelines_source}");
976
977 let scopes_count = project_context.valid_scopes.len();
979 let scopes_source = if scopes_count > 0 {
980 match config_source_label(context_dir, "scopes.yaml") {
981 ConfigSourceLabel::NotFound => {
982 format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
983 }
984 label => format!("✅ {label} ({scopes_count} scopes)"),
985 }
986 } else {
987 "❌ None found".to_string()
988 };
989 println!(" 🎯 Valid scopes: {scopes_source}");
990
991 println!();
992 Ok(())
993 }
994
995 async fn execute_no_ai(&self) -> Result<()> {
997 use crate::data::amendments::{Amendment, AmendmentFile};
998
999 println!("📋 Generating amendments YAML without AI processing...");
1000
1001 let repo_view = self.generate_repository_view().await?;
1003
1004 let amendments: Vec<Amendment> = repo_view
1006 .commits
1007 .iter()
1008 .map(|commit| Amendment {
1009 commit: commit.hash.clone(),
1010 message: commit.original_message.clone(),
1011 summary: None,
1012 })
1013 .collect();
1014
1015 let amendment_file = AmendmentFile { amendments };
1016
1017 if let Some(save_path) = &self.save_only {
1019 amendment_file.save_to_file(save_path)?;
1020 println!("💾 Amendments saved to file");
1021 return Ok(());
1022 }
1023
1024 if !amendment_file.amendments.is_empty() {
1026 let temp_dir = tempfile::tempdir()?;
1028 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1029 amendment_file.save_to_file(&amendments_file)?;
1030
1031 {
1033 use std::io::IsTerminal;
1034 if !self.auto_apply
1035 && !self.handle_amendments_file(
1036 &amendments_file,
1037 &amendment_file,
1038 std::io::stdin().is_terminal(),
1039 &mut std::io::BufReader::new(std::io::stdin()),
1040 )?
1041 {
1042 println!("❌ Amendment cancelled by user");
1043 return Ok(());
1044 }
1045 }
1046
1047 self.apply_amendments_from_file(&amendments_file).await?;
1049 println!("✅ Commit messages applied successfully!");
1050
1051 if self.check {
1053 self.run_post_twiddle_check().await?;
1054 }
1055 } else {
1056 println!("✨ No commits found to process!");
1057 }
1058
1059 Ok(())
1060 }
1061
1062 async fn run_post_twiddle_check(&self) -> Result<()> {
1066 const MAX_CHECK_RETRIES: u32 = 3;
1067
1068 let guidelines = self.load_check_guidelines()?;
1070 let valid_scopes = self.load_check_scopes();
1071 let beta = self
1072 .beta_header
1073 .as_deref()
1074 .map(parse_beta_header)
1075 .transpose()?;
1076 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
1077
1078 for attempt in 0..=MAX_CHECK_RETRIES {
1079 println!();
1080 if attempt == 0 {
1081 println!("🔍 Running commit message validation...");
1082 } else {
1083 println!("🔍 Re-checking commit messages (retry {attempt}/{MAX_CHECK_RETRIES})...");
1084 }
1085
1086 let mut repo_view = self.generate_repository_view().await?;
1088
1089 if repo_view.commits.is_empty() {
1090 println!("⚠️ No commits to check");
1091 return Ok(());
1092 }
1093
1094 println!("📊 Checking {} commits", repo_view.commits.len());
1095
1096 for commit in &mut repo_view.commits {
1098 commit.analysis.refine_scope(&valid_scopes);
1099 }
1100
1101 if attempt == 0 {
1102 self.show_check_guidance_files_status(&guidelines, &valid_scopes);
1103 }
1104
1105 let report = if repo_view.commits.len() > 1 {
1107 println!(
1108 "🔄 Checking {} commits in parallel...",
1109 repo_view.commits.len()
1110 );
1111 self.check_commits_map_reduce(
1112 &claude_client,
1113 &repo_view,
1114 guidelines.as_deref(),
1115 &valid_scopes,
1116 )
1117 .await?
1118 } else {
1119 println!("🤖 Analyzing commits with AI...");
1120 claude_client
1121 .check_commits_with_scopes(
1122 &repo_view,
1123 guidelines.as_deref(),
1124 &valid_scopes,
1125 true,
1126 )
1127 .await?
1128 };
1129
1130 self.output_check_text_report(&report)?;
1132
1133 if !report.has_errors() {
1135 if report.has_warnings() {
1136 println!("ℹ️ Some commit messages have minor warnings");
1137 } else {
1138 println!("✅ All commit messages pass validation");
1139 }
1140 return Ok(());
1141 }
1142
1143 if attempt == MAX_CHECK_RETRIES {
1145 println!(
1146 "⚠️ Some commit messages still have issues after {MAX_CHECK_RETRIES} retries"
1147 );
1148 return Ok(());
1149 }
1150
1151 let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
1153
1154 if amendments.is_empty() {
1155 println!(
1156 "⚠️ Some commit messages have issues but no suggestions available to retry"
1157 );
1158 return Ok(());
1159 }
1160
1161 println!(
1163 "🔄 Applying {} suggested fix(es) and re-checking...",
1164 amendments.len()
1165 );
1166 let amendment_file = AmendmentFile { amendments };
1167 let temp_file = tempfile::NamedTempFile::new()
1168 .context("Failed to create temp file for retry amendments")?;
1169 amendment_file
1170 .save_to_file(temp_file.path())
1171 .context("Failed to save retry amendments")?;
1172 self.apply_amendments_from_file(temp_file.path()).await?;
1173 }
1174
1175 Ok(())
1176 }
1177
1178 fn build_amendments_from_suggestions(
1182 &self,
1183 report: &crate::data::check::CheckReport,
1184 repo_view: &crate::data::RepositoryView,
1185 ) -> Vec<crate::data::amendments::Amendment> {
1186 use crate::data::amendments::Amendment;
1187
1188 let candidate_hashes: Vec<String> =
1189 repo_view.commits.iter().map(|c| c.hash.clone()).collect();
1190
1191 report
1192 .commits
1193 .iter()
1194 .filter(|r| !r.passes)
1195 .filter_map(|r| {
1196 let suggestion = r.suggestion.as_ref()?;
1197 let full_hash = super::formatting::resolve_short_hash(&r.hash, &candidate_hashes)?;
1198 Some(Amendment::new(
1199 full_hash.to_string(),
1200 suggestion.message.clone(),
1201 ))
1202 })
1203 .collect()
1204 }
1205
1206 fn load_check_guidelines(&self) -> Result<Option<String>> {
1208 let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
1209 crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")
1210 }
1211
1212 fn load_check_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
1214 let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
1215 crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."))
1216 }
1217
1218 fn show_check_guidance_files_status(
1220 &self,
1221 guidelines: &Option<String>,
1222 valid_scopes: &[crate::data::context::ScopeDefinition],
1223 ) {
1224 use crate::claude::context::{
1225 config_source_label, resolve_context_dir_with_source, ConfigSourceLabel,
1226 };
1227
1228 let (context_dir, dir_source) =
1229 resolve_context_dir_with_source(self.context_dir.as_deref());
1230
1231 println!("📋 Project guidance files status:");
1232 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
1233
1234 let guidelines_source = if guidelines.is_some() {
1236 match config_source_label(&context_dir, "commit-guidelines.md") {
1237 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
1238 label => format!("✅ {label}"),
1239 }
1240 } else {
1241 "⚪ Using defaults".to_string()
1242 };
1243 println!(" 📝 Commit guidelines: {guidelines_source}");
1244
1245 let scopes_count = valid_scopes.len();
1247 let scopes_source = if scopes_count > 0 {
1248 match config_source_label(&context_dir, "scopes.yaml") {
1249 ConfigSourceLabel::NotFound => {
1250 format!("✅ (source unknown) ({scopes_count} scopes)")
1251 }
1252 label => format!("✅ {label} ({scopes_count} scopes)"),
1253 }
1254 } else {
1255 "⚪ None found (any scope accepted)".to_string()
1256 };
1257 println!(" 🎯 Valid scopes: {scopes_source}");
1258
1259 println!();
1260 }
1261
1262 async fn check_commits_map_reduce(
1264 &self,
1265 claude_client: &crate::claude::client::ClaudeClient,
1266 full_repo_view: &crate::data::RepositoryView,
1267 guidelines: Option<&str>,
1268 valid_scopes: &[crate::data::context::ScopeDefinition],
1269 ) -> Result<crate::data::check::CheckReport> {
1270 use std::sync::atomic::{AtomicUsize, Ordering};
1271 use std::sync::Arc;
1272
1273 use crate::claude::batch;
1274 use crate::claude::token_budget;
1275 use crate::data::check::{CheckReport, CommitCheckResult};
1276
1277 let total_commits = full_repo_view.commits.len();
1278
1279 let metadata = claude_client.get_ai_client_metadata();
1281 let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
1282 guidelines,
1283 valid_scopes,
1284 );
1285 let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
1286 let batch_plan =
1287 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
1288
1289 if batch_plan.batches.len() < total_commits {
1290 println!(
1291 " 📦 Grouped {} commits into {} batches by token budget",
1292 total_commits,
1293 batch_plan.batches.len()
1294 );
1295 }
1296
1297 let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
1298 let completed = Arc::new(AtomicUsize::new(0));
1299
1300 let futs: Vec<_> = batch_plan
1301 .batches
1302 .iter()
1303 .map(|batch| {
1304 let sem = semaphore.clone();
1305 let completed = completed.clone();
1306 let batch_indices = &batch.commit_indices;
1307
1308 async move {
1309 let _permit = sem
1310 .acquire()
1311 .await
1312 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
1313
1314 let batch_size = batch_indices.len();
1315
1316 let batch_view = if batch_size == 1 {
1317 full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
1318 } else {
1319 let commits: Vec<_> = batch_indices
1320 .iter()
1321 .map(|&i| &full_repo_view.commits[i])
1322 .collect();
1323 full_repo_view.multi_commit_view(&commits)
1324 };
1325
1326 let result = claude_client
1327 .check_commits_with_scopes(&batch_view, guidelines, valid_scopes, true)
1328 .await;
1329
1330 match result {
1331 Ok(report) => {
1332 let done =
1333 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
1334 println!(" ✅ {done}/{total_commits} commits checked");
1335
1336 let items: Vec<_> = report
1337 .commits
1338 .into_iter()
1339 .map(|r| {
1340 let summary = r.summary.clone().unwrap_or_default();
1341 (r, summary)
1342 })
1343 .collect();
1344 Ok::<_, anyhow::Error>((items, vec![]))
1345 }
1346 Err(e) if batch_size > 1 => {
1347 eprintln!(
1348 "warning: batch of {batch_size} failed, retrying individually: {e}"
1349 );
1350 let mut items = Vec::new();
1351 let mut failed_indices = Vec::new();
1352 for &idx in batch_indices {
1353 let single_view =
1354 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1355 let single_result = claude_client
1356 .check_commits_with_scopes(
1357 &single_view,
1358 guidelines,
1359 valid_scopes,
1360 true,
1361 )
1362 .await;
1363 match single_result {
1364 Ok(report) => {
1365 if let Some(r) = report.commits.into_iter().next() {
1366 let summary = r.summary.clone().unwrap_or_default();
1367 items.push((r, summary));
1368 }
1369 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1370 println!(" ✅ {done}/{total_commits} commits checked");
1371 }
1372 Err(e) => {
1373 eprintln!("warning: failed to check commit: {e}");
1374 failed_indices.push(idx);
1375 println!(" ❌ commit check failed");
1376 }
1377 }
1378 }
1379 Ok((items, failed_indices))
1380 }
1381 Err(e) => {
1382 let idx = batch_indices[0];
1384 eprintln!("warning: failed to check commit: {e}");
1385 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1386 println!(" ❌ {done}/{total_commits} commits checked (failed)");
1387 Ok((vec![], vec![idx]))
1388 }
1389 }
1390 }
1391 })
1392 .collect();
1393
1394 let results = futures::future::join_all(futs).await;
1395
1396 let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
1397 let mut failed_indices: Vec<usize> = Vec::new();
1398
1399 for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
1400 match result {
1401 Ok((items, failed)) => {
1402 successes.extend(items);
1403 failed_indices.extend(failed);
1404 }
1405 Err(e) => {
1406 eprintln!("warning: batch processing error: {e}");
1407 failed_indices.extend(&batch.commit_indices);
1408 }
1409 }
1410 }
1411
1412 if !failed_indices.is_empty() && !self.quiet {
1414 use std::io::IsTerminal;
1415 if std::io::stdin().is_terminal() {
1416 self.run_interactive_retry_twiddle_check(
1417 &mut failed_indices,
1418 full_repo_view,
1419 claude_client,
1420 guidelines,
1421 valid_scopes,
1422 &mut successes,
1423 &mut std::io::BufReader::new(std::io::stdin()),
1424 )
1425 .await?;
1426 } else {
1427 eprintln!(
1428 "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1429 failed_indices.len()
1430 );
1431 }
1432 } else if !failed_indices.is_empty() {
1433 eprintln!(
1434 "warning: {} commit(s) failed to check",
1435 failed_indices.len()
1436 );
1437 }
1438
1439 if !failed_indices.is_empty() {
1440 eprintln!(
1441 "warning: {} commit(s) ultimately failed to check",
1442 failed_indices.len()
1443 );
1444 }
1445
1446 if successes.is_empty() {
1447 anyhow::bail!("All commits failed to check");
1448 }
1449
1450 let single_batch = batch_plan.batches.len() <= 1;
1452 if !self.no_coherence && !single_batch && successes.len() >= 2 {
1453 println!("🔗 Running cross-commit coherence pass...");
1454 match claude_client
1455 .refine_checks_coherence(&successes, full_repo_view)
1456 .await
1457 {
1458 Ok(refined) => return Ok(refined),
1459 Err(e) => {
1460 eprintln!("warning: coherence pass failed, using individual results: {e}");
1461 }
1462 }
1463 }
1464
1465 Ok(CheckReport::new(
1466 successes.into_iter().map(|(r, _)| r).collect(),
1467 ))
1468 }
1469
1470 #[allow(clippy::too_many_arguments)]
1474 async fn run_interactive_retry_twiddle_check(
1475 &self,
1476 failed_indices: &mut Vec<usize>,
1477 full_repo_view: &crate::data::RepositoryView,
1478 claude_client: &crate::claude::client::ClaudeClient,
1479 guidelines: Option<&str>,
1480 valid_scopes: &[crate::data::context::ScopeDefinition],
1481 successes: &mut Vec<(crate::data::check::CommitCheckResult, String)>,
1482 reader: &mut (dyn std::io::BufRead + Send),
1483 ) -> Result<()> {
1484 use std::io::Write as _;
1485 println!("\n⚠️ {} commit(s) failed to check:", failed_indices.len());
1486 for &idx in failed_indices.iter() {
1487 let commit = &full_repo_view.commits[idx];
1488 let subject = commit
1489 .original_message
1490 .lines()
1491 .next()
1492 .unwrap_or("(no message)");
1493 println!(" - {}: {}", &commit.hash[..8], subject);
1494 }
1495 loop {
1496 print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1497 std::io::stdout().flush()?;
1498 let Some(input) = super::read_interactive_line(reader)? else {
1499 eprintln!("warning: stdin closed, skipping failed commit(s)");
1500 break;
1501 };
1502 match input.trim().to_lowercase().as_str() {
1503 "r" | "retry" | "" => {
1504 let mut still_failed = Vec::new();
1505 for &idx in failed_indices.iter() {
1506 let single_view =
1507 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1508 match claude_client
1509 .check_commits_with_scopes(&single_view, guidelines, valid_scopes, true)
1510 .await
1511 {
1512 Ok(report) => {
1513 if let Some(r) = report.commits.into_iter().next() {
1514 let summary = r.summary.clone().unwrap_or_default();
1515 successes.push((r, summary));
1516 }
1517 }
1518 Err(e) => {
1519 eprintln!("warning: still failed: {e}");
1520 still_failed.push(idx);
1521 }
1522 }
1523 }
1524 *failed_indices = still_failed;
1525 if failed_indices.is_empty() {
1526 println!("✅ All retried commits succeeded.");
1527 break;
1528 }
1529 println!("\n⚠️ {} commit(s) still failed:", failed_indices.len());
1530 for &idx in failed_indices.iter() {
1531 let commit = &full_repo_view.commits[idx];
1532 let subject = commit
1533 .original_message
1534 .lines()
1535 .next()
1536 .unwrap_or("(no message)");
1537 println!(" - {}: {}", &commit.hash[..8], subject);
1538 }
1539 }
1540 "s" | "skip" => {
1541 println!("Skipping {} failed commit(s).", failed_indices.len());
1542 break;
1543 }
1544 _ => println!("Please enter 'r' to retry or 's' to skip."),
1545 }
1546 }
1547 Ok(())
1548 }
1549
1550 #[allow(clippy::too_many_arguments)]
1556 async fn run_interactive_retry_generate_amendments(
1557 &self,
1558 failed_indices: &mut Vec<usize>,
1559 full_repo_view: &crate::data::RepositoryView,
1560 claude_client: &crate::claude::client::ClaudeClient,
1561 context: Option<&crate::data::context::CommitContext>,
1562 fresh: bool,
1563 successes: &mut Vec<(crate::data::amendments::Amendment, String)>,
1564 is_terminal: bool,
1565 reader: &mut (dyn std::io::BufRead + Send),
1566 ) -> Result<()> {
1567 use std::io::Write as _;
1568 println!(
1569 "\n⚠️ {} commit(s) failed to process:",
1570 failed_indices.len()
1571 );
1572 for &idx in failed_indices.iter() {
1573 let commit = &full_repo_view.commits[idx];
1574 let subject = commit
1575 .original_message
1576 .lines()
1577 .next()
1578 .unwrap_or("(no message)");
1579 println!(" - {}: {}", &commit.hash[..8], subject);
1580 }
1581 if !is_terminal {
1582 eprintln!(
1583 "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1584 failed_indices.len()
1585 );
1586 return Ok(());
1587 }
1588 loop {
1589 print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1590 std::io::stdout().flush()?;
1591 let Some(input) = super::read_interactive_line(reader)? else {
1592 eprintln!("warning: stdin closed, skipping failed commit(s)");
1593 break;
1594 };
1595 match input.trim().to_lowercase().as_str() {
1596 "r" | "retry" | "" => {
1597 let mut still_failed = Vec::new();
1598 for &idx in failed_indices.iter() {
1599 let single_view =
1600 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1601 let result = if let Some(ctx) = context {
1602 claude_client
1603 .generate_contextual_amendments_with_options(
1604 &single_view,
1605 ctx,
1606 fresh,
1607 )
1608 .await
1609 } else {
1610 claude_client
1611 .generate_amendments_with_options(&single_view, fresh)
1612 .await
1613 };
1614 match result {
1615 Ok(af) => {
1616 if let Some(a) = af.amendments.into_iter().next() {
1617 let summary = a.summary.clone().unwrap_or_default();
1618 successes.push((a, summary));
1619 }
1620 }
1621 Err(e) => {
1622 eprintln!("warning: still failed: {e}");
1623 still_failed.push(idx);
1624 }
1625 }
1626 }
1627 *failed_indices = still_failed;
1628 if failed_indices.is_empty() {
1629 println!("✅ All retried commits succeeded.");
1630 break;
1631 }
1632 println!("\n⚠️ {} commit(s) still failed:", failed_indices.len());
1633 for &idx in failed_indices.iter() {
1634 let commit = &full_repo_view.commits[idx];
1635 let subject = commit
1636 .original_message
1637 .lines()
1638 .next()
1639 .unwrap_or("(no message)");
1640 println!(" - {}: {}", &commit.hash[..8], subject);
1641 }
1642 }
1643 "s" | "skip" => {
1644 println!("Skipping {} failed commit(s).", failed_indices.len());
1645 break;
1646 }
1647 _ => println!("Please enter 'r' to retry or 's' to skip."),
1648 }
1649 }
1650 Ok(())
1651 }
1652
1653 fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1655 println!();
1656
1657 for result in &report.commits {
1658 if result.passes {
1660 continue;
1661 }
1662
1663 let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
1664 let short_hash = super::formatting::truncate_hash(&result.hash);
1665
1666 println!("{} {} - \"{}\"", icon, short_hash, result.message);
1667
1668 for issue in &result.issues {
1670 let severity_str = super::formatting::format_severity_label(issue.severity);
1671
1672 println!(
1673 " {} [{}] {}",
1674 severity_str, issue.section, issue.explanation
1675 );
1676 }
1677
1678 if let Some(suggestion) = &result.suggestion {
1680 println!();
1681 println!(" Suggested message:");
1682 for line in suggestion.message.lines() {
1683 println!(" {line}");
1684 }
1685 }
1686
1687 println!();
1688 }
1689
1690 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1692 println!("Summary: {} commits checked", report.summary.total_commits);
1693 println!(
1694 " {} errors, {} warnings",
1695 report.summary.error_count, report.summary.warning_count
1696 );
1697 println!(
1698 " {} passed, {} with issues",
1699 report.summary.passing_commits, report.summary.failing_commits
1700 );
1701
1702 Ok(())
1703 }
1704}
1705
1706#[derive(Debug, Clone)]
1708pub struct TwiddleOutcome {
1709 pub amendments_yaml: String,
1711 pub applied: bool,
1714 pub amendment_count: usize,
1716}
1717
1718pub async fn run_twiddle(
1729 range: Option<&str>,
1730 model: Option<String>,
1731 dry_run: bool,
1732 repo_path: Option<&std::path::Path>,
1733) -> Result<TwiddleOutcome> {
1734 let _cwd_guard = match repo_path {
1735 Some(p) => Some(super::CwdGuard::enter(p).await?),
1736 None => None,
1737 };
1738
1739 crate::utils::check_ai_command_prerequisites(model.as_deref())?;
1740
1741 if !dry_run {
1742 crate::utils::preflight::check_working_directory_clean()?;
1743 }
1744
1745 let claude_client = crate::claude::create_default_claude_client(model, None)?;
1746 run_twiddle_with_client(range, dry_run, &claude_client).await
1747}
1748
1749pub(crate) async fn run_twiddle_with_client(
1756 range: Option<&str>,
1757 dry_run: bool,
1758 claude_client: &crate::claude::client::ClaudeClient,
1759) -> Result<TwiddleOutcome> {
1760 use crate::data::{
1761 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1762 WorkingDirectoryInfo,
1763 };
1764 use crate::git::{GitRepository, RemoteInfo};
1765 use crate::utils::ai_scratch;
1766
1767 let resolved_range = range.unwrap_or("HEAD~5..HEAD");
1768
1769 let repo = GitRepository::open()
1770 .context("Failed to open git repository. Make sure you're in a git repository.")?;
1771
1772 let current_branch = repo
1773 .get_current_branch()
1774 .unwrap_or_else(|_| "HEAD".to_string());
1775
1776 let wd_status = repo.get_working_directory_status()?;
1777 let working_directory = WorkingDirectoryInfo {
1778 clean: wd_status.clean,
1779 untracked_changes: wd_status
1780 .untracked_changes
1781 .into_iter()
1782 .map(|fs| FileStatusInfo {
1783 status: fs.status,
1784 file: fs.file,
1785 })
1786 .collect(),
1787 };
1788
1789 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1790 let commits = repo.get_commits_in_range(resolved_range)?;
1791
1792 if commits.is_empty() {
1793 let empty_file = AmendmentFile { amendments: vec![] };
1794 let yaml =
1795 crate::data::to_yaml(&empty_file).context("Failed to serialise empty AmendmentFile")?;
1796 return Ok(TwiddleOutcome {
1797 amendments_yaml: yaml,
1798 applied: false,
1799 amendment_count: 0,
1800 });
1801 }
1802
1803 let ai_scratch_path =
1804 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1805 let ai_info = AiInfo {
1806 scratch: ai_scratch_path.to_string_lossy().to_string(),
1807 };
1808
1809 let mut repo_view = RepositoryView {
1810 versions: Some(VersionInfo {
1811 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1812 }),
1813 explanation: FieldExplanation::default(),
1814 working_directory,
1815 remotes,
1816 ai: ai_info,
1817 branch_info: Some(BranchInfo {
1818 branch: current_branch,
1819 }),
1820 pr_template: None,
1821 pr_template_location: None,
1822 branch_prs: None,
1823 commits,
1824 };
1825 repo_view.update_field_presence();
1826
1827 let mut amendments = claude_client
1828 .generate_amendments_with_options(&repo_view, true)
1829 .await?;
1830
1831 let context_dir = crate::claude::context::resolve_context_dir(None);
1832 let scope_defs =
1833 crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."));
1834 refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
1835
1836 let amendments_yaml =
1837 crate::data::to_yaml(&amendments).context("Failed to serialise AmendmentFile")?;
1838 let amendment_count = amendments.amendments.len();
1839
1840 if dry_run || amendment_count == 0 {
1841 return Ok(TwiddleOutcome {
1842 amendments_yaml,
1843 applied: false,
1844 amendment_count,
1845 });
1846 }
1847
1848 let temp_dir = tempfile::tempdir().context("Failed to create temp dir")?;
1849 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1850 amendments
1851 .save_to_file(&amendments_file)
1852 .context("Failed to save amendments")?;
1853 let handler =
1854 crate::git::AmendmentHandler::new().context("Failed to initialise amendment handler")?;
1855 handler
1856 .apply_amendments(&amendments_file.to_string_lossy())
1857 .context("Failed to apply amendments")?;
1858
1859 Ok(TwiddleOutcome {
1860 amendments_yaml,
1861 applied: true,
1862 amendment_count,
1863 })
1864}
1865
1866#[cfg(test)]
1867#[allow(clippy::unwrap_used, clippy::expect_used)]
1868mod run_twiddle_tests {
1869 use super::*;
1870 use crate::claude::client::ClaudeClient;
1871 use crate::claude::test_utils::ConfigurableMockAiClient;
1872 use git2::{Repository, Signature};
1873
1874 #[tokio::test]
1875 async fn run_twiddle_invalid_repo_path_errors_before_ai() {
1876 let err = run_twiddle(
1877 None,
1878 None,
1879 true,
1880 Some(std::path::Path::new("/no/such/path/exists")),
1881 )
1882 .await
1883 .unwrap_err();
1884 let msg = format!("{err:#}");
1885 assert!(
1886 msg.to_lowercase().contains("set_current_dir")
1887 || msg.to_lowercase().contains("no such")
1888 || msg.to_lowercase().contains("directory"),
1889 "expected cwd-related error, got: {msg}"
1890 );
1891 }
1892
1893 fn init_test_repo_with_commit() -> (tempfile::TempDir, String) {
1894 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1895 std::fs::create_dir_all(&tmp_root).unwrap();
1896 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1897 let repo = Repository::init(temp_dir.path()).unwrap();
1898 {
1899 let mut cfg = repo.config().unwrap();
1900 cfg.set_str("user.name", "Test").unwrap();
1901 cfg.set_str("user.email", "test@example.com").unwrap();
1902 }
1903 let signature = Signature::now("Test", "test@example.com").unwrap();
1904 std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
1905 let mut idx = repo.index().unwrap();
1906 idx.add_path(std::path::Path::new("f.txt")).unwrap();
1907 idx.write().unwrap();
1908 let tree_id = idx.write_tree().unwrap();
1909 let tree = repo.find_tree(tree_id).unwrap();
1910 let oid = repo
1911 .commit(
1912 Some("HEAD"),
1913 &signature,
1914 &signature,
1915 "feat: original",
1916 &tree,
1917 &[],
1918 )
1919 .unwrap();
1920 (temp_dir, oid.to_string())
1921 }
1922
1923 fn amendment_yaml(hash: &str, msg: &str) -> String {
1924 format!("amendments:\n - commit: {hash}\n message: '{msg}'\n")
1925 }
1926
1927 #[tokio::test]
1928 async fn run_twiddle_with_client_dry_run_returns_amendments() {
1929 let (temp_dir, hash) = init_test_repo_with_commit();
1930 let _guard = super::super::CwdGuard::enter(temp_dir.path())
1931 .await
1932 .unwrap();
1933
1934 let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
1935 &hash,
1936 "feat(cli): better subject",
1937 ))]);
1938 let client = ClaudeClient::new(Box::new(mock));
1939
1940 let outcome = run_twiddle_with_client(Some("HEAD"), true, &client)
1941 .await
1942 .unwrap();
1943 assert!(!outcome.applied, "dry_run must not apply");
1944 assert_eq!(outcome.amendment_count, 1);
1945 assert!(outcome.amendments_yaml.contains("amendments:"));
1946 }
1947
1948 #[tokio::test]
1949 async fn run_twiddle_with_client_empty_range_returns_empty() {
1950 let (temp_dir, _hash) = init_test_repo_with_commit();
1951 let _guard = super::super::CwdGuard::enter(temp_dir.path())
1952 .await
1953 .unwrap();
1954
1955 let mock = ConfigurableMockAiClient::new(vec![]);
1956 let client = ClaudeClient::new(Box::new(mock));
1957
1958 let outcome = run_twiddle_with_client(Some("HEAD..HEAD"), true, &client)
1959 .await
1960 .unwrap();
1961 assert_eq!(outcome.amendment_count, 0);
1962 assert!(!outcome.applied);
1963 }
1964
1965 #[tokio::test]
1966 async fn run_twiddle_with_client_ai_failure_errors() {
1967 let (temp_dir, _hash) = init_test_repo_with_commit();
1968 let _guard = super::super::CwdGuard::enter(temp_dir.path())
1969 .await
1970 .unwrap();
1971
1972 let mock = ConfigurableMockAiClient::new(vec![]);
1973 let client = ClaudeClient::new(Box::new(mock));
1974 let err = run_twiddle_with_client(Some("HEAD"), true, &client)
1975 .await
1976 .unwrap_err();
1977 let _ = err;
1978 }
1979
1980 #[tokio::test]
1981 async fn run_twiddle_with_client_default_range_errors_on_sparse_repo() {
1982 let (temp_dir, _hash) = init_test_repo_with_commit();
1983 let _guard = super::super::CwdGuard::enter(temp_dir.path())
1984 .await
1985 .unwrap();
1986
1987 let mock = ConfigurableMockAiClient::new(vec![]);
1991 let client = ClaudeClient::new(Box::new(mock));
1992
1993 let err = run_twiddle_with_client(None, true, &client)
1994 .await
1995 .unwrap_err();
1996 assert!(
1997 format!("{err:#}").contains("HEAD~5")
1998 || format!("{err:#}").to_lowercase().contains("not found"),
1999 "expected HEAD~5 resolution error"
2000 );
2001 }
2002
2003 #[test]
2004 fn twiddle_outcome_clone_and_debug() {
2005 let outcome = TwiddleOutcome {
2006 amendments_yaml: "x".to_string(),
2007 applied: true,
2008 amendment_count: 2,
2009 };
2010 let cloned = outcome.clone();
2011 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
2012 }
2013
2014 #[tokio::test]
2018 async fn run_twiddle_with_client_applies_head_amendment() {
2019 let (temp_dir, hash) = init_test_repo_with_commit();
2020 let _guard = super::super::CwdGuard::enter(temp_dir.path())
2021 .await
2022 .unwrap();
2023
2024 let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
2025 &hash,
2026 "feat(cli): much better subject",
2027 ))]);
2028 let client = ClaudeClient::new(Box::new(mock));
2029
2030 let outcome = run_twiddle_with_client(Some("HEAD"), false, &client)
2031 .await
2032 .unwrap();
2033 assert!(outcome.applied, "dry_run=false must apply amendments");
2034 assert_eq!(outcome.amendment_count, 1);
2035
2036 let repo = git2::Repository::open(temp_dir.path()).unwrap();
2038 let head_msg = repo
2039 .head()
2040 .unwrap()
2041 .peel_to_commit()
2042 .unwrap()
2043 .message()
2044 .unwrap()
2045 .to_string();
2046 assert!(
2047 head_msg.contains("much better subject"),
2048 "HEAD message should be rewritten: {head_msg}"
2049 );
2050 }
2051}
2052
2053fn format_work_pattern(pattern: &crate::data::context::WorkPattern) -> Option<&'static str> {
2059 use crate::data::context::WorkPattern;
2060 match pattern {
2061 WorkPattern::Sequential => Some("\u{1f504} Pattern: Sequential development"),
2062 WorkPattern::Refactoring => Some("\u{1f9f9} Pattern: Refactoring work"),
2063 WorkPattern::BugHunt => Some("\u{1f41b} Pattern: Bug investigation"),
2064 WorkPattern::Documentation => Some("\u{1f4d6} Pattern: Documentation updates"),
2065 WorkPattern::Configuration => Some("\u{2699}\u{fe0f} Pattern: Configuration changes"),
2066 WorkPattern::Unknown => None,
2067 }
2068}
2069
2070fn format_verbosity_level(level: crate::data::context::VerbosityLevel) -> &'static str {
2072 use crate::data::context::VerbosityLevel;
2073 match level {
2074 VerbosityLevel::Comprehensive => {
2075 "\u{1f4dd} Detail level: Comprehensive (significant changes detected)"
2076 }
2077 VerbosityLevel::Detailed => "\u{1f4dd} Detail level: Detailed",
2078 VerbosityLevel::Concise => "\u{1f4dd} Detail level: Concise",
2079 }
2080}
2081
2082fn format_scope_list(scopes: &[crate::data::context::ScopeDefinition]) -> String {
2084 scopes
2085 .iter()
2086 .map(|s| s.name.as_str())
2087 .collect::<Vec<_>>()
2088 .join(", ")
2089}
2090
2091fn refine_amendment_scopes(
2094 amendments: &mut AmendmentFile,
2095 repo_view: &RepositoryView,
2096 scope_defs: &[crate::data::context::ScopeDefinition],
2097) {
2098 for amendment in &mut amendments.amendments {
2099 if let Some(commit) = repo_view
2100 .commits
2101 .iter()
2102 .find(|c| c.hash == amendment.commit)
2103 {
2104 let files: Vec<&str> = commit
2105 .analysis
2106 .file_changes
2107 .file_list
2108 .iter()
2109 .map(|f| f.file.as_str())
2110 .collect();
2111 amendment.message =
2112 crate::git::refine_message_scope(&amendment.message, &files, scope_defs);
2113 }
2114 }
2115}
2116
2117#[cfg(test)]
2118#[allow(clippy::unwrap_used, clippy::expect_used)]
2119mod tests {
2120 use super::*;
2121 use crate::data::context::{ScopeDefinition, VerbosityLevel, WorkPattern};
2122
2123 #[test]
2126 fn work_pattern_sequential() {
2127 let result = format_work_pattern(&WorkPattern::Sequential);
2128 assert!(result.is_some());
2129 assert!(result.unwrap().contains("Sequential development"));
2130 }
2131
2132 #[test]
2133 fn work_pattern_refactoring() {
2134 let result = format_work_pattern(&WorkPattern::Refactoring);
2135 assert!(result.is_some());
2136 assert!(result.unwrap().contains("Refactoring work"));
2137 }
2138
2139 #[test]
2140 fn work_pattern_bug_hunt() {
2141 let result = format_work_pattern(&WorkPattern::BugHunt);
2142 assert!(result.is_some());
2143 assert!(result.unwrap().contains("Bug investigation"));
2144 }
2145
2146 #[test]
2147 fn work_pattern_docs() {
2148 let result = format_work_pattern(&WorkPattern::Documentation);
2149 assert!(result.is_some());
2150 assert!(result.unwrap().contains("Documentation updates"));
2151 }
2152
2153 #[test]
2154 fn work_pattern_config() {
2155 let result = format_work_pattern(&WorkPattern::Configuration);
2156 assert!(result.is_some());
2157 assert!(result.unwrap().contains("Configuration changes"));
2158 }
2159
2160 #[test]
2161 fn work_pattern_unknown() {
2162 assert!(format_work_pattern(&WorkPattern::Unknown).is_none());
2163 }
2164
2165 #[test]
2168 fn verbosity_comprehensive() {
2169 let label = format_verbosity_level(VerbosityLevel::Comprehensive);
2170 assert!(label.contains("Comprehensive"));
2171 assert!(label.contains("significant changes"));
2172 }
2173
2174 #[test]
2175 fn verbosity_detailed() {
2176 let label = format_verbosity_level(VerbosityLevel::Detailed);
2177 assert!(label.contains("Detailed"));
2178 }
2179
2180 #[test]
2181 fn verbosity_concise() {
2182 let label = format_verbosity_level(VerbosityLevel::Concise);
2183 assert!(label.contains("Concise"));
2184 }
2185
2186 #[test]
2189 fn scope_list_single() {
2190 let scopes = vec![ScopeDefinition {
2191 name: "cli".to_string(),
2192 description: String::new(),
2193 examples: vec![],
2194 file_patterns: vec![],
2195 }];
2196 assert_eq!(format_scope_list(&scopes), "cli");
2197 }
2198
2199 #[test]
2200 fn scope_list_multiple() {
2201 let scopes = vec![
2202 ScopeDefinition {
2203 name: "cli".to_string(),
2204 description: String::new(),
2205 examples: vec![],
2206 file_patterns: vec![],
2207 },
2208 ScopeDefinition {
2209 name: "git".to_string(),
2210 description: String::new(),
2211 examples: vec![],
2212 file_patterns: vec![],
2213 },
2214 ScopeDefinition {
2215 name: "docs".to_string(),
2216 description: String::new(),
2217 examples: vec![],
2218 file_patterns: vec![],
2219 },
2220 ];
2221 assert_eq!(format_scope_list(&scopes), "cli, git, docs");
2222 }
2223
2224 #[test]
2227 fn context_dir_default() {
2228 let result = crate::claude::context::resolve_context_dir(None);
2229 assert!(
2231 result.ends_with(".omni-dev"),
2232 "expected path ending in .omni-dev, got {result:?}"
2233 );
2234 }
2235
2236 #[test]
2237 fn context_dir_override() {
2238 let custom = std::path::PathBuf::from("custom-dir");
2239 let result = crate::claude::context::resolve_context_dir(Some(&custom));
2240 assert_eq!(result, custom);
2241 }
2242
2243 fn parse_twiddle(args: &[&str]) -> TwiddleCommand {
2246 let mut full_args = vec!["twiddle"];
2247 full_args.extend_from_slice(args);
2248 TwiddleCommand::try_parse_from(full_args).unwrap()
2249 }
2250
2251 #[test]
2252 fn default_is_fresh() {
2253 let cmd = parse_twiddle(&[]);
2254 assert!(cmd.is_fresh(), "default should be fresh mode");
2255 }
2256
2257 #[test]
2258 fn refine_disables_fresh() {
2259 let cmd = parse_twiddle(&["--refine"]);
2260 assert!(!cmd.is_fresh(), "--refine should disable fresh mode");
2261 }
2262
2263 #[test]
2264 fn explicit_fresh_is_fresh() {
2265 let cmd = parse_twiddle(&["--fresh"]);
2266 assert!(cmd.is_fresh(), "--fresh should be fresh mode");
2267 }
2268
2269 #[test]
2270 fn fresh_and_refine_conflict() {
2271 let result = TwiddleCommand::try_parse_from(["twiddle", "--fresh", "--refine"]);
2272 assert!(result.is_err(), "--fresh and --refine should conflict");
2273 }
2274
2275 fn make_twiddle_cmd() -> TwiddleCommand {
2278 TwiddleCommand {
2279 commit_range: None,
2280 model: None,
2281 beta_header: None,
2282 auto_apply: false,
2283 save_only: None,
2284 use_context: false,
2285 context_dir: None,
2286 work_context: None,
2287 branch_context: None,
2288 no_context: true,
2289 concurrency: 4,
2290 batch_size: None,
2291 no_coherence: true,
2292 no_ai: false,
2293 fresh: false,
2294 refine: false,
2295 check: false,
2296 quiet: false,
2297 }
2298 }
2299
2300 fn make_twiddle_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
2301 use crate::git::commit::FileChanges;
2302 use crate::git::{CommitAnalysis, CommitInfo};
2303 let tmp = tempfile::NamedTempFile::new().unwrap();
2304 let commit = CommitInfo {
2305 hash: hash.to_string(),
2306 author: "Test <test@test.com>".to_string(),
2307 date: chrono::Utc::now().fixed_offset(),
2308 original_message: format!("feat: commit {hash}"),
2309 in_main_branches: vec![],
2310 analysis: CommitAnalysis {
2311 detected_type: "feat".to_string(),
2312 detected_scope: String::new(),
2313 proposed_message: format!("feat: commit {hash}"),
2314 file_changes: FileChanges {
2315 total_files: 0,
2316 files_added: 0,
2317 files_deleted: 0,
2318 file_list: vec![],
2319 },
2320 diff_summary: String::new(),
2321 diff_file: tmp.path().to_string_lossy().to_string(),
2322 file_diffs: Vec::new(),
2323 },
2324 };
2325 (commit, tmp)
2326 }
2327
2328 fn make_twiddle_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
2329 use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
2330 RepositoryView {
2331 versions: None,
2332 explanation: FieldExplanation::default(),
2333 working_directory: WorkingDirectoryInfo {
2334 clean: true,
2335 untracked_changes: vec![],
2336 },
2337 remotes: vec![],
2338 ai: AiInfo {
2339 scratch: String::new(),
2340 },
2341 branch_info: None,
2342 pr_template: None,
2343 pr_template_location: None,
2344 branch_prs: None,
2345 commits,
2346 }
2347 }
2348
2349 fn twiddle_check_yaml(hash: &str) -> String {
2350 format!("checks:\n - commit: {hash}\n passes: true\n issues: []\n")
2351 }
2352
2353 fn make_mock_client(
2354 responses: Vec<anyhow::Result<String>>,
2355 ) -> crate::claude::client::ClaudeClient {
2356 crate::claude::client::ClaudeClient::new(Box::new(
2357 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2358 ))
2359 }
2360
2361 #[tokio::test]
2362 async fn check_commits_map_reduce_single_commit_succeeds() {
2363 let (commit, _tmp) = make_twiddle_commit("abc00000");
2365 let cmd = make_twiddle_cmd();
2366 let repo_view = make_twiddle_repo_view(vec![commit]);
2367 let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2368 let result = cmd
2369 .check_commits_map_reduce(&client, &repo_view, None, &[])
2370 .await;
2371 assert!(result.is_ok());
2372 assert_eq!(result.unwrap().commits.len(), 1);
2373 }
2374
2375 #[tokio::test]
2376 async fn check_commits_map_reduce_batch_fails_split_retry_both_succeed() {
2377 let (c1, _t1) = make_twiddle_commit("abc00000");
2381 let (c2, _t2) = make_twiddle_commit("def00000");
2382 let cmd = make_twiddle_cmd();
2383 let repo_view = make_twiddle_repo_view(vec![c1, c2]);
2384 let mut responses: Vec<anyhow::Result<String>> =
2385 (0..3).map(|_| Err(anyhow::anyhow!("batch fail"))).collect();
2386 responses.push(Ok(twiddle_check_yaml("abc00000")));
2387 responses.push(Ok(twiddle_check_yaml("def00000")));
2388 let client = make_mock_client(responses);
2389 let result = cmd
2390 .check_commits_map_reduce(&client, &repo_view, None, &[])
2391 .await;
2392 assert!(result.is_ok());
2393 assert_eq!(result.unwrap().commits.len(), 2);
2394 }
2395
2396 #[tokio::test]
2399 async fn interactive_retry_twiddle_skip_immediately() {
2400 let (commit, _tmp) = make_twiddle_commit("abc00000");
2402 let cmd = make_twiddle_cmd();
2403 let repo_view = make_twiddle_repo_view(vec![commit]);
2404 let client = make_mock_client(vec![]);
2405 let mut failed = vec![0usize];
2406 let mut successes = vec![];
2407 let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
2408 cmd.run_interactive_retry_twiddle_check(
2409 &mut failed,
2410 &repo_view,
2411 &client,
2412 None,
2413 &[],
2414 &mut successes,
2415 &mut stdin,
2416 )
2417 .await
2418 .unwrap();
2419 assert_eq!(
2420 failed,
2421 vec![0],
2422 "skip should leave failed_indices unchanged"
2423 );
2424 assert!(successes.is_empty());
2425 }
2426
2427 #[tokio::test]
2428 async fn interactive_retry_twiddle_retry_succeeds() {
2429 let (commit, _tmp) = make_twiddle_commit("abc00000");
2431 let cmd = make_twiddle_cmd();
2432 let repo_view = make_twiddle_repo_view(vec![commit]);
2433 let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2434 let mut failed = vec![0usize];
2435 let mut successes = vec![];
2436 let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
2437 cmd.run_interactive_retry_twiddle_check(
2438 &mut failed,
2439 &repo_view,
2440 &client,
2441 None,
2442 &[],
2443 &mut successes,
2444 &mut stdin,
2445 )
2446 .await
2447 .unwrap();
2448 assert!(
2449 failed.is_empty(),
2450 "retry succeeded → failed_indices cleared"
2451 );
2452 assert_eq!(successes.len(), 1);
2453 }
2454
2455 #[tokio::test]
2456 async fn interactive_retry_twiddle_default_input_retries() {
2457 let (commit, _tmp) = make_twiddle_commit("abc00000");
2459 let cmd = make_twiddle_cmd();
2460 let repo_view = make_twiddle_repo_view(vec![commit]);
2461 let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2462 let mut failed = vec![0usize];
2463 let mut successes = vec![];
2464 let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
2465 cmd.run_interactive_retry_twiddle_check(
2466 &mut failed,
2467 &repo_view,
2468 &client,
2469 None,
2470 &[],
2471 &mut successes,
2472 &mut stdin,
2473 )
2474 .await
2475 .unwrap();
2476 assert!(failed.is_empty());
2477 assert_eq!(successes.len(), 1);
2478 }
2479
2480 #[tokio::test]
2481 async fn interactive_retry_twiddle_still_fails_then_skip() {
2482 let (commit, _tmp) = make_twiddle_commit("abc00000");
2484 let cmd = make_twiddle_cmd();
2485 let repo_view = make_twiddle_repo_view(vec![commit]);
2486 let responses = (0..3).map(|_| Err(anyhow::anyhow!("mock fail"))).collect();
2488 let client = make_mock_client(responses);
2489 let mut failed = vec![0usize];
2490 let mut successes = vec![];
2491 let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
2492 cmd.run_interactive_retry_twiddle_check(
2493 &mut failed,
2494 &repo_view,
2495 &client,
2496 None,
2497 &[],
2498 &mut successes,
2499 &mut stdin,
2500 )
2501 .await
2502 .unwrap();
2503 assert_eq!(failed, vec![0], "commit still failed after retry");
2504 assert!(successes.is_empty());
2505 }
2506
2507 #[tokio::test]
2508 async fn interactive_retry_twiddle_invalid_input_then_skip() {
2509 let (commit, _tmp) = make_twiddle_commit("abc00000");
2511 let cmd = make_twiddle_cmd();
2512 let repo_view = make_twiddle_repo_view(vec![commit]);
2513 let client = make_mock_client(vec![]);
2514 let mut failed = vec![0usize];
2515 let mut successes = vec![];
2516 let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
2517 cmd.run_interactive_retry_twiddle_check(
2518 &mut failed,
2519 &repo_view,
2520 &client,
2521 None,
2522 &[],
2523 &mut successes,
2524 &mut stdin,
2525 )
2526 .await
2527 .unwrap();
2528 assert_eq!(failed, vec![0]);
2529 assert!(successes.is_empty());
2530 }
2531
2532 #[tokio::test]
2533 async fn interactive_retry_twiddle_eof_breaks_immediately() {
2534 let (commit, _tmp) = make_twiddle_commit("abc00000");
2537 let cmd = make_twiddle_cmd();
2538 let repo_view = make_twiddle_repo_view(vec![commit]);
2539 let client = make_mock_client(vec![]); let mut failed = vec![0usize];
2541 let mut successes = vec![];
2542 let mut stdin = std::io::Cursor::new(b"" as &[u8]);
2543 cmd.run_interactive_retry_twiddle_check(
2544 &mut failed,
2545 &repo_view,
2546 &client,
2547 None,
2548 &[],
2549 &mut successes,
2550 &mut stdin,
2551 )
2552 .await
2553 .unwrap();
2554 assert_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
2555 assert!(successes.is_empty());
2556 }
2557
2558 fn make_amendment_file() -> crate::data::amendments::AmendmentFile {
2561 crate::data::amendments::AmendmentFile {
2562 amendments: vec![crate::data::amendments::Amendment {
2563 commit: "abc0000000000000000000000000000000000001".to_string(),
2564 message: "feat: improved commit message".to_string(),
2565 summary: None,
2566 }],
2567 }
2568 }
2569
2570 #[test]
2571 fn handle_amendments_file_non_terminal_returns_false() {
2572 let cmd = make_twiddle_cmd();
2574 let amendments = make_amendment_file();
2575 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2576 let mut reader = std::io::Cursor::new(b"" as &[u8]);
2577 let result = cmd
2578 .handle_amendments_file(dummy_path, &amendments, false, &mut reader)
2579 .unwrap();
2580 assert!(!result, "non-terminal should return false");
2581 }
2582
2583 #[test]
2584 fn handle_amendments_file_eof_returns_false() {
2585 let cmd = make_twiddle_cmd();
2587 let amendments = make_amendment_file();
2588 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2589 let mut reader = std::io::Cursor::new(b"" as &[u8]);
2590 let result = cmd
2591 .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2592 .unwrap();
2593 assert!(!result, "EOF should return false");
2594 }
2595
2596 #[test]
2597 fn handle_amendments_file_quit_returns_false() {
2598 let cmd = make_twiddle_cmd();
2600 let amendments = make_amendment_file();
2601 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2602 let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
2603 let result = cmd
2604 .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2605 .unwrap();
2606 assert!(!result, "quit should return false");
2607 }
2608
2609 #[test]
2610 fn handle_amendments_file_apply_returns_true() {
2611 let cmd = make_twiddle_cmd();
2613 let amendments = make_amendment_file();
2614 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2615 let mut reader = std::io::Cursor::new(b"a\n" as &[u8]);
2616 let result = cmd
2617 .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2618 .unwrap();
2619 assert!(result, "apply should return true");
2620 }
2621
2622 #[test]
2623 fn handle_amendments_file_invalid_then_quit_returns_false() {
2624 let cmd = make_twiddle_cmd();
2626 let amendments = make_amendment_file();
2627 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
2628 let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
2629 let result = cmd
2630 .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
2631 .unwrap();
2632 assert!(!result, "invalid then quit should return false");
2633 }
2634
2635 const HASH_40: &str = "abc0000000000000000000000000000000000000";
2639
2640 fn twiddle_amendment_yaml(hash: &str) -> String {
2641 format!("amendments:\n - commit: \"{hash}\"\n message: \"feat: improved message\"\n")
2642 }
2643
2644 #[tokio::test]
2645 async fn retry_generate_amendments_non_terminal_returns_immediately() {
2646 let (commit, _tmp) = make_twiddle_commit("abc00000");
2648 let cmd = make_twiddle_cmd();
2649 let repo_view = make_twiddle_repo_view(vec![commit]);
2650 let client = make_mock_client(vec![]); let mut failed = vec![0usize];
2652 let mut successes = vec![];
2653 let mut reader = std::io::Cursor::new(b"" as &[u8]);
2654 cmd.run_interactive_retry_generate_amendments(
2655 &mut failed,
2656 &repo_view,
2657 &client,
2658 None,
2659 false,
2660 &mut successes,
2661 false, &mut reader,
2663 )
2664 .await
2665 .unwrap();
2666 assert_eq!(
2667 failed,
2668 vec![0],
2669 "non-terminal should leave failed unchanged"
2670 );
2671 assert!(successes.is_empty());
2672 }
2673
2674 #[tokio::test]
2675 async fn retry_generate_amendments_eof_breaks_immediately() {
2676 let (commit, _tmp) = make_twiddle_commit("abc00000");
2678 let cmd = make_twiddle_cmd();
2679 let repo_view = make_twiddle_repo_view(vec![commit]);
2680 let client = make_mock_client(vec![]); let mut failed = vec![0usize];
2682 let mut successes = vec![];
2683 let mut reader = std::io::Cursor::new(b"" as &[u8]);
2684 cmd.run_interactive_retry_generate_amendments(
2685 &mut failed,
2686 &repo_view,
2687 &client,
2688 None,
2689 false,
2690 &mut successes,
2691 true, &mut reader,
2693 )
2694 .await
2695 .unwrap();
2696 assert_eq!(failed, vec![0], "EOF should leave failed unchanged");
2697 assert!(successes.is_empty());
2698 }
2699
2700 #[tokio::test]
2701 async fn retry_generate_amendments_skip_breaks_immediately() {
2702 let (commit, _tmp) = make_twiddle_commit("abc00000");
2704 let cmd = make_twiddle_cmd();
2705 let repo_view = make_twiddle_repo_view(vec![commit]);
2706 let client = make_mock_client(vec![]); let mut failed = vec![0usize];
2708 let mut successes = vec![];
2709 let mut reader = std::io::Cursor::new(b"s\n" as &[u8]);
2710 cmd.run_interactive_retry_generate_amendments(
2711 &mut failed,
2712 &repo_view,
2713 &client,
2714 None,
2715 false,
2716 &mut successes,
2717 true,
2718 &mut reader,
2719 )
2720 .await
2721 .unwrap();
2722 assert_eq!(failed, vec![0], "skip should leave failed unchanged");
2723 assert!(successes.is_empty());
2724 }
2725
2726 #[tokio::test]
2727 async fn retry_generate_amendments_invalid_then_skip() {
2728 let (commit, _tmp) = make_twiddle_commit("abc00000");
2730 let cmd = make_twiddle_cmd();
2731 let repo_view = make_twiddle_repo_view(vec![commit]);
2732 let client = make_mock_client(vec![]);
2733 let mut failed = vec![0usize];
2734 let mut successes = vec![];
2735 let mut reader = std::io::Cursor::new(b"x\ns\n" as &[u8]);
2736 cmd.run_interactive_retry_generate_amendments(
2737 &mut failed,
2738 &repo_view,
2739 &client,
2740 None,
2741 false,
2742 &mut successes,
2743 true,
2744 &mut reader,
2745 )
2746 .await
2747 .unwrap();
2748 assert_eq!(failed, vec![0]);
2749 assert!(successes.is_empty());
2750 }
2751
2752 #[tokio::test]
2753 async fn retry_generate_amendments_retry_fails_then_skip() {
2754 let (commit, _tmp) = make_twiddle_commit("abc00000");
2756 let cmd = make_twiddle_cmd();
2757 let repo_view = make_twiddle_repo_view(vec![commit]);
2758 let client = make_mock_client(vec![Err(anyhow::anyhow!("mock fail"))]);
2759 let mut failed = vec![0usize];
2760 let mut successes = vec![];
2761 let mut reader = std::io::Cursor::new(b"r\ns\n" as &[u8]);
2762 cmd.run_interactive_retry_generate_amendments(
2763 &mut failed,
2764 &repo_view,
2765 &client,
2766 None,
2767 false,
2768 &mut successes,
2769 true,
2770 &mut reader,
2771 )
2772 .await
2773 .unwrap();
2774 assert_eq!(failed, vec![0], "commit still failed after retry");
2775 assert!(successes.is_empty());
2776 }
2777
2778 #[tokio::test]
2779 async fn retry_generate_amendments_retry_succeeds() {
2780 let (commit, _tmp) = make_twiddle_commit(HASH_40);
2782 let cmd = make_twiddle_cmd();
2783 let repo_view = make_twiddle_repo_view(vec![commit]);
2784 let client = make_mock_client(vec![Ok(twiddle_amendment_yaml(HASH_40))]);
2785 let mut failed = vec![0usize];
2786 let mut successes = vec![];
2787 let mut reader = std::io::Cursor::new(b"r\n" as &[u8]);
2788 cmd.run_interactive_retry_generate_amendments(
2789 &mut failed,
2790 &repo_view,
2791 &client,
2792 None,
2793 false,
2794 &mut successes,
2795 true,
2796 &mut reader,
2797 )
2798 .await
2799 .unwrap();
2800 assert!(failed.is_empty(), "retry succeeded → failed cleared");
2801 assert_eq!(successes.len(), 1);
2802 }
2803
2804 #[test]
2805 fn refine_amendment_scopes_replaces_scope_from_file_patterns() {
2806 use crate::data::amendments::Amendment;
2807 use crate::data::context::ScopeDefinition;
2808 use crate::git::commit::FileChange;
2809
2810 let (mut commit, _tmp) = make_twiddle_commit("aaa00000");
2812 commit.analysis.file_changes.file_list = vec![FileChange {
2813 status: "M".to_string(),
2814 file: "src/cli/git/twiddle.rs".to_string(),
2815 }];
2816
2817 let repo_view = make_twiddle_repo_view(vec![commit]);
2818
2819 let scope_defs = vec![ScopeDefinition {
2820 name: "cli".to_string(),
2821 description: "CLI commands".to_string(),
2822 examples: vec![],
2823 file_patterns: vec!["src/cli/**".to_string()],
2824 }];
2825
2826 let mut amendments = AmendmentFile {
2827 amendments: vec![Amendment {
2828 commit: "aaa00000".to_string(),
2829 message: "fix(wrong-scope): tweak something".to_string(),
2830 summary: None,
2831 }],
2832 };
2833
2834 refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
2835
2836 assert_eq!(
2837 amendments.amendments[0].message,
2838 "fix(cli): tweak something",
2839 );
2840 }
2841
2842 #[test]
2843 fn refine_amendment_scopes_no_match_leaves_message_unchanged() {
2844 use crate::data::amendments::Amendment;
2845
2846 let (commit, _tmp) = make_twiddle_commit("bbb00000");
2847 let repo_view = make_twiddle_repo_view(vec![commit]);
2848
2849 let mut amendments = AmendmentFile {
2850 amendments: vec![Amendment {
2851 commit: "bbb00000".to_string(),
2852 message: "feat(stuff): add feature".to_string(),
2853 summary: None,
2854 }],
2855 };
2856
2857 refine_amendment_scopes(&mut amendments, &repo_view, &[]);
2859
2860 assert_eq!(amendments.amendments[0].message, "feat(stuff): add feature",);
2861 }
2862
2863 #[test]
2864 fn refine_amendment_scopes_skips_unknown_commits() {
2865 use crate::data::amendments::Amendment;
2866 use crate::data::context::ScopeDefinition;
2867
2868 let (commit, _tmp) = make_twiddle_commit("ccc00000");
2869 let repo_view = make_twiddle_repo_view(vec![commit]);
2870
2871 let scope_defs = vec![ScopeDefinition {
2872 name: "cli".to_string(),
2873 description: "CLI".to_string(),
2874 examples: vec![],
2875 file_patterns: vec!["src/cli/**".to_string()],
2876 }];
2877
2878 let mut amendments = AmendmentFile {
2879 amendments: vec![Amendment {
2880 commit: "unknown_hash".to_string(),
2881 message: "fix(wrong): something".to_string(),
2882 summary: None,
2883 }],
2884 };
2885
2886 refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
2887
2888 assert_eq!(amendments.amendments[0].message, "fix(wrong): something",);
2890 }
2891}