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    /// Ask the user to pick one or more specs. Returns the selected
86    /// `SpecEntry` values expanded from grouped logical units.
87    ///
88    /// Each row in the picker represents one logical unit (a Spec Kit
89    /// feature, an `OpenSpec` change, or a Markdown spec). Selecting a row
90    /// returns every underlying `SpecEntry` belonging to that unit.
91    fn select_specs(&self, specs: &[SpecEntry]) -> Result<Vec<SpecEntry>, PawError>;
92}
93
94// ---------------------------------------------------------------------------
95// Real prompter (dialoguer)
96// ---------------------------------------------------------------------------
97
98/// Interactive prompter using `dialoguer` for terminal UI.
99pub 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
183/// Pure post-processing for `select_specs`: maps the dialoguer
184/// `Option<Vec<usize>>` selection (over grouped rows) back to the underlying
185/// `SpecEntry` values, and treats both `None` (Ctrl+C) and `Some(empty)`
186/// (zero rows selected) as `PawError::UserCancelled` — matching
187/// `select_branches`.
188fn 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
208/// Groups `SpecEntry` values by logical unit (Spec Kit feature, `OpenSpec`
209/// change, or Markdown spec) and produces a display label per unit.
210///
211/// Returns a vector of `(label, indices_into_specs)` pairs. Each label is
212/// either the bare unit id (for one-entry units) or a Spec Kit summary like
213/// `"003-user-list — 3 worktrees: 2 [P] + 1 phase/"`.
214///
215/// Order follows the discovery order of the first entry in each group, so
216/// the picker preserves the backend's natural listing.
217fn 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
240/// Extracts the logical unit id (feature for Spec Kit, change/file stem for
241/// `OpenSpec` and Markdown).
242fn 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
301/// Maps dialoguer errors to `PawError`, treating I/O interrupted (Ctrl+C) as
302/// user cancellation.
303fn 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
314// ---------------------------------------------------------------------------
315// Core selection logic (independent of UI)
316// ---------------------------------------------------------------------------
317
318/// Runs the full interactive selection flow, skipping prompts when CLI flags
319/// provide the necessary data.
320///
321/// # Errors
322///
323/// Returns `PawError::NoCLIsFound` if `clis` is empty.
324/// Returns `PawError::BranchError` if `branches` is empty.
325/// Returns `PawError::UserCancelled` if the user cancels any prompt.
326pub 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    // Determine which branches to use.
341    let selected_branches = if let Some(flagged) = branches_flag {
342        flagged.to_vec()
343    } else {
344        prompter.select_branches(branches)?
345    };
346
347    // Determine CLI mapping.
348    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
377// ---------------------------------------------------------------------------
378// Spec-driven CLI resolution
379// ---------------------------------------------------------------------------
380
381/// Resolves which CLI to use for each spec-driven branch using a 5-level
382/// priority chain:
383///
384/// 1. `cli_flag` (from `--cli`) → all branches, no prompt
385/// 2. `spec.cli` (`paw_cli` in spec) → that branch only
386/// 3. `config.default_spec_cli` → remaining branches, no prompt
387/// 4. `config.default_cli` → pre-selects in picker for remaining
388/// 5. Nothing → full picker for remaining
389///
390/// Prompts at most once. Validates all resolved CLI names against
391/// `available_clis`.
392pub 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    // Priority 1: --cli flag overrides everything
402    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    // Priority 2: per-spec paw_cli
416    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    // Priority 3: default_spec_cli (no prompt)
432    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    // Priority 4+5: prompt once (pre-selected if default_cli set)
443    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    // -----------------------------------------------------------------------
456    // Fake prompter for testing
457    // -----------------------------------------------------------------------
458
459    use std::cell::Cell;
460
461    /// A configurable fake prompter that returns predetermined responses.
462    /// Uses `Cell` for interior mutability to track per-branch call order
463    /// and to capture the `default` parameter passed to `select_cli()`.
464    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        /// Captures the `default` parameter passed to the last `select_cli()` call.
473        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        /// Creates a prompter that returns a fixed CLI, used for spec resolution tests.
530        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    // -----------------------------------------------------------------------
587    // Test helpers
588    // -----------------------------------------------------------------------
589
590    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    // -----------------------------------------------------------------------
608    // Behavior tests: flag-based prompt skipping
609    // -----------------------------------------------------------------------
610
611    #[test]
612    fn both_flags_skips_all_prompts_and_maps_cli_to_all_branches() {
613        let prompter = TrackingPrompter::cancel_on_branches(); // should never be called
614        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        // Should use the flag CLI, and the branch from the prompter (index 0)
645        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    // -----------------------------------------------------------------------
671    // Behavior tests: interactive mode selection
672    // -----------------------------------------------------------------------
673
674    #[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    // -----------------------------------------------------------------------
728    // Behavior tests: cancellation / error cases
729    // -----------------------------------------------------------------------
730
731    #[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        // Empty branch_indices with cancel_on_branch_select=false still returns cancelled
767        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    // -----------------------------------------------------------------------
788    // Behavior tests: selection with subset of branches
789    // -----------------------------------------------------------------------
790
791    #[test]
792    fn selecting_subset_of_branches_works() {
793        let prompter = TrackingPrompter::uniform(vec![1], "alpha"); // only fix/api
794        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    // -----------------------------------------------------------------------
806    // Display impls
807    // -----------------------------------------------------------------------
808
809    #[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    // -----------------------------------------------------------------------
834    // resolve_cli_for_specs tests
835    // -----------------------------------------------------------------------
836
837    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![]); // would fail if called
893        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        // Verify default was passed to select_cli
914        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        // If select_cli is called more than once this will still return "alpha",
981        // but we verify the behavior: all remaining get the same CLI.
982        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![]); // would fail if called
995        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![]); // would fail if called
1010        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        // Should not error — the default just doesn't pre-select
1079        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    // -----------------------------------------------------------------------
1088    // Spec multi-select picker grouping (cross-format-spec-selection)
1089    // -----------------------------------------------------------------------
1090
1091    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        // User toggles "add-auth" (row 0) and "add-logging" (row 2).
1126        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        // Single row "003-user-list" → all 3 underlying entries.
1140        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        // dialoguer returns None when the user presses Ctrl+C.
1155        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        // User confirms (Enter) without toggling any row → empty Vec.
1164        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    // --- test-coverage-v0-5-0: spec picker cancellation paths -----------------
1197    //
1198    // The two scenarios `User cancels spec picker via Ctrl+C` and `User confirms
1199    // with zero rows selected` both expect the caller to propagate
1200    // `PawError::UserCancelled`. The TerminalPrompter implementation routes
1201    // both through `finalize_spec_selection`. For the unit tests we exercise
1202    // the mapping function directly (which is the production code path) and
1203    // assert the resulting Err shape.
1204
1205    /// A `Prompter` whose `select_specs` always returns
1206    /// `Err(PawError::UserCancelled)` — the Ctrl+C path.
1207    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    // Maps to scenario `User cancels spec picker via Ctrl+C` from
1236    // cross-format-spec-selection. (test-coverage-v0-5-0 task 7.1)
1237    #[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    // Maps to scenario `User confirms with zero rows selected` from
1249    // cross-format-spec-selection. The TerminalPrompter wires
1250    // `MultiSelect::interact_opt() -> Some(empty Vec)` through
1251    // `finalize_spec_selection`, which maps it to UserCancelled. This test
1252    // exercises that mapping function directly with `Some(empty)` because
1253    // that is where the production decision lives.
1254    // (test-coverage-v0-5-0 task 7.2)
1255    #[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        // `Some(vec![])` represents the user confirming with zero rows
1260        // selected — equivalent to dialoguer returning `Ok(vec![])`.
1261        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}