Skip to main content

git_paw/
interactive.rs

1//! Interactive selection prompts.
2//!
3//! User-facing selection flows using `dialoguer`: mode picker, branch picker,
4//! and CLI picker (uniform and per-branch). Logic is separated from UI via
5//! the [`Prompter`] trait for testability.
6
7use std::fmt;
8
9use dialoguer::{MultiSelect, Select};
10
11use crate::config::PawConfig;
12use crate::error::PawError;
13use crate::specs::SpecEntry;
14
15// ---------------------------------------------------------------------------
16// Types
17// ---------------------------------------------------------------------------
18
19/// Information about an available AI CLI.
20///
21/// Contains the data needed to display a CLI option in interactive prompts.
22pub struct CliInfo {
23    /// Human-readable name shown in prompts (e.g., "My Agent").
24    pub display_name: String,
25    /// Binary name used for invocation (e.g., "my-agent").
26    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/// How the user wants to assign CLIs to branches.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CliMode {
42    /// Same CLI for all selected branches.
43    Uniform,
44    /// Different CLI for each branch.
45    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/// Result of the full interactive selection flow.
58#[derive(Debug)]
59pub struct SelectionResult {
60    /// Branch-to-CLI mappings as `(branch_name, cli_binary_name)` pairs.
61    pub mappings: Vec<(String, String)>,
62}
63
64// ---------------------------------------------------------------------------
65// Prompter trait (separates logic from UI)
66// ---------------------------------------------------------------------------
67
68/// Abstraction over interactive prompts, allowing test doubles.
69pub trait Prompter {
70    /// Ask the user to choose between uniform and per-branch CLI assignment.
71    fn select_mode(&self) -> Result<CliMode, PawError>;
72
73    /// Ask the user to pick one or more branches. Returns selected branch names.
74    fn select_branches(&self, branches: &[String]) -> Result<Vec<String>, PawError>;
75
76    /// Ask the user to pick a single CLI for all branches. Returns binary name.
77    ///
78    /// When `default` is `Some` and matches a CLI's `binary_name`, that entry
79    /// is pre-selected in the picker. Otherwise the first item is selected.
80    fn select_cli(&self, clis: &[CliInfo], default: Option<&str>) -> Result<String, PawError>;
81
82    /// Ask the user to pick a CLI for a specific branch. Returns binary name.
83    fn select_cli_for_branch(&self, branch: &str, clis: &[CliInfo]) -> Result<String, PawError>;
84}
85
86// ---------------------------------------------------------------------------
87// Real prompter (dialoguer)
88// ---------------------------------------------------------------------------
89
90/// Interactive prompter using `dialoguer` for terminal UI.
91pub 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
162/// Maps dialoguer errors to `PawError`, treating I/O interrupted (Ctrl+C) as
163/// user cancellation.
164fn 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
175// ---------------------------------------------------------------------------
176// Core selection logic (independent of UI)
177// ---------------------------------------------------------------------------
178
179/// Runs the full interactive selection flow, skipping prompts when CLI flags
180/// provide the necessary data.
181///
182/// # Errors
183///
184/// Returns `PawError::NoCLIsFound` if `clis` is empty.
185/// Returns `PawError::BranchError` if `branches` is empty.
186/// Returns `PawError::UserCancelled` if the user cancels any prompt.
187pub 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    // Determine which branches to use.
202    let selected_branches = if let Some(flagged) = branches_flag {
203        flagged.to_vec()
204    } else {
205        prompter.select_branches(branches)?
206    };
207
208    // Determine CLI mapping.
209    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
238// ---------------------------------------------------------------------------
239// Spec-driven CLI resolution
240// ---------------------------------------------------------------------------
241
242/// Resolves which CLI to use for each spec-driven branch using a 5-level
243/// priority chain:
244///
245/// 1. `cli_flag` (from `--cli`) → all branches, no prompt
246/// 2. `spec.cli` (`paw_cli` in spec) → that branch only
247/// 3. `config.default_spec_cli` → remaining branches, no prompt
248/// 4. `config.default_cli` → pre-selects in picker for remaining
249/// 5. Nothing → full picker for remaining
250///
251/// Prompts at most once. Validates all resolved CLI names against
252/// `available_clis`.
253pub 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    // Priority 1: --cli flag overrides everything
263    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    // Priority 2: per-spec paw_cli
277    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    // Priority 3: default_spec_cli (no prompt)
293    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    // Priority 4+5: prompt once (pre-selected if default_cli set)
304    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    // -----------------------------------------------------------------------
317    // Fake prompter for testing
318    // -----------------------------------------------------------------------
319
320    use std::cell::Cell;
321
322    /// A configurable fake prompter that returns predetermined responses.
323    /// Uses `Cell` for interior mutability to track per-branch call order
324    /// and to capture the `default` parameter passed to `select_cli()`.
325    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        /// Captures the `default` parameter passed to the last `select_cli()` call.
334        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        /// Creates a prompter that returns a fixed CLI, used for spec resolution tests.
391        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    // -----------------------------------------------------------------------
444    // Test helpers
445    // -----------------------------------------------------------------------
446
447    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    // -----------------------------------------------------------------------
465    // Behavior tests: flag-based prompt skipping
466    // -----------------------------------------------------------------------
467
468    #[test]
469    fn both_flags_skips_all_prompts_and_maps_cli_to_all_branches() {
470        let prompter = TrackingPrompter::cancel_on_branches(); // should never be called
471        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        // Should use the flag CLI, and the branch from the prompter (index 0)
502        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    // -----------------------------------------------------------------------
528    // Behavior tests: interactive mode selection
529    // -----------------------------------------------------------------------
530
531    #[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    // -----------------------------------------------------------------------
585    // Behavior tests: cancellation / error cases
586    // -----------------------------------------------------------------------
587
588    #[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        // Empty branch_indices with cancel_on_branch_select=false still returns cancelled
624        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    // -----------------------------------------------------------------------
645    // Behavior tests: selection with subset of branches
646    // -----------------------------------------------------------------------
647
648    #[test]
649    fn selecting_subset_of_branches_works() {
650        let prompter = TrackingPrompter::uniform(vec![1], "alpha"); // only fix/api
651        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    // -----------------------------------------------------------------------
663    // Display impls
664    // -----------------------------------------------------------------------
665
666    #[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    // -----------------------------------------------------------------------
691    // resolve_cli_for_specs tests
692    // -----------------------------------------------------------------------
693
694    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![]); // would fail if called
749        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        // Verify default was passed to select_cli
770        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        // If select_cli is called more than once this will still return "alpha",
837        // but we verify the behavior: all remaining get the same CLI.
838        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![]); // would fail if called
851        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![]); // would fail if called
866        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        // Should not error — the default just doesn't pre-select
935        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}