1use std::fmt;
8
9use dialoguer::{MultiSelect, Select};
10
11use crate::config::PawConfig;
12use crate::error::PawError;
13use crate::specs::SpecEntry;
14
15pub struct CliInfo {
23 pub display_name: String,
25 pub binary_name: String,
27}
28
29impl fmt::Display for CliInfo {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 if self.display_name == self.binary_name {
32 write!(f, "{}", self.binary_name)
33 } else {
34 write!(f, "{} ({})", self.display_name, self.binary_name)
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CliMode {
42 Uniform,
44 PerBranch,
46}
47
48impl fmt::Display for CliMode {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 match self {
51 Self::Uniform => write!(f, "Same CLI for all branches"),
52 Self::PerBranch => write!(f, "Different CLI per branch"),
53 }
54 }
55}
56
57#[derive(Debug)]
59pub struct SelectionResult {
60 pub mappings: Vec<(String, String)>,
62}
63
64pub trait Prompter {
70 fn select_mode(&self) -> Result<CliMode, PawError>;
72
73 fn select_branches(&self, branches: &[String]) -> Result<Vec<String>, PawError>;
75
76 fn select_cli(&self, clis: &[CliInfo], default: Option<&str>) -> Result<String, PawError>;
81
82 fn select_cli_for_branch(&self, branch: &str, clis: &[CliInfo]) -> Result<String, PawError>;
84
85 fn select_specs(&self, specs: &[SpecEntry]) -> Result<Vec<SpecEntry>, PawError>;
92}
93
94pub struct TerminalPrompter;
100
101impl Prompter for TerminalPrompter {
102 fn select_mode(&self) -> Result<CliMode, PawError> {
103 let modes = [CliMode::Uniform, CliMode::PerBranch];
104 let labels: Vec<String> = modes.iter().map(ToString::to_string).collect();
105
106 let selection = Select::new()
107 .with_prompt("CLI assignment mode")
108 .items(&labels)
109 .default(0)
110 .interact_opt()
111 .map_err(|e| map_dialoguer_error(&e))?;
112
113 match selection {
114 Some(idx) => Ok(modes[idx]),
115 None => Err(PawError::UserCancelled),
116 }
117 }
118
119 fn select_branches(&self, branches: &[String]) -> Result<Vec<String>, PawError> {
120 let selection = MultiSelect::new()
121 .with_prompt("Select branches (space to toggle, enter to confirm)")
122 .items(branches)
123 .interact_opt()
124 .map_err(|e| map_dialoguer_error(&e))?;
125
126 match selection {
127 Some(indices) if indices.is_empty() => Err(PawError::UserCancelled),
128 Some(indices) => Ok(indices.into_iter().map(|i| branches[i].clone()).collect()),
129 None => Err(PawError::UserCancelled),
130 }
131 }
132
133 fn select_cli(&self, clis: &[CliInfo], default: Option<&str>) -> Result<String, PawError> {
134 let labels: Vec<String> = clis.iter().map(ToString::to_string).collect();
135
136 let default_idx = default
137 .and_then(|name| clis.iter().position(|c| c.binary_name == name))
138 .unwrap_or(0);
139
140 let selection = Select::new()
141 .with_prompt("Select AI CLI for all branches")
142 .items(&labels)
143 .default(default_idx)
144 .interact_opt()
145 .map_err(|e| map_dialoguer_error(&e))?;
146
147 match selection {
148 Some(idx) => Ok(clis[idx].binary_name.clone()),
149 None => Err(PawError::UserCancelled),
150 }
151 }
152
153 fn select_cli_for_branch(&self, branch: &str, clis: &[CliInfo]) -> Result<String, PawError> {
154 let labels: Vec<String> = clis.iter().map(ToString::to_string).collect();
155
156 let selection = Select::new()
157 .with_prompt(format!("Select CLI for {branch}"))
158 .items(&labels)
159 .default(0)
160 .interact_opt()
161 .map_err(|e| map_dialoguer_error(&e))?;
162
163 match selection {
164 Some(idx) => Ok(clis[idx].binary_name.clone()),
165 None => Err(PawError::UserCancelled),
166 }
167 }
168
169 fn select_specs(&self, specs: &[SpecEntry]) -> Result<Vec<SpecEntry>, PawError> {
170 let groups = group_specs_by_unit(specs);
171 let labels: Vec<String> = groups.iter().map(|(label, _)| label.clone()).collect();
172
173 let selection = MultiSelect::new()
174 .with_prompt("Select specs (space to toggle, enter to confirm)")
175 .items(&labels)
176 .interact_opt()
177 .map_err(|e| map_dialoguer_error(&e))?;
178
179 finalize_spec_selection(specs, &groups, selection)
180 }
181}
182
183fn finalize_spec_selection(
189 specs: &[SpecEntry],
190 groups: &[(String, Vec<usize>)],
191 selection: Option<Vec<usize>>,
192) -> Result<Vec<SpecEntry>, PawError> {
193 match selection {
194 Some(indices) if indices.is_empty() => Err(PawError::UserCancelled),
195 Some(indices) => {
196 let mut out = Vec::new();
197 for row in indices {
198 for &entry_idx in &groups[row].1 {
199 out.push(specs[entry_idx].clone());
200 }
201 }
202 Ok(out)
203 }
204 None => Err(PawError::UserCancelled),
205 }
206}
207
208fn group_specs_by_unit(specs: &[SpecEntry]) -> Vec<(String, Vec<usize>)> {
218 let mut order: Vec<String> = Vec::new();
219 let mut groups: std::collections::HashMap<String, Vec<usize>> =
220 std::collections::HashMap::new();
221
222 for (idx, entry) in specs.iter().enumerate() {
223 let unit = unit_id_of(&entry.id);
224 if !groups.contains_key(&unit) {
225 order.push(unit.clone());
226 }
227 groups.entry(unit).or_default().push(idx);
228 }
229
230 order
231 .into_iter()
232 .map(|unit| {
233 let idxs = groups.remove(&unit).unwrap_or_default();
234 let label = build_unit_label(&unit, &idxs, specs);
235 (label, idxs)
236 })
237 .collect()
238}
239
240fn unit_id_of(id: &str) -> String {
243 if let Some((before, after)) = id.rsplit_once("-phase-")
244 && !after.is_empty()
245 && after.chars().all(|c| c.is_ascii_digit())
246 {
247 return before.to_string();
248 }
249 if let Some((before, after)) = id.rsplit_once("-T")
250 && !after.is_empty()
251 && after.chars().all(|c| c.is_ascii_digit())
252 {
253 return before.to_string();
254 }
255 id.to_string()
256}
257
258fn build_unit_label(unit: &str, indices: &[usize], specs: &[SpecEntry]) -> String {
259 if indices.len() <= 1 {
260 return unit.to_string();
261 }
262 let total = indices.len();
263 let mut parallel = 0;
264 let mut phase = 0;
265 for &i in indices {
266 let id = &specs[i].id;
267 if id_is_parallel_task(id) {
268 parallel += 1;
269 } else if id_is_phase(id) {
270 phase += 1;
271 }
272 }
273 let mut parts = Vec::new();
274 if parallel > 0 {
275 parts.push(format!("{parallel} [P]"));
276 }
277 if phase > 0 {
278 parts.push(format!("{phase} phase/"));
279 }
280 if parts.is_empty() {
281 format!("{unit} \u{2014} {total} worktrees")
282 } else {
283 format!("{unit} \u{2014} {total} worktrees: {}", parts.join(" + "))
284 }
285}
286
287fn id_is_parallel_task(id: &str) -> bool {
288 let Some((_, after)) = id.rsplit_once("-T") else {
289 return false;
290 };
291 !after.is_empty() && after.chars().all(|c| c.is_ascii_digit())
292}
293
294fn id_is_phase(id: &str) -> bool {
295 let Some((_, after)) = id.rsplit_once("-phase-") else {
296 return false;
297 };
298 !after.is_empty() && after.chars().all(|c| c.is_ascii_digit())
299}
300
301fn map_dialoguer_error(err: &dialoguer::Error) -> PawError {
304 match err {
305 dialoguer::Error::IO(io_err) if io_err.kind() == std::io::ErrorKind::Interrupted => {
306 PawError::UserCancelled
307 }
308 dialoguer::Error::IO(_) => {
309 PawError::SessionError(format!("Interactive prompt failed: {err}"))
310 }
311 }
312}
313
314pub fn run_selection(
327 prompter: &dyn Prompter,
328 branches: &[String],
329 clis: &[CliInfo],
330 cli_flag: Option<&str>,
331 branches_flag: Option<&[String]>,
332) -> Result<SelectionResult, PawError> {
333 if clis.is_empty() {
334 return Err(PawError::NoCLIsFound);
335 }
336 if branches.is_empty() {
337 return Err(PawError::BranchError("No branches available.".to_string()));
338 }
339
340 let selected_branches = if let Some(flagged) = branches_flag {
342 flagged.to_vec()
343 } else {
344 prompter.select_branches(branches)?
345 };
346
347 let mappings = if let Some(cli) = cli_flag {
349 selected_branches
350 .into_iter()
351 .map(|branch| (branch, cli.to_string()))
352 .collect()
353 } else {
354 let mode = prompter.select_mode()?;
355 match mode {
356 CliMode::Uniform => {
357 let cli = prompter.select_cli(clis, None)?;
358 selected_branches
359 .into_iter()
360 .map(|branch| (branch, cli.clone()))
361 .collect()
362 }
363 CliMode::PerBranch => {
364 let mut pairs = Vec::with_capacity(selected_branches.len());
365 for branch in selected_branches {
366 let cli = prompter.select_cli_for_branch(&branch, clis)?;
367 pairs.push((branch, cli));
368 }
369 pairs
370 }
371 }
372 };
373
374 Ok(SelectionResult { mappings })
375}
376
377pub fn resolve_cli_for_specs(
393 specs: &[SpecEntry],
394 cli_flag: Option<&str>,
395 config: &PawConfig,
396 available_clis: &[CliInfo],
397 prompter: &dyn Prompter,
398) -> Result<Vec<(String, String)>, PawError> {
399 let cli_exists = |name: &str| available_clis.iter().any(|c| c.binary_name == name);
400
401 if let Some(flag) = cli_flag {
403 if !cli_exists(flag) {
404 return Err(PawError::CliNotFound(flag.to_string()));
405 }
406 return Ok(specs
407 .iter()
408 .map(|s| (s.branch.clone(), flag.to_string()))
409 .collect());
410 }
411
412 let mut mappings: Vec<(String, String)> = Vec::with_capacity(specs.len());
413 let mut remaining: Vec<&SpecEntry> = Vec::new();
414
415 for spec in specs {
417 if let Some(ref cli_name) = spec.cli {
418 if !cli_exists(cli_name) {
419 return Err(PawError::CliNotFound(cli_name.clone()));
420 }
421 mappings.push((spec.branch.clone(), cli_name.clone()));
422 } else {
423 remaining.push(spec);
424 }
425 }
426
427 if remaining.is_empty() {
428 return Ok(mappings);
429 }
430
431 if let Some(ref spec_cli) = config.default_spec_cli {
433 if !cli_exists(spec_cli) {
434 return Err(PawError::CliNotFound(spec_cli.clone()));
435 }
436 for spec in &remaining {
437 mappings.push((spec.branch.clone(), spec_cli.clone()));
438 }
439 return Ok(mappings);
440 }
441
442 let chosen = prompter.select_cli(available_clis, config.default_cli.as_deref())?;
444 for spec in &remaining {
445 mappings.push((spec.branch.clone(), chosen.clone()));
446 }
447
448 Ok(mappings)
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 use std::cell::Cell;
460
461 struct TrackingPrompter {
465 mode: CliMode,
466 branch_indices: Vec<usize>,
467 uniform_cli: String,
468 per_branch_clis: Vec<String>,
469 per_branch_call_count: Cell<usize>,
470 cancel_on_branch_select: bool,
471 cancel_on_cli_select: bool,
472 last_select_cli_default: Cell<Option<String>>,
474 }
475
476 impl TrackingPrompter {
477 fn uniform(branch_indices: Vec<usize>, cli: &str) -> Self {
478 Self {
479 mode: CliMode::Uniform,
480 branch_indices,
481 uniform_cli: cli.to_string(),
482 per_branch_clis: vec![],
483 per_branch_call_count: Cell::new(0),
484 cancel_on_branch_select: false,
485 cancel_on_cli_select: false,
486 last_select_cli_default: Cell::new(None),
487 }
488 }
489
490 fn per_branch(branch_indices: Vec<usize>, clis: Vec<&str>) -> Self {
491 Self {
492 mode: CliMode::PerBranch,
493 branch_indices,
494 uniform_cli: String::new(),
495 per_branch_clis: clis.into_iter().map(String::from).collect(),
496 per_branch_call_count: Cell::new(0),
497 cancel_on_branch_select: false,
498 cancel_on_cli_select: false,
499 last_select_cli_default: Cell::new(None),
500 }
501 }
502
503 fn cancel_on_branches() -> Self {
504 Self {
505 mode: CliMode::Uniform,
506 branch_indices: vec![],
507 uniform_cli: String::new(),
508 per_branch_clis: vec![],
509 per_branch_call_count: Cell::new(0),
510 cancel_on_branch_select: true,
511 cancel_on_cli_select: false,
512 last_select_cli_default: Cell::new(None),
513 }
514 }
515
516 fn cancel_on_cli(branch_indices: Vec<usize>) -> Self {
517 Self {
518 mode: CliMode::Uniform,
519 branch_indices,
520 uniform_cli: String::new(),
521 per_branch_clis: vec![],
522 per_branch_call_count: Cell::new(0),
523 cancel_on_branch_select: false,
524 cancel_on_cli_select: true,
525 last_select_cli_default: Cell::new(None),
526 }
527 }
528
529 fn for_specs(cli: &str) -> Self {
531 Self {
532 mode: CliMode::Uniform,
533 branch_indices: vec![],
534 uniform_cli: cli.to_string(),
535 per_branch_clis: vec![],
536 per_branch_call_count: Cell::new(0),
537 cancel_on_branch_select: false,
538 cancel_on_cli_select: false,
539 last_select_cli_default: Cell::new(None),
540 }
541 }
542 }
543
544 impl Prompter for TrackingPrompter {
545 fn select_mode(&self) -> Result<CliMode, PawError> {
546 Ok(self.mode)
547 }
548
549 fn select_branches(&self, branches: &[String]) -> Result<Vec<String>, PawError> {
550 if self.cancel_on_branch_select || self.branch_indices.is_empty() {
551 return Err(PawError::UserCancelled);
552 }
553 Ok(self
554 .branch_indices
555 .iter()
556 .map(|&i| branches[i].clone())
557 .collect())
558 }
559
560 fn select_cli(&self, _clis: &[CliInfo], default: Option<&str>) -> Result<String, PawError> {
561 self.last_select_cli_default.set(default.map(String::from));
562 if self.cancel_on_cli_select {
563 return Err(PawError::UserCancelled);
564 }
565 Ok(self.uniform_cli.clone())
566 }
567
568 fn select_cli_for_branch(
569 &self,
570 _branch: &str,
571 _clis: &[CliInfo],
572 ) -> Result<String, PawError> {
573 let idx = self.per_branch_call_count.get();
574 self.per_branch_call_count.set(idx + 1);
575 self.per_branch_clis
576 .get(idx)
577 .cloned()
578 .ok_or(PawError::UserCancelled)
579 }
580
581 fn select_specs(&self, _specs: &[SpecEntry]) -> Result<Vec<SpecEntry>, PawError> {
582 Err(PawError::UserCancelled)
583 }
584 }
585
586 fn test_clis() -> Vec<CliInfo> {
591 vec![
592 CliInfo {
593 display_name: "Alpha CLI".to_string(),
594 binary_name: "alpha".to_string(),
595 },
596 CliInfo {
597 display_name: "Beta CLI".to_string(),
598 binary_name: "beta".to_string(),
599 },
600 ]
601 }
602
603 fn test_branches() -> Vec<String> {
604 vec!["feature/auth".to_string(), "fix/api".to_string()]
605 }
606
607 #[test]
612 fn both_flags_skips_all_prompts_and_maps_cli_to_all_branches() {
613 let prompter = TrackingPrompter::cancel_on_branches(); let branches = test_branches();
615 let clis = test_clis();
616 let flag_branches = vec!["feature/auth".to_string(), "fix/api".to_string()];
617
618 let result = run_selection(
619 &prompter,
620 &branches,
621 &clis,
622 Some("alpha"),
623 Some(&flag_branches),
624 )
625 .unwrap();
626
627 assert_eq!(
628 result.mappings,
629 vec![
630 ("feature/auth".to_string(), "alpha".to_string()),
631 ("fix/api".to_string(), "alpha".to_string()),
632 ]
633 );
634 }
635
636 #[test]
637 fn cli_flag_skips_cli_prompt_but_prompts_for_branches() {
638 let prompter = TrackingPrompter::uniform(vec![0], "should-not-be-used");
639 let branches = test_branches();
640 let clis = test_clis();
641
642 let result = run_selection(&prompter, &branches, &clis, Some("alpha"), None).unwrap();
643
644 assert_eq!(
646 result.mappings,
647 vec![("feature/auth".to_string(), "alpha".to_string())]
648 );
649 }
650
651 #[test]
652 fn branches_flag_skips_branch_prompt_but_prompts_for_cli_uniform() {
653 let prompter = TrackingPrompter::uniform(vec![], "beta");
654 let branches = test_branches();
655 let clis = test_clis();
656 let flag_branches = vec!["feature/auth".to_string(), "fix/api".to_string()];
657
658 let result =
659 run_selection(&prompter, &branches, &clis, None, Some(&flag_branches)).unwrap();
660
661 assert_eq!(
662 result.mappings,
663 vec![
664 ("feature/auth".to_string(), "beta".to_string()),
665 ("fix/api".to_string(), "beta".to_string()),
666 ]
667 );
668 }
669
670 #[test]
675 fn uniform_mode_maps_same_cli_to_all_selected_branches() {
676 let prompter = TrackingPrompter::uniform(vec![0, 1], "alpha");
677 let branches = test_branches();
678 let clis = test_clis();
679
680 let result = run_selection(&prompter, &branches, &clis, None, None).unwrap();
681
682 assert_eq!(
683 result.mappings,
684 vec![
685 ("feature/auth".to_string(), "alpha".to_string()),
686 ("fix/api".to_string(), "alpha".to_string()),
687 ]
688 );
689 }
690
691 #[test]
692 fn per_branch_mode_maps_different_cli_to_each_branch() {
693 let prompter = TrackingPrompter::per_branch(vec![0, 1], vec!["alpha", "beta"]);
694 let branches = test_branches();
695 let clis = test_clis();
696
697 let result = run_selection(&prompter, &branches, &clis, None, None).unwrap();
698
699 assert_eq!(
700 result.mappings,
701 vec![
702 ("feature/auth".to_string(), "alpha".to_string()),
703 ("fix/api".to_string(), "beta".to_string()),
704 ]
705 );
706 }
707
708 #[test]
709 fn per_branch_mode_with_branches_flag() {
710 let prompter = TrackingPrompter::per_branch(vec![], vec!["beta", "alpha"]);
711 let branches = test_branches();
712 let clis = test_clis();
713 let flag_branches = vec!["feature/auth".to_string(), "fix/api".to_string()];
714
715 let result =
716 run_selection(&prompter, &branches, &clis, None, Some(&flag_branches)).unwrap();
717
718 assert_eq!(
719 result.mappings,
720 vec![
721 ("feature/auth".to_string(), "beta".to_string()),
722 ("fix/api".to_string(), "alpha".to_string()),
723 ]
724 );
725 }
726
727 #[test]
732 fn no_clis_available_returns_error() {
733 let prompter = TrackingPrompter::cancel_on_branches();
734 let branches = test_branches();
735 let clis: Vec<CliInfo> = vec![];
736
737 let result = run_selection(&prompter, &branches, &clis, None, None);
738
739 assert!(matches!(result, Err(PawError::NoCLIsFound)));
740 }
741
742 #[test]
743 fn no_branches_available_returns_error() {
744 let prompter = TrackingPrompter::cancel_on_branches();
745 let branches: Vec<String> = vec![];
746 let clis = test_clis();
747
748 let result = run_selection(&prompter, &branches, &clis, None, None);
749
750 assert!(matches!(result, Err(PawError::BranchError(_))));
751 }
752
753 #[test]
754 fn user_cancels_branch_selection_returns_cancelled() {
755 let prompter = TrackingPrompter::cancel_on_branches();
756 let branches = test_branches();
757 let clis = test_clis();
758
759 let result = run_selection(&prompter, &branches, &clis, None, None);
760
761 assert!(matches!(result, Err(PawError::UserCancelled)));
762 }
763
764 #[test]
765 fn user_selects_no_branches_returns_cancelled() {
766 let prompter = TrackingPrompter::uniform(vec![], "alpha");
768 let branches = test_branches();
769 let clis = test_clis();
770
771 let result = run_selection(&prompter, &branches, &clis, None, None);
772
773 assert!(matches!(result, Err(PawError::UserCancelled)));
774 }
775
776 #[test]
777 fn user_cancels_cli_selection_returns_cancelled() {
778 let prompter = TrackingPrompter::cancel_on_cli(vec![0]);
779 let branches = test_branches();
780 let clis = test_clis();
781
782 let result = run_selection(&prompter, &branches, &clis, None, None);
783
784 assert!(matches!(result, Err(PawError::UserCancelled)));
785 }
786
787 #[test]
792 fn selecting_subset_of_branches_works() {
793 let prompter = TrackingPrompter::uniform(vec![1], "alpha"); let branches = test_branches();
795 let clis = test_clis();
796
797 let result = run_selection(&prompter, &branches, &clis, None, None).unwrap();
798
799 assert_eq!(
800 result.mappings,
801 vec![("fix/api".to_string(), "alpha".to_string())]
802 );
803 }
804
805 #[test]
810 fn cli_mode_display() {
811 assert_eq!(CliMode::Uniform.to_string(), "Same CLI for all branches");
812 assert_eq!(CliMode::PerBranch.to_string(), "Different CLI per branch");
813 }
814
815 #[test]
816 fn cli_info_display_same_names() {
817 let info = CliInfo {
818 display_name: "claude".to_string(),
819 binary_name: "claude".to_string(),
820 };
821 assert_eq!(info.to_string(), "claude");
822 }
823
824 #[test]
825 fn cli_info_display_different_names() {
826 let info = CliInfo {
827 display_name: "My Agent".to_string(),
828 binary_name: "my-agent".to_string(),
829 };
830 assert_eq!(info.to_string(), "My Agent (my-agent)");
831 }
832
833 fn default_config() -> PawConfig {
838 PawConfig::default()
839 }
840
841 fn spec(branch: &str, cli: Option<&str>) -> SpecEntry {
842 SpecEntry {
843 id: branch.to_string(),
844 backend: crate::specs::SpecBackendKind::Markdown,
845 branch: branch.to_string(),
846 cli: cli.map(String::from),
847 prompt: String::new(),
848 owned_files: None,
849 }
850 }
851
852 fn test_specs() -> Vec<SpecEntry> {
853 vec![
854 spec("spec/auth", None),
855 spec("spec/api", None),
856 spec("spec/db", None),
857 ]
858 }
859
860 #[test]
861 fn cli_flag_overrides_all_specs() {
862 let prompter = TrackingPrompter::for_specs("should-not-be-used");
863 let clis = test_clis();
864 let specs = test_specs();
865
866 let result =
867 resolve_cli_for_specs(&specs, Some("alpha"), &default_config(), &clis, &prompter)
868 .unwrap();
869
870 assert_eq!(result.len(), 3);
871 assert!(result.iter().all(|(_, cli)| cli == "alpha"));
872 }
873
874 #[test]
875 fn paw_cli_per_spec_overrides_config() {
876 let specs = vec![spec("spec/auth", Some("beta")), spec("spec/api", None)];
877 let mut config = default_config();
878 config.default_spec_cli = Some("alpha".to_string());
879 let prompter = TrackingPrompter::for_specs("should-not-be-used");
880 let clis = test_clis();
881
882 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
883
884 assert!(result.iter().any(|(b, c)| b == "spec/auth" && c == "beta"));
885 assert!(result.iter().any(|(b, c)| b == "spec/api" && c == "alpha"));
886 }
887
888 #[test]
889 fn default_spec_cli_fills_remaining_without_prompt() {
890 let mut config = default_config();
891 config.default_spec_cli = Some("alpha".to_string());
892 let prompter = TrackingPrompter::cancel_on_cli(vec![]); let clis = test_clis();
894 let specs = test_specs();
895
896 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
897
898 assert_eq!(result.len(), 3);
899 assert!(result.iter().all(|(_, cli)| cli == "alpha"));
900 }
901
902 #[test]
903 fn default_cli_pre_selects_in_picker() {
904 let mut config = default_config();
905 config.default_cli = Some("beta".to_string());
906 let prompter = TrackingPrompter::for_specs("beta");
907 let clis = test_clis();
908 let specs = vec![spec("spec/auth", None)];
909
910 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
911
912 assert_eq!(result, vec![("spec/auth".to_string(), "beta".to_string())]);
913 assert_eq!(
915 prompter.last_select_cli_default.take(),
916 Some("beta".to_string())
917 );
918 }
919
920 #[test]
921 fn no_defaults_picker_fires_with_none_default() {
922 let prompter = TrackingPrompter::for_specs("alpha");
923 let clis = test_clis();
924 let specs = vec![spec("spec/auth", None)];
925
926 let result =
927 resolve_cli_for_specs(&specs, None, &default_config(), &clis, &prompter).unwrap();
928
929 assert_eq!(result, vec![("spec/auth".to_string(), "alpha".to_string())]);
930 assert_eq!(prompter.last_select_cli_default.take(), None);
931 }
932
933 #[test]
934 fn mixed_paw_cli_and_default_spec_cli() {
935 let specs = vec![
936 spec("spec/auth", Some("beta")),
937 spec("spec/api", None),
938 spec("spec/db", None),
939 ];
940 let mut config = default_config();
941 config.default_spec_cli = Some("alpha".to_string());
942 let prompter = TrackingPrompter::for_specs("should-not-be-used");
943 let clis = test_clis();
944
945 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
946
947 assert_eq!(result.len(), 3);
948 assert!(result.iter().any(|(b, c)| b == "spec/auth" && c == "beta"));
949 assert!(result.iter().any(|(b, c)| b == "spec/api" && c == "alpha"));
950 assert!(result.iter().any(|(b, c)| b == "spec/db" && c == "alpha"));
951 }
952
953 #[test]
954 fn mixed_paw_cli_and_interactive() {
955 let specs = vec![
956 spec("spec/auth", Some("beta")),
957 spec("spec/api", None),
958 spec("spec/db", None),
959 ];
960 let prompter = TrackingPrompter::for_specs("alpha");
961 let clis = test_clis();
962
963 let result =
964 resolve_cli_for_specs(&specs, None, &default_config(), &clis, &prompter).unwrap();
965
966 assert_eq!(result.len(), 3);
967 assert!(result.iter().any(|(b, c)| b == "spec/auth" && c == "beta"));
968 assert!(result.iter().any(|(b, c)| b == "spec/api" && c == "alpha"));
969 assert!(result.iter().any(|(b, c)| b == "spec/db" && c == "alpha"));
970 }
971
972 #[test]
973 fn picker_fires_at_most_once_for_multiple_remaining() {
974 let specs = vec![
975 spec("spec/a", Some("beta")),
976 spec("spec/b", None),
977 spec("spec/c", None),
978 spec("spec/d", None),
979 ];
980 let prompter = TrackingPrompter::for_specs("alpha");
983 let clis = test_clis();
984
985 let result =
986 resolve_cli_for_specs(&specs, None, &default_config(), &clis, &prompter).unwrap();
987
988 let remaining: Vec<_> = result.iter().filter(|(_, c)| c == "alpha").collect();
989 assert_eq!(remaining.len(), 3);
990 }
991
992 #[test]
993 fn all_resolved_via_flag_no_prompt() {
994 let prompter = TrackingPrompter::cancel_on_cli(vec![]); let clis = test_clis();
996 let specs = test_specs();
997
998 let result =
999 resolve_cli_for_specs(&specs, Some("alpha"), &default_config(), &clis, &prompter)
1000 .unwrap();
1001 assert_eq!(result.len(), 3);
1002 }
1003
1004 #[test]
1005 fn all_resolved_via_paw_cli_and_default_spec_cli_no_prompt() {
1006 let specs = vec![spec("spec/auth", Some("alpha")), spec("spec/api", None)];
1007 let mut config = default_config();
1008 config.default_spec_cli = Some("beta".to_string());
1009 let prompter = TrackingPrompter::cancel_on_cli(vec![]); let clis = test_clis();
1011
1012 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
1013 assert_eq!(result.len(), 2);
1014 }
1015
1016 #[test]
1017 fn paw_cli_references_unknown_cli_returns_error() {
1018 let specs = vec![spec("spec/auth", Some("nonexistent"))];
1019 let prompter = TrackingPrompter::for_specs("alpha");
1020 let clis = test_clis();
1021
1022 let result = resolve_cli_for_specs(&specs, None, &default_config(), &clis, &prompter);
1023 assert!(matches!(result, Err(PawError::CliNotFound(ref name)) if name == "nonexistent"));
1024 }
1025
1026 #[test]
1027 fn default_spec_cli_references_unknown_cli_returns_error() {
1028 let mut config = default_config();
1029 config.default_spec_cli = Some("nonexistent".to_string());
1030 let prompter = TrackingPrompter::for_specs("alpha");
1031 let clis = test_clis();
1032 let specs = test_specs();
1033
1034 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter);
1035 assert!(matches!(result, Err(PawError::CliNotFound(ref name)) if name == "nonexistent"));
1036 }
1037
1038 #[test]
1039 fn cli_flag_references_unknown_cli_returns_error() {
1040 let prompter = TrackingPrompter::for_specs("alpha");
1041 let clis = test_clis();
1042 let specs = test_specs();
1043
1044 let result = resolve_cli_for_specs(
1045 &specs,
1046 Some("nonexistent"),
1047 &default_config(),
1048 &clis,
1049 &prompter,
1050 );
1051 assert!(matches!(result, Err(PawError::CliNotFound(ref name)) if name == "nonexistent"));
1052 }
1053
1054 #[test]
1055 fn select_cli_with_default_present_and_in_list() {
1056 let prompter = TrackingPrompter::for_specs("beta");
1057 let clis = test_clis();
1058 let specs = vec![spec("spec/x", None)];
1059 let mut config = default_config();
1060 config.default_cli = Some("beta".to_string());
1061
1062 resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
1063
1064 assert_eq!(
1065 prompter.last_select_cli_default.take(),
1066 Some("beta".to_string())
1067 );
1068 }
1069
1070 #[test]
1071 fn select_cli_with_default_not_in_list_graceful() {
1072 let prompter = TrackingPrompter::for_specs("alpha");
1073 let clis = test_clis();
1074 let specs = vec![spec("spec/x", None)];
1075 let mut config = default_config();
1076 config.default_cli = Some("nonexistent".to_string());
1077
1078 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
1080 assert_eq!(result, vec![("spec/x".to_string(), "alpha".to_string())]);
1081 assert_eq!(
1082 prompter.last_select_cli_default.take(),
1083 Some("nonexistent".to_string())
1084 );
1085 }
1086
1087 fn bare_spec(id: &str) -> SpecEntry {
1092 SpecEntry {
1093 id: id.to_string(),
1094 backend: crate::specs::SpecBackendKind::Markdown,
1095 branch: format!("spec/{id}"),
1096 cli: None,
1097 prompt: String::new(),
1098 owned_files: None,
1099 }
1100 }
1101
1102 #[test]
1103 fn group_flat_specs_yields_one_row_each() {
1104 let specs = vec![
1105 bare_spec("add-auth"),
1106 bare_spec("fix-session"),
1107 bare_spec("add-logging"),
1108 ];
1109 let groups = group_specs_by_unit(&specs);
1110 let labels: Vec<&str> = groups.iter().map(|(l, _)| l.as_str()).collect();
1111 assert_eq!(labels, vec!["add-auth", "fix-session", "add-logging"]);
1112 for (_, idxs) in &groups {
1113 assert_eq!(idxs.len(), 1);
1114 }
1115 }
1116
1117 #[test]
1118 fn finalize_spec_selection_returns_chosen_subset_for_flat_entries() {
1119 let specs = vec![
1120 bare_spec("add-auth"),
1121 bare_spec("fix-session"),
1122 bare_spec("add-logging"),
1123 ];
1124 let groups = group_specs_by_unit(&specs);
1125 let result = finalize_spec_selection(&specs, &groups, Some(vec![0, 2])).unwrap();
1127 let ids: Vec<&str> = result.iter().map(|e| e.id.as_str()).collect();
1128 assert_eq!(ids, vec!["add-auth", "add-logging"]);
1129 }
1130
1131 #[test]
1132 fn finalize_spec_selection_expands_spec_kit_feature_row_to_all_entries() {
1133 let specs = vec![
1134 bare_spec("003-user-list-T009"),
1135 bare_spec("003-user-list-T010"),
1136 bare_spec("003-user-list-phase-2"),
1137 ];
1138 let groups = group_specs_by_unit(&specs);
1139 let result = finalize_spec_selection(&specs, &groups, Some(vec![0])).unwrap();
1141 let ids: Vec<&str> = result.iter().map(|e| e.id.as_str()).collect();
1142 assert_eq!(
1143 ids,
1144 vec![
1145 "003-user-list-T009",
1146 "003-user-list-T010",
1147 "003-user-list-phase-2",
1148 ]
1149 );
1150 }
1151
1152 #[test]
1153 fn finalize_spec_selection_none_returns_user_cancelled() {
1154 let specs = vec![bare_spec("add-auth")];
1156 let groups = group_specs_by_unit(&specs);
1157 let result = finalize_spec_selection(&specs, &groups, None);
1158 assert!(matches!(result, Err(PawError::UserCancelled)));
1159 }
1160
1161 #[test]
1162 fn finalize_spec_selection_empty_indices_returns_user_cancelled() {
1163 let specs = vec![bare_spec("add-auth"), bare_spec("fix-session")];
1165 let groups = group_specs_by_unit(&specs);
1166 let result = finalize_spec_selection(&specs, &groups, Some(vec![]));
1167 assert!(matches!(result, Err(PawError::UserCancelled)));
1168 }
1169
1170 #[test]
1171 fn group_spec_kit_feature_collapses_to_one_row_with_count_hint() {
1172 let specs = vec![
1173 bare_spec("003-user-list-T009"),
1174 bare_spec("003-user-list-T010"),
1175 bare_spec("003-user-list-phase-2"),
1176 bare_spec("004-error-handling-phase-1"),
1177 ];
1178 let groups = group_specs_by_unit(&specs);
1179 assert_eq!(groups.len(), 2);
1180 let user_list = &groups[0];
1181 assert!(
1182 user_list.0.starts_with("003-user-list"),
1183 "first group label should start with feature id; got: {}",
1184 user_list.0
1185 );
1186 assert!(user_list.0.contains("3 worktrees"), "got: {}", user_list.0);
1187 assert!(user_list.0.contains("2 [P]"), "got: {}", user_list.0);
1188 assert!(user_list.0.contains("1 phase/"), "got: {}", user_list.0);
1189 assert_eq!(user_list.1.len(), 3);
1190
1191 let error_handling = &groups[1];
1192 assert_eq!(error_handling.0, "004-error-handling");
1193 assert_eq!(error_handling.1.len(), 1);
1194 }
1195
1196 struct CancelOnSpecsPrompter;
1208
1209 impl Prompter for CancelOnSpecsPrompter {
1210 fn select_mode(&self) -> Result<CliMode, PawError> {
1211 Err(PawError::UserCancelled)
1212 }
1213 fn select_branches(&self, _branches: &[String]) -> Result<Vec<String>, PawError> {
1214 Err(PawError::UserCancelled)
1215 }
1216 fn select_cli(
1217 &self,
1218 _clis: &[CliInfo],
1219 _default: Option<&str>,
1220 ) -> Result<String, PawError> {
1221 Err(PawError::UserCancelled)
1222 }
1223 fn select_cli_for_branch(
1224 &self,
1225 _branch: &str,
1226 _clis: &[CliInfo],
1227 ) -> Result<String, PawError> {
1228 Err(PawError::UserCancelled)
1229 }
1230 fn select_specs(&self, _specs: &[SpecEntry]) -> Result<Vec<SpecEntry>, PawError> {
1231 Err(PawError::UserCancelled)
1232 }
1233 }
1234
1235 #[test]
1238 fn select_specs_cancel_returns_user_cancelled() {
1239 let prompter = CancelOnSpecsPrompter;
1240 let specs = vec![bare_spec("003-user-list")];
1241 let result = prompter.select_specs(&specs);
1242 assert!(
1243 matches!(result, Err(PawError::UserCancelled)),
1244 "select_specs cancel path must propagate UserCancelled; got: {result:?}"
1245 );
1246 }
1247
1248 #[test]
1256 fn select_specs_zero_selection_returns_user_cancelled() {
1257 let specs = vec![bare_spec("003-user-list")];
1258 let groups = group_specs_by_unit(&specs);
1259 let result = finalize_spec_selection(&specs, &groups, Some(vec![]));
1262 assert!(
1263 matches!(result, Err(PawError::UserCancelled)),
1264 "zero-row confirmation must map to UserCancelled; got: {result:?}"
1265 );
1266 }
1267}