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
86pub struct TerminalPrompter;
92
93impl Prompter for TerminalPrompter {
94 fn select_mode(&self) -> Result<CliMode, PawError> {
95 let modes = [CliMode::Uniform, CliMode::PerBranch];
96 let labels: Vec<String> = modes.iter().map(ToString::to_string).collect();
97
98 let selection = Select::new()
99 .with_prompt("CLI assignment mode")
100 .items(&labels)
101 .default(0)
102 .interact_opt()
103 .map_err(|e| map_dialoguer_error(&e))?;
104
105 match selection {
106 Some(idx) => Ok(modes[idx]),
107 None => Err(PawError::UserCancelled),
108 }
109 }
110
111 fn select_branches(&self, branches: &[String]) -> Result<Vec<String>, PawError> {
112 let selection = MultiSelect::new()
113 .with_prompt("Select branches (space to toggle, enter to confirm)")
114 .items(branches)
115 .interact_opt()
116 .map_err(|e| map_dialoguer_error(&e))?;
117
118 match selection {
119 Some(indices) if indices.is_empty() => Err(PawError::UserCancelled),
120 Some(indices) => Ok(indices.into_iter().map(|i| branches[i].clone()).collect()),
121 None => Err(PawError::UserCancelled),
122 }
123 }
124
125 fn select_cli(&self, clis: &[CliInfo], default: Option<&str>) -> Result<String, PawError> {
126 let labels: Vec<String> = clis.iter().map(ToString::to_string).collect();
127
128 let default_idx = default
129 .and_then(|name| clis.iter().position(|c| c.binary_name == name))
130 .unwrap_or(0);
131
132 let selection = Select::new()
133 .with_prompt("Select AI CLI for all branches")
134 .items(&labels)
135 .default(default_idx)
136 .interact_opt()
137 .map_err(|e| map_dialoguer_error(&e))?;
138
139 match selection {
140 Some(idx) => Ok(clis[idx].binary_name.clone()),
141 None => Err(PawError::UserCancelled),
142 }
143 }
144
145 fn select_cli_for_branch(&self, branch: &str, clis: &[CliInfo]) -> Result<String, PawError> {
146 let labels: Vec<String> = clis.iter().map(ToString::to_string).collect();
147
148 let selection = Select::new()
149 .with_prompt(format!("Select CLI for {branch}"))
150 .items(&labels)
151 .default(0)
152 .interact_opt()
153 .map_err(|e| map_dialoguer_error(&e))?;
154
155 match selection {
156 Some(idx) => Ok(clis[idx].binary_name.clone()),
157 None => Err(PawError::UserCancelled),
158 }
159 }
160}
161
162fn map_dialoguer_error(err: &dialoguer::Error) -> PawError {
165 match err {
166 dialoguer::Error::IO(io_err) if io_err.kind() == std::io::ErrorKind::Interrupted => {
167 PawError::UserCancelled
168 }
169 dialoguer::Error::IO(_) => {
170 PawError::SessionError(format!("Interactive prompt failed: {err}"))
171 }
172 }
173}
174
175pub fn run_selection(
188 prompter: &dyn Prompter,
189 branches: &[String],
190 clis: &[CliInfo],
191 cli_flag: Option<&str>,
192 branches_flag: Option<&[String]>,
193) -> Result<SelectionResult, PawError> {
194 if clis.is_empty() {
195 return Err(PawError::NoCLIsFound);
196 }
197 if branches.is_empty() {
198 return Err(PawError::BranchError("No branches available.".to_string()));
199 }
200
201 let selected_branches = if let Some(flagged) = branches_flag {
203 flagged.to_vec()
204 } else {
205 prompter.select_branches(branches)?
206 };
207
208 let mappings = if let Some(cli) = cli_flag {
210 selected_branches
211 .into_iter()
212 .map(|branch| (branch, cli.to_string()))
213 .collect()
214 } else {
215 let mode = prompter.select_mode()?;
216 match mode {
217 CliMode::Uniform => {
218 let cli = prompter.select_cli(clis, None)?;
219 selected_branches
220 .into_iter()
221 .map(|branch| (branch, cli.clone()))
222 .collect()
223 }
224 CliMode::PerBranch => {
225 let mut pairs = Vec::with_capacity(selected_branches.len());
226 for branch in selected_branches {
227 let cli = prompter.select_cli_for_branch(&branch, clis)?;
228 pairs.push((branch, cli));
229 }
230 pairs
231 }
232 }
233 };
234
235 Ok(SelectionResult { mappings })
236}
237
238pub fn resolve_cli_for_specs(
254 specs: &[SpecEntry],
255 cli_flag: Option<&str>,
256 config: &PawConfig,
257 available_clis: &[CliInfo],
258 prompter: &dyn Prompter,
259) -> Result<Vec<(String, String)>, PawError> {
260 let cli_exists = |name: &str| available_clis.iter().any(|c| c.binary_name == name);
261
262 if let Some(flag) = cli_flag {
264 if !cli_exists(flag) {
265 return Err(PawError::CliNotFound(flag.to_string()));
266 }
267 return Ok(specs
268 .iter()
269 .map(|s| (s.branch.clone(), flag.to_string()))
270 .collect());
271 }
272
273 let mut mappings: Vec<(String, String)> = Vec::with_capacity(specs.len());
274 let mut remaining: Vec<&SpecEntry> = Vec::new();
275
276 for spec in specs {
278 if let Some(ref cli_name) = spec.cli {
279 if !cli_exists(cli_name) {
280 return Err(PawError::CliNotFound(cli_name.clone()));
281 }
282 mappings.push((spec.branch.clone(), cli_name.clone()));
283 } else {
284 remaining.push(spec);
285 }
286 }
287
288 if remaining.is_empty() {
289 return Ok(mappings);
290 }
291
292 if let Some(ref spec_cli) = config.default_spec_cli {
294 if !cli_exists(spec_cli) {
295 return Err(PawError::CliNotFound(spec_cli.clone()));
296 }
297 for spec in &remaining {
298 mappings.push((spec.branch.clone(), spec_cli.clone()));
299 }
300 return Ok(mappings);
301 }
302
303 let chosen = prompter.select_cli(available_clis, config.default_cli.as_deref())?;
305 for spec in &remaining {
306 mappings.push((spec.branch.clone(), chosen.clone()));
307 }
308
309 Ok(mappings)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 use std::cell::Cell;
321
322 struct TrackingPrompter {
326 mode: CliMode,
327 branch_indices: Vec<usize>,
328 uniform_cli: String,
329 per_branch_clis: Vec<String>,
330 per_branch_call_count: Cell<usize>,
331 cancel_on_branch_select: bool,
332 cancel_on_cli_select: bool,
333 last_select_cli_default: Cell<Option<String>>,
335 }
336
337 impl TrackingPrompter {
338 fn uniform(branch_indices: Vec<usize>, cli: &str) -> Self {
339 Self {
340 mode: CliMode::Uniform,
341 branch_indices,
342 uniform_cli: cli.to_string(),
343 per_branch_clis: vec![],
344 per_branch_call_count: Cell::new(0),
345 cancel_on_branch_select: false,
346 cancel_on_cli_select: false,
347 last_select_cli_default: Cell::new(None),
348 }
349 }
350
351 fn per_branch(branch_indices: Vec<usize>, clis: Vec<&str>) -> Self {
352 Self {
353 mode: CliMode::PerBranch,
354 branch_indices,
355 uniform_cli: String::new(),
356 per_branch_clis: clis.into_iter().map(String::from).collect(),
357 per_branch_call_count: Cell::new(0),
358 cancel_on_branch_select: false,
359 cancel_on_cli_select: false,
360 last_select_cli_default: Cell::new(None),
361 }
362 }
363
364 fn cancel_on_branches() -> Self {
365 Self {
366 mode: CliMode::Uniform,
367 branch_indices: vec![],
368 uniform_cli: String::new(),
369 per_branch_clis: vec![],
370 per_branch_call_count: Cell::new(0),
371 cancel_on_branch_select: true,
372 cancel_on_cli_select: false,
373 last_select_cli_default: Cell::new(None),
374 }
375 }
376
377 fn cancel_on_cli(branch_indices: Vec<usize>) -> Self {
378 Self {
379 mode: CliMode::Uniform,
380 branch_indices,
381 uniform_cli: String::new(),
382 per_branch_clis: vec![],
383 per_branch_call_count: Cell::new(0),
384 cancel_on_branch_select: false,
385 cancel_on_cli_select: true,
386 last_select_cli_default: Cell::new(None),
387 }
388 }
389
390 fn for_specs(cli: &str) -> Self {
392 Self {
393 mode: CliMode::Uniform,
394 branch_indices: vec![],
395 uniform_cli: cli.to_string(),
396 per_branch_clis: vec![],
397 per_branch_call_count: Cell::new(0),
398 cancel_on_branch_select: false,
399 cancel_on_cli_select: false,
400 last_select_cli_default: Cell::new(None),
401 }
402 }
403 }
404
405 impl Prompter for TrackingPrompter {
406 fn select_mode(&self) -> Result<CliMode, PawError> {
407 Ok(self.mode)
408 }
409
410 fn select_branches(&self, branches: &[String]) -> Result<Vec<String>, PawError> {
411 if self.cancel_on_branch_select || self.branch_indices.is_empty() {
412 return Err(PawError::UserCancelled);
413 }
414 Ok(self
415 .branch_indices
416 .iter()
417 .map(|&i| branches[i].clone())
418 .collect())
419 }
420
421 fn select_cli(&self, _clis: &[CliInfo], default: Option<&str>) -> Result<String, PawError> {
422 self.last_select_cli_default.set(default.map(String::from));
423 if self.cancel_on_cli_select {
424 return Err(PawError::UserCancelled);
425 }
426 Ok(self.uniform_cli.clone())
427 }
428
429 fn select_cli_for_branch(
430 &self,
431 _branch: &str,
432 _clis: &[CliInfo],
433 ) -> Result<String, PawError> {
434 let idx = self.per_branch_call_count.get();
435 self.per_branch_call_count.set(idx + 1);
436 self.per_branch_clis
437 .get(idx)
438 .cloned()
439 .ok_or(PawError::UserCancelled)
440 }
441 }
442
443 fn test_clis() -> Vec<CliInfo> {
448 vec![
449 CliInfo {
450 display_name: "Alpha CLI".to_string(),
451 binary_name: "alpha".to_string(),
452 },
453 CliInfo {
454 display_name: "Beta CLI".to_string(),
455 binary_name: "beta".to_string(),
456 },
457 ]
458 }
459
460 fn test_branches() -> Vec<String> {
461 vec!["feature/auth".to_string(), "fix/api".to_string()]
462 }
463
464 #[test]
469 fn both_flags_skips_all_prompts_and_maps_cli_to_all_branches() {
470 let prompter = TrackingPrompter::cancel_on_branches(); let branches = test_branches();
472 let clis = test_clis();
473 let flag_branches = vec!["feature/auth".to_string(), "fix/api".to_string()];
474
475 let result = run_selection(
476 &prompter,
477 &branches,
478 &clis,
479 Some("alpha"),
480 Some(&flag_branches),
481 )
482 .unwrap();
483
484 assert_eq!(
485 result.mappings,
486 vec![
487 ("feature/auth".to_string(), "alpha".to_string()),
488 ("fix/api".to_string(), "alpha".to_string()),
489 ]
490 );
491 }
492
493 #[test]
494 fn cli_flag_skips_cli_prompt_but_prompts_for_branches() {
495 let prompter = TrackingPrompter::uniform(vec![0], "should-not-be-used");
496 let branches = test_branches();
497 let clis = test_clis();
498
499 let result = run_selection(&prompter, &branches, &clis, Some("alpha"), None).unwrap();
500
501 assert_eq!(
503 result.mappings,
504 vec![("feature/auth".to_string(), "alpha".to_string())]
505 );
506 }
507
508 #[test]
509 fn branches_flag_skips_branch_prompt_but_prompts_for_cli_uniform() {
510 let prompter = TrackingPrompter::uniform(vec![], "beta");
511 let branches = test_branches();
512 let clis = test_clis();
513 let flag_branches = vec!["feature/auth".to_string(), "fix/api".to_string()];
514
515 let result =
516 run_selection(&prompter, &branches, &clis, None, Some(&flag_branches)).unwrap();
517
518 assert_eq!(
519 result.mappings,
520 vec![
521 ("feature/auth".to_string(), "beta".to_string()),
522 ("fix/api".to_string(), "beta".to_string()),
523 ]
524 );
525 }
526
527 #[test]
532 fn uniform_mode_maps_same_cli_to_all_selected_branches() {
533 let prompter = TrackingPrompter::uniform(vec![0, 1], "alpha");
534 let branches = test_branches();
535 let clis = test_clis();
536
537 let result = run_selection(&prompter, &branches, &clis, None, None).unwrap();
538
539 assert_eq!(
540 result.mappings,
541 vec![
542 ("feature/auth".to_string(), "alpha".to_string()),
543 ("fix/api".to_string(), "alpha".to_string()),
544 ]
545 );
546 }
547
548 #[test]
549 fn per_branch_mode_maps_different_cli_to_each_branch() {
550 let prompter = TrackingPrompter::per_branch(vec![0, 1], vec!["alpha", "beta"]);
551 let branches = test_branches();
552 let clis = test_clis();
553
554 let result = run_selection(&prompter, &branches, &clis, None, None).unwrap();
555
556 assert_eq!(
557 result.mappings,
558 vec![
559 ("feature/auth".to_string(), "alpha".to_string()),
560 ("fix/api".to_string(), "beta".to_string()),
561 ]
562 );
563 }
564
565 #[test]
566 fn per_branch_mode_with_branches_flag() {
567 let prompter = TrackingPrompter::per_branch(vec![], vec!["beta", "alpha"]);
568 let branches = test_branches();
569 let clis = test_clis();
570 let flag_branches = vec!["feature/auth".to_string(), "fix/api".to_string()];
571
572 let result =
573 run_selection(&prompter, &branches, &clis, None, Some(&flag_branches)).unwrap();
574
575 assert_eq!(
576 result.mappings,
577 vec![
578 ("feature/auth".to_string(), "beta".to_string()),
579 ("fix/api".to_string(), "alpha".to_string()),
580 ]
581 );
582 }
583
584 #[test]
589 fn no_clis_available_returns_error() {
590 let prompter = TrackingPrompter::cancel_on_branches();
591 let branches = test_branches();
592 let clis: Vec<CliInfo> = vec![];
593
594 let result = run_selection(&prompter, &branches, &clis, None, None);
595
596 assert!(matches!(result, Err(PawError::NoCLIsFound)));
597 }
598
599 #[test]
600 fn no_branches_available_returns_error() {
601 let prompter = TrackingPrompter::cancel_on_branches();
602 let branches: Vec<String> = vec![];
603 let clis = test_clis();
604
605 let result = run_selection(&prompter, &branches, &clis, None, None);
606
607 assert!(matches!(result, Err(PawError::BranchError(_))));
608 }
609
610 #[test]
611 fn user_cancels_branch_selection_returns_cancelled() {
612 let prompter = TrackingPrompter::cancel_on_branches();
613 let branches = test_branches();
614 let clis = test_clis();
615
616 let result = run_selection(&prompter, &branches, &clis, None, None);
617
618 assert!(matches!(result, Err(PawError::UserCancelled)));
619 }
620
621 #[test]
622 fn user_selects_no_branches_returns_cancelled() {
623 let prompter = TrackingPrompter::uniform(vec![], "alpha");
625 let branches = test_branches();
626 let clis = test_clis();
627
628 let result = run_selection(&prompter, &branches, &clis, None, None);
629
630 assert!(matches!(result, Err(PawError::UserCancelled)));
631 }
632
633 #[test]
634 fn user_cancels_cli_selection_returns_cancelled() {
635 let prompter = TrackingPrompter::cancel_on_cli(vec![0]);
636 let branches = test_branches();
637 let clis = test_clis();
638
639 let result = run_selection(&prompter, &branches, &clis, None, None);
640
641 assert!(matches!(result, Err(PawError::UserCancelled)));
642 }
643
644 #[test]
649 fn selecting_subset_of_branches_works() {
650 let prompter = TrackingPrompter::uniform(vec![1], "alpha"); let branches = test_branches();
652 let clis = test_clis();
653
654 let result = run_selection(&prompter, &branches, &clis, None, None).unwrap();
655
656 assert_eq!(
657 result.mappings,
658 vec![("fix/api".to_string(), "alpha".to_string())]
659 );
660 }
661
662 #[test]
667 fn cli_mode_display() {
668 assert_eq!(CliMode::Uniform.to_string(), "Same CLI for all branches");
669 assert_eq!(CliMode::PerBranch.to_string(), "Different CLI per branch");
670 }
671
672 #[test]
673 fn cli_info_display_same_names() {
674 let info = CliInfo {
675 display_name: "claude".to_string(),
676 binary_name: "claude".to_string(),
677 };
678 assert_eq!(info.to_string(), "claude");
679 }
680
681 #[test]
682 fn cli_info_display_different_names() {
683 let info = CliInfo {
684 display_name: "My Agent".to_string(),
685 binary_name: "my-agent".to_string(),
686 };
687 assert_eq!(info.to_string(), "My Agent (my-agent)");
688 }
689
690 fn default_config() -> PawConfig {
695 PawConfig::default()
696 }
697
698 fn spec(branch: &str, cli: Option<&str>) -> SpecEntry {
699 SpecEntry {
700 id: branch.to_string(),
701 branch: branch.to_string(),
702 cli: cli.map(String::from),
703 prompt: String::new(),
704 owned_files: None,
705 }
706 }
707
708 fn test_specs() -> Vec<SpecEntry> {
709 vec![
710 spec("spec/auth", None),
711 spec("spec/api", None),
712 spec("spec/db", None),
713 ]
714 }
715
716 #[test]
717 fn cli_flag_overrides_all_specs() {
718 let prompter = TrackingPrompter::for_specs("should-not-be-used");
719 let clis = test_clis();
720 let specs = test_specs();
721
722 let result =
723 resolve_cli_for_specs(&specs, Some("alpha"), &default_config(), &clis, &prompter)
724 .unwrap();
725
726 assert_eq!(result.len(), 3);
727 assert!(result.iter().all(|(_, cli)| cli == "alpha"));
728 }
729
730 #[test]
731 fn paw_cli_per_spec_overrides_config() {
732 let specs = vec![spec("spec/auth", Some("beta")), spec("spec/api", None)];
733 let mut config = default_config();
734 config.default_spec_cli = Some("alpha".to_string());
735 let prompter = TrackingPrompter::for_specs("should-not-be-used");
736 let clis = test_clis();
737
738 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
739
740 assert!(result.iter().any(|(b, c)| b == "spec/auth" && c == "beta"));
741 assert!(result.iter().any(|(b, c)| b == "spec/api" && c == "alpha"));
742 }
743
744 #[test]
745 fn default_spec_cli_fills_remaining_without_prompt() {
746 let mut config = default_config();
747 config.default_spec_cli = Some("alpha".to_string());
748 let prompter = TrackingPrompter::cancel_on_cli(vec![]); let clis = test_clis();
750 let specs = test_specs();
751
752 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
753
754 assert_eq!(result.len(), 3);
755 assert!(result.iter().all(|(_, cli)| cli == "alpha"));
756 }
757
758 #[test]
759 fn default_cli_pre_selects_in_picker() {
760 let mut config = default_config();
761 config.default_cli = Some("beta".to_string());
762 let prompter = TrackingPrompter::for_specs("beta");
763 let clis = test_clis();
764 let specs = vec![spec("spec/auth", None)];
765
766 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
767
768 assert_eq!(result, vec![("spec/auth".to_string(), "beta".to_string())]);
769 assert_eq!(
771 prompter.last_select_cli_default.take(),
772 Some("beta".to_string())
773 );
774 }
775
776 #[test]
777 fn no_defaults_picker_fires_with_none_default() {
778 let prompter = TrackingPrompter::for_specs("alpha");
779 let clis = test_clis();
780 let specs = vec![spec("spec/auth", None)];
781
782 let result =
783 resolve_cli_for_specs(&specs, None, &default_config(), &clis, &prompter).unwrap();
784
785 assert_eq!(result, vec![("spec/auth".to_string(), "alpha".to_string())]);
786 assert_eq!(prompter.last_select_cli_default.take(), None);
787 }
788
789 #[test]
790 fn mixed_paw_cli_and_default_spec_cli() {
791 let specs = vec![
792 spec("spec/auth", Some("beta")),
793 spec("spec/api", None),
794 spec("spec/db", None),
795 ];
796 let mut config = default_config();
797 config.default_spec_cli = Some("alpha".to_string());
798 let prompter = TrackingPrompter::for_specs("should-not-be-used");
799 let clis = test_clis();
800
801 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
802
803 assert_eq!(result.len(), 3);
804 assert!(result.iter().any(|(b, c)| b == "spec/auth" && c == "beta"));
805 assert!(result.iter().any(|(b, c)| b == "spec/api" && c == "alpha"));
806 assert!(result.iter().any(|(b, c)| b == "spec/db" && c == "alpha"));
807 }
808
809 #[test]
810 fn mixed_paw_cli_and_interactive() {
811 let specs = vec![
812 spec("spec/auth", Some("beta")),
813 spec("spec/api", None),
814 spec("spec/db", None),
815 ];
816 let prompter = TrackingPrompter::for_specs("alpha");
817 let clis = test_clis();
818
819 let result =
820 resolve_cli_for_specs(&specs, None, &default_config(), &clis, &prompter).unwrap();
821
822 assert_eq!(result.len(), 3);
823 assert!(result.iter().any(|(b, c)| b == "spec/auth" && c == "beta"));
824 assert!(result.iter().any(|(b, c)| b == "spec/api" && c == "alpha"));
825 assert!(result.iter().any(|(b, c)| b == "spec/db" && c == "alpha"));
826 }
827
828 #[test]
829 fn picker_fires_at_most_once_for_multiple_remaining() {
830 let specs = vec![
831 spec("spec/a", Some("beta")),
832 spec("spec/b", None),
833 spec("spec/c", None),
834 spec("spec/d", None),
835 ];
836 let prompter = TrackingPrompter::for_specs("alpha");
839 let clis = test_clis();
840
841 let result =
842 resolve_cli_for_specs(&specs, None, &default_config(), &clis, &prompter).unwrap();
843
844 let remaining: Vec<_> = result.iter().filter(|(_, c)| c == "alpha").collect();
845 assert_eq!(remaining.len(), 3);
846 }
847
848 #[test]
849 fn all_resolved_via_flag_no_prompt() {
850 let prompter = TrackingPrompter::cancel_on_cli(vec![]); let clis = test_clis();
852 let specs = test_specs();
853
854 let result =
855 resolve_cli_for_specs(&specs, Some("alpha"), &default_config(), &clis, &prompter)
856 .unwrap();
857 assert_eq!(result.len(), 3);
858 }
859
860 #[test]
861 fn all_resolved_via_paw_cli_and_default_spec_cli_no_prompt() {
862 let specs = vec![spec("spec/auth", Some("alpha")), spec("spec/api", None)];
863 let mut config = default_config();
864 config.default_spec_cli = Some("beta".to_string());
865 let prompter = TrackingPrompter::cancel_on_cli(vec![]); let clis = test_clis();
867
868 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
869 assert_eq!(result.len(), 2);
870 }
871
872 #[test]
873 fn paw_cli_references_unknown_cli_returns_error() {
874 let specs = vec![spec("spec/auth", Some("nonexistent"))];
875 let prompter = TrackingPrompter::for_specs("alpha");
876 let clis = test_clis();
877
878 let result = resolve_cli_for_specs(&specs, None, &default_config(), &clis, &prompter);
879 assert!(matches!(result, Err(PawError::CliNotFound(ref name)) if name == "nonexistent"));
880 }
881
882 #[test]
883 fn default_spec_cli_references_unknown_cli_returns_error() {
884 let mut config = default_config();
885 config.default_spec_cli = Some("nonexistent".to_string());
886 let prompter = TrackingPrompter::for_specs("alpha");
887 let clis = test_clis();
888 let specs = test_specs();
889
890 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter);
891 assert!(matches!(result, Err(PawError::CliNotFound(ref name)) if name == "nonexistent"));
892 }
893
894 #[test]
895 fn cli_flag_references_unknown_cli_returns_error() {
896 let prompter = TrackingPrompter::for_specs("alpha");
897 let clis = test_clis();
898 let specs = test_specs();
899
900 let result = resolve_cli_for_specs(
901 &specs,
902 Some("nonexistent"),
903 &default_config(),
904 &clis,
905 &prompter,
906 );
907 assert!(matches!(result, Err(PawError::CliNotFound(ref name)) if name == "nonexistent"));
908 }
909
910 #[test]
911 fn select_cli_with_default_present_and_in_list() {
912 let prompter = TrackingPrompter::for_specs("beta");
913 let clis = test_clis();
914 let specs = vec![spec("spec/x", None)];
915 let mut config = default_config();
916 config.default_cli = Some("beta".to_string());
917
918 resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
919
920 assert_eq!(
921 prompter.last_select_cli_default.take(),
922 Some("beta".to_string())
923 );
924 }
925
926 #[test]
927 fn select_cli_with_default_not_in_list_graceful() {
928 let prompter = TrackingPrompter::for_specs("alpha");
929 let clis = test_clis();
930 let specs = vec![spec("spec/x", None)];
931 let mut config = default_config();
932 config.default_cli = Some("nonexistent".to_string());
933
934 let result = resolve_cli_for_specs(&specs, None, &config, &clis, &prompter).unwrap();
936 assert_eq!(result, vec![("spec/x".to_string(), "alpha".to_string())]);
937 assert_eq!(
938 prompter.last_select_cli_default.take(),
939 Some("nonexistent".to_string())
940 );
941 }
942}