Skip to main content

haz_cli/commands/init/
model.rs

1//! Interactive-tree state machine for `haz init` per
2//! `AUX-003`..`AUX-005`.
3//!
4//! This module owns the pure data structures and state transitions
5//! the interactive scaffold flow needs; the TUI runtime lives in a
6//! sibling module and drives this model in response to keystrokes.
7//! Keeping the model pure (no terminal IO, no filesystem reads)
8//! makes the `AUX-004` rules exhaustively unit-testable.
9//!
10//! The model represents the cwd-rooted directory subtree as a
11//! [`Node`] tree with per-node [`SelectionState`] and a single
12//! focused position. Navigation, expansion, and state cycling are
13//! all expressed as tree mutations; the driver re-renders the
14//! [`Tree::visible_nodes`] list after each mutation.
15
16use std::collections::BTreeSet;
17use std::ffi::OsString;
18use std::path::PathBuf;
19
20/// Why a directory was treated as hidden by the TUI driver.
21///
22/// The model carries the classification per-node so the renderer
23/// can style hidden rows distinctly when the user has toggled
24/// "show hidden" on. Dotfile-prefixed entries dim normally;
25/// gitignored entries dim with a warning-coloured foreground.
26/// When both apply, `Gitignored` wins so the warning channel is
27/// preserved.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum HiddenKind {
30    /// Default: not hidden, rendered normally.
31    #[default]
32    Visible,
33    /// Basename starts with `.` (e.g. `.git`, `.cache`).
34    Dotfile,
35    /// Matched by the workspace's `.gitignore` (or the global
36    /// gitignore) at the moment the directory was loaded.
37    Gitignored,
38}
39
40/// One of the three selection states `AUX-003` defines for a
41/// directory node. Cycling goes
42/// `None` -> `Project` -> `ProjectsParent` -> `None`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum SelectionState {
45    /// Not referenced by `projects:` (default).
46    #[default]
47    None,
48    /// Referenced as a literal `/<dir>` entry in `projects:`.
49    Project,
50    /// Referenced as a glob `/<dir>/*` entry in `projects:` per
51    /// the `AUX-003` `projects-parent` arm.
52    ProjectsParent,
53}
54
55impl SelectionState {
56    /// The next state in the `AUX-003` cycle. Cycling out of
57    /// `ProjectsParent` returns to `None`.
58    #[must_use]
59    pub fn next(self) -> Self {
60        match self {
61            Self::None => Self::Project,
62            Self::Project => Self::ProjectsParent,
63            Self::ProjectsParent => Self::None,
64        }
65    }
66}
67
68/// A single visible filesystem directory inside the interactive
69/// tree.
70///
71/// The model does not represent regular files: per `AUX-003`,
72/// only directories carry a selection state. File-system reads
73/// happen in the TUI driver; the model is fed `Node` trees and
74/// has no IO of its own.
75#[derive(Debug, Clone)]
76pub struct Node {
77    /// Display label (the directory's basename).
78    pub name: OsString,
79    /// Workspace-absolute path of this directory (host filesystem
80    /// path is computed by the driver before display).
81    pub path: PathBuf,
82    /// Current selection state per `AUX-003`.
83    pub state: SelectionState,
84    /// Whether the node is currently expanded (its children are
85    /// part of the visible list).
86    pub expanded: bool,
87    /// Whether [`Self::children`] has been populated from disk.
88    /// `false` for a directory whose contents have never been
89    /// loaded; cycling into [`SelectionState::ProjectsParent`]
90    /// requires this to be `true` (the empty-glob check needs to
91    /// know whether subdirectories exist).
92    pub children_loaded: bool,
93    /// Sub-directory nodes in the order the driver loaded them.
94    pub children: Vec<Node>,
95    /// How this directory was classified at read time. Defaults
96    /// to [`HiddenKind::Visible`]; the TUI driver overwrites it
97    /// for entries it loaded while "show hidden" was on.
98    pub hidden_kind: HiddenKind,
99}
100
101impl Node {
102    /// Construct a directory node with no children loaded yet.
103    #[must_use]
104    pub fn new(name: OsString, path: PathBuf) -> Self {
105        Self {
106            name,
107            path,
108            state: SelectionState::default(),
109            expanded: false,
110            children_loaded: false,
111            children: Vec::new(),
112            hidden_kind: HiddenKind::default(),
113        }
114    }
115
116    /// Whether this node has at least one direct subdirectory.
117    ///
118    /// Used by [`Tree::cycle_focused`] to evaluate `AUX-004`
119    /// rule 1 (empty-glob refusal). The caller MUST have loaded
120    /// children first (`children_loaded == true`); calling on an
121    /// unloaded node returns `false` conservatively, which lets
122    /// the driver decide whether to trigger a load.
123    #[must_use]
124    pub fn has_subdirectories(&self) -> bool {
125        self.children_loaded && !self.children.is_empty()
126    }
127}
128
129/// Outcome of an attempted [`Tree::cycle_focused`] call.
130///
131/// `AUX-004` defines two refusal cases; cycling outside those
132/// constraints succeeds and returns the new state.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum CycleOutcome {
135    /// The cycle succeeded; the focused node's new state is the
136    /// carried value.
137    Advanced(SelectionState),
138    /// `AUX-004` rule 1: the focused node has no subdirectories,
139    /// so cycling *into* [`SelectionState::ProjectsParent`] is
140    /// refused. The previous state is preserved.
141    RefusedEmptyGlob,
142    /// `AUX-004` rule 2: the focused node lies under an ancestor
143    /// in [`SelectionState::ProjectsParent`], so cycling is
144    /// refused while the lock holds. The focused node's state
145    /// is left unchanged; under the rule 2 invariant the locked
146    /// descendant is always [`SelectionState::None`].
147    RefusedSubtreeLock,
148    /// The focused node's children have not been loaded yet, so
149    /// the rule 1 check cannot be evaluated. The driver MUST
150    /// load children and re-issue the cycle. The previous state
151    /// is preserved.
152    NeedsChildrenLoaded,
153}
154
155/// Outcome of an attempted [`Tree::try_expand_focused`] call.
156///
157/// Expanding a directory whose `children` list (after loading)
158/// is empty is refused, mirroring the AUX-004 rule 1 refusal on
159/// the selection cycle: the user expects "expand" to do
160/// something visible, and silently changing `expanded` on an
161/// empty directory does not.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum ExpandOutcome {
164    /// The focused node has subdirectories and is now expanded.
165    Expanded,
166    /// The focused node was already expanded. No-op.
167    AlreadyExpanded,
168    /// The focused node has zero subdirectories. The expansion
169    /// is refused; `expanded` stays at its previous value
170    /// (false), and the driver fires the perceptible refusal
171    /// signal.
172    RefusedNoSubdirs,
173    /// The focused node's children have not been loaded yet.
174    /// The driver MUST load them and re-issue the expand
175    /// request.
176    NeedsChildrenLoaded,
177}
178
179/// The interactive-tree state.
180///
181/// Carries the root [`Node`] plus the focus path (a sequence of
182/// indices from the root to the currently-focused node).
183#[derive(Debug, Clone)]
184pub struct Tree {
185    root: Node,
186    focus_path: Vec<usize>,
187}
188
189impl Tree {
190    /// Build a tree from a pre-populated root.
191    #[must_use]
192    pub fn new(root: Node) -> Self {
193        Self {
194            root,
195            focus_path: Vec::new(),
196        }
197    }
198
199    /// Borrow the root node.
200    #[must_use]
201    pub fn root(&self) -> &Node {
202        &self.root
203    }
204
205    /// Borrow the focused node.
206    #[must_use]
207    pub fn focused(&self) -> &Node {
208        let mut node = &self.root;
209        for &idx in &self.focus_path {
210            node = &node.children[idx];
211        }
212        node
213    }
214
215    /// Cycle the focused node's [`SelectionState`] per `AUX-003`,
216    /// subject to the `AUX-004` refusal rules.
217    ///
218    /// On a directory with zero sub-directories, `ProjectsParent`
219    /// is unreachable per `AUX-004` rule 1. The cycle continues
220    /// to the state that would come after it (`None`) so the
221    /// user can always escape `Project`; the return value
222    /// [`CycleOutcome::RefusedEmptyGlob`] tells the driver to
223    /// fire the perceptible refusal channel the rule mandates.
224    ///
225    /// Cycling INTO `ProjectsParent` clears every descendant's
226    /// selection state to `None` per `AUX-004` rule 2. Without
227    /// the clear, the commit pass could emit a `projects:` list
228    /// that mixes the glob with a literal entry the glob already
229    /// shadows. The clearing is one-way: cycling back out of
230    /// `ProjectsParent` does NOT restore prior descendant states.
231    pub fn cycle_focused(&mut self) -> CycleOutcome {
232        if self.focused_under_projects_parent_ancestor() {
233            return CycleOutcome::RefusedSubtreeLock;
234        }
235        let current = self.focused().state;
236        let next = current.next();
237        if next == SelectionState::ProjectsParent {
238            if !self.focused().children_loaded {
239                return CycleOutcome::NeedsChildrenLoaded;
240            }
241            if !self.focused().has_subdirectories() {
242                let after = next.next();
243                self.focused_mut().state = after;
244                return CycleOutcome::RefusedEmptyGlob;
245            }
246        }
247        let focused = self.focused_mut();
248        focused.state = next;
249        if next == SelectionState::ProjectsParent {
250            clear_descendant_states(focused);
251        }
252        CycleOutcome::Advanced(next)
253    }
254
255    /// Move focus to the next visible node (down arrow).
256    ///
257    /// "Next" walks the visible-nodes list in depth-first order;
258    /// at the tail it wraps to the head, which makes long lists
259    /// navigable without explicit "back to top" UI.
260    pub fn focus_next(&mut self) {
261        let total = self.visible_count();
262        if total == 0 {
263            return;
264        }
265        let current = self.focused_index();
266        let next = (current + 1) % total;
267        self.set_focused_index(next);
268    }
269
270    /// Move focus to the previous visible node (up arrow). Wraps
271    /// at the head per the symmetric rule of [`Self::focus_next`].
272    pub fn focus_prev(&mut self) {
273        let total = self.visible_count();
274        if total == 0 {
275            return;
276        }
277        let current = self.focused_index();
278        let prev = if current == 0 { total - 1 } else { current - 1 };
279        self.set_focused_index(prev);
280    }
281
282    /// Try to expand the focused node, reporting why the
283    /// expansion did or did not take effect. The TUI driver uses
284    /// the outcome to decide whether to ring the bell, set a
285    /// refusal status message, or trigger a disk read.
286    pub fn try_expand_focused(&mut self) -> ExpandOutcome {
287        let node = self.focused();
288        if node.expanded {
289            return ExpandOutcome::AlreadyExpanded;
290        }
291        if !node.children_loaded {
292            return ExpandOutcome::NeedsChildrenLoaded;
293        }
294        if node.children.is_empty() {
295            return ExpandOutcome::RefusedNoSubdirs;
296        }
297        self.focused_mut().expanded = true;
298        ExpandOutcome::Expanded
299    }
300
301    /// Collapse the focused node. No-op when not currently
302    /// expanded. The node's state and the states of its
303    /// descendants are preserved across collapse / expand cycles.
304    pub fn collapse_focused(&mut self) {
305        self.focused_mut().expanded = false;
306    }
307
308    /// Whether the focused node's children need to be loaded
309    /// before the next [`Self::cycle_focused`] call can evaluate
310    /// the `AUX-004` rule 1 empty-glob refusal.
311    #[must_use]
312    pub fn focused_needs_children_loaded(&self) -> bool {
313        !self.focused().children_loaded
314    }
315
316    /// Replace the focused node's children with the supplied list
317    /// and mark its `children_loaded` flag. Called by the TUI
318    /// driver after a `read_dir` populates the directory contents
319    /// the model needs.
320    pub fn populate_focused_children(&mut self, children: Vec<Node>) {
321        let node = self.focused_mut();
322        node.children = children;
323        node.children_loaded = true;
324    }
325
326    /// Iterate every currently-visible node in depth-first order
327    /// (root, then expanded children recursively). Each entry
328    /// carries the node's depth (root = 0), a focus marker, and a
329    /// lock flag indicating whether the node is currently
330    /// subject to the `AUX-004` rule 2 sub-tree lockout.
331    #[must_use]
332    pub fn visible_nodes(&self) -> Vec<VisibleNode<'_>> {
333        let mut out: Vec<VisibleNode<'_>> = Vec::new();
334        walk_visible(
335            &self.root,
336            &mut Vec::new(),
337            &self.focus_path,
338            false,
339            0,
340            &mut out,
341        );
342        out
343    }
344
345    /// Compute the `AUX-005` step 1 project-root set: every
346    /// directory currently in [`SelectionState::Project`], plus
347    /// every direct subdirectory of every directory currently in
348    /// [`SelectionState::ProjectsParent`].
349    ///
350    /// The result is ordered (`BTreeSet`) so the
351    /// `AUX-005` step 3 summary and the `workspace.yml`
352    /// emission are byte-stable across runs.
353    #[must_use]
354    pub fn project_root_set(&self) -> BTreeSet<PathBuf> {
355        let mut out: BTreeSet<PathBuf> = BTreeSet::new();
356        collect_roots(&self.root, &mut out);
357        out
358    }
359
360    /// Index of the focused node in the visible list (the result
361    /// of [`Self::visible_nodes`]). The TUI driver uses this to
362    /// reason about navigation positions across the synthetic
363    /// commit-button row that lives outside the model.
364    #[must_use]
365    pub fn focused_visible_index(&self) -> usize {
366        self.focused_index()
367    }
368
369    /// Move focus to the node at position `target` in the visible
370    /// list. Panics when `target` is out of range; the TUI driver
371    /// always clamps to `[0, visible_nodes().len())` before
372    /// calling.
373    pub fn focus_at_visible_index(&mut self, target: usize) {
374        self.set_focused_index(target);
375    }
376
377    /// Mutable borrow of the root node. The TUI driver uses this
378    /// to walk the tree and re-read directory children when the
379    /// visibility filter (`H` toggle) changes.
380    pub fn root_mut(&mut self) -> &mut Node {
381        &mut self.root
382    }
383
384    /// Replace the focus path with `path`, defaulting to root
385    /// (`[]`) when `path` does not identify a visible node
386    /// (out-of-range child index, or any ancestor along the path
387    /// is collapsed). Used by the TUI driver after a reload that
388    /// may have shifted or removed the previously-focused node.
389    pub fn set_focus_path(&mut self, path: Vec<usize>) {
390        if Self::is_valid_visible_path(&self.root, &path) {
391            self.focus_path = path;
392        } else {
393            self.focus_path = Vec::new();
394        }
395    }
396
397    fn is_valid_visible_path(root: &Node, path: &[usize]) -> bool {
398        let mut node = root;
399        for &idx in path {
400            if !node.expanded || idx >= node.children.len() {
401                return false;
402            }
403            node = &node.children[idx];
404        }
405        true
406    }
407
408    // --- internals ---
409
410    fn focused_mut(&mut self) -> &mut Node {
411        let mut node = &mut self.root;
412        for &idx in &self.focus_path {
413            node = &mut node.children[idx];
414        }
415        node
416    }
417
418    fn focused_under_projects_parent_ancestor(&self) -> bool {
419        let mut node = &self.root;
420        for &idx in &self.focus_path {
421            // The check inspects strict ancestors; the focused
422            // node itself does NOT count.
423            if node.state == SelectionState::ProjectsParent {
424                return true;
425            }
426            node = &node.children[idx];
427        }
428        false
429    }
430
431    fn visible_count(&self) -> usize {
432        self.visible_nodes().len()
433    }
434
435    fn focused_index(&self) -> usize {
436        // The visible-nodes list always starts at the root, so
437        // the focused index equals the position the same focus
438        // path produces during the visible walk. We compute it
439        // by re-walking the tree and counting nodes until we
440        // encounter the focused one.
441        let mut count = 0usize;
442        find_index(&self.root, &mut Vec::new(), &self.focus_path, &mut count)
443            .expect("focus path must always identify a visible node")
444    }
445
446    fn set_focused_index(&mut self, target: usize) {
447        let mut count = 0usize;
448        let mut path: Vec<usize> = Vec::new();
449        find_path_at_index(&self.root, &mut path, target, &mut count)
450            .expect("target index in [0, visible_count) is always reachable");
451        self.focus_path = path;
452    }
453}
454
455/// A node currently visible in the tree view, paired with its
456/// depth and `AUX-004`-rule-2 lockout flag.
457#[derive(Debug, Clone, Copy)]
458pub struct VisibleNode<'a> {
459    /// The node itself.
460    pub node: &'a Node,
461    /// Depth from the root (0 for the root).
462    pub depth: usize,
463    /// Whether this node is the currently-focused one.
464    pub focused: bool,
465    /// Whether this node is subject to the `AUX-004` rule 2
466    /// sub-tree lockout because some strict ancestor is in
467    /// [`SelectionState::ProjectsParent`].
468    pub locked: bool,
469}
470
471// --- free helpers --------------------------------------------
472
473fn walk_visible<'a>(
474    node: &'a Node,
475    here: &mut Vec<usize>,
476    focus: &[usize],
477    inherited_lock: bool,
478    depth: usize,
479    out: &mut Vec<VisibleNode<'a>>,
480) {
481    let focused = here.as_slice() == focus;
482    out.push(VisibleNode {
483        node,
484        depth,
485        focused,
486        locked: inherited_lock,
487    });
488    if !node.expanded {
489        return;
490    }
491    let child_lock = inherited_lock || node.state == SelectionState::ProjectsParent;
492    for (i, child) in node.children.iter().enumerate() {
493        here.push(i);
494        walk_visible(child, here, focus, child_lock, depth + 1, out);
495        here.pop();
496    }
497}
498
499fn clear_descendant_states(node: &mut Node) {
500    for child in &mut node.children {
501        child.state = SelectionState::None;
502        clear_descendant_states(child);
503    }
504}
505
506fn collect_roots(node: &Node, out: &mut BTreeSet<PathBuf>) {
507    match node.state {
508        SelectionState::None => {}
509        SelectionState::Project => {
510            out.insert(node.path.clone());
511        }
512        SelectionState::ProjectsParent => {
513            for child in &node.children {
514                out.insert(child.path.clone());
515            }
516        }
517    }
518    for child in &node.children {
519        collect_roots(child, out);
520    }
521}
522
523fn find_index(
524    node: &Node,
525    here: &mut Vec<usize>,
526    focus: &[usize],
527    count: &mut usize,
528) -> Option<usize> {
529    if here.as_slice() == focus {
530        return Some(*count);
531    }
532    *count += 1;
533    if !node.expanded {
534        return None;
535    }
536    for (i, child) in node.children.iter().enumerate() {
537        here.push(i);
538        if let Some(found) = find_index(child, here, focus, count) {
539            return Some(found);
540        }
541        here.pop();
542    }
543    None
544}
545
546fn find_path_at_index(
547    node: &Node,
548    path: &mut Vec<usize>,
549    target: usize,
550    count: &mut usize,
551) -> Option<()> {
552    if *count == target {
553        return Some(());
554    }
555    *count += 1;
556    if !node.expanded {
557        return None;
558    }
559    for (i, child) in node.children.iter().enumerate() {
560        path.push(i);
561        if find_path_at_index(child, path, target, count).is_some() {
562            return Some(());
563        }
564        path.pop();
565    }
566    None
567}
568
569#[cfg(test)]
570mod tests {
571    use std::collections::BTreeSet;
572    use std::ffi::OsString;
573    use std::path::PathBuf;
574
575    use super::{CycleOutcome, ExpandOutcome, Node, SelectionState, Tree, VisibleNode};
576
577    fn dir(name: &str, path: &str) -> Node {
578        Node::new(OsString::from(name), PathBuf::from(path))
579    }
580
581    fn loaded_dir(name: &str, path: &str, children: Vec<Node>) -> Node {
582        let mut node = dir(name, path);
583        node.children = children;
584        node.children_loaded = true;
585        node
586    }
587
588    fn loaded_empty(name: &str, path: &str) -> Node {
589        loaded_dir(name, path, Vec::new())
590    }
591
592    fn visible_paths(vn: &[VisibleNode<'_>]) -> Vec<PathBuf> {
593        vn.iter().map(|v| v.node.path.clone()).collect()
594    }
595
596    fn pb(s: &str) -> PathBuf {
597        PathBuf::from(s)
598    }
599
600    fn pbs(paths: &[&str]) -> BTreeSet<PathBuf> {
601        paths.iter().map(|s| pb(s)).collect()
602    }
603
604    // --- SelectionState::next cycles in the documented order ---
605
606    #[test]
607    fn selection_state_cycles_none_project_projects_parent_none() {
608        assert_eq!(SelectionState::None.next(), SelectionState::Project);
609        assert_eq!(
610            SelectionState::Project.next(),
611            SelectionState::ProjectsParent,
612        );
613        assert_eq!(SelectionState::ProjectsParent.next(), SelectionState::None);
614    }
615
616    // --- visible_nodes lists the root, then expanded children ---
617
618    #[test]
619    fn visible_nodes_lists_only_root_when_unexpanded() {
620        let tree = Tree::new(dir("root", "/"));
621        let vis = tree.visible_nodes();
622        assert_eq!(vis.len(), 1);
623        assert_eq!(vis[0].node.name, OsString::from("root"));
624        assert_eq!(vis[0].depth, 0);
625        assert!(vis[0].focused);
626    }
627
628    #[test]
629    fn visible_nodes_includes_children_when_root_expanded() {
630        let mut root = loaded_dir(
631            "root",
632            "/",
633            vec![loaded_empty("lib", "/lib"), loaded_empty("web", "/web")],
634        );
635        root.expanded = true;
636        let tree = Tree::new(root);
637        let vis = tree.visible_nodes();
638        assert_eq!(visible_paths(&vis), vec![pb("/"), pb("/lib"), pb("/web")]);
639        assert_eq!(
640            vis.iter().map(|v| v.depth).collect::<Vec<_>>(),
641            vec![0, 1, 1]
642        );
643    }
644
645    #[test]
646    fn collapse_hides_descendants_without_dropping_their_state() {
647        let mut root = loaded_dir("root", "/", vec![loaded_empty("lib", "/lib")]);
648        root.expanded = true;
649        root.children[0].state = SelectionState::Project;
650        let mut tree = Tree::new(root);
651        tree.focus_next(); // focus lib (depth 1)
652        tree.set_focused_to_root_for_test();
653        tree.collapse_focused();
654        let vis = tree.visible_nodes();
655        assert_eq!(visible_paths(&vis), vec![pb("/")]);
656        // Re-expand: descendant state preserved.
657        assert_eq!(tree.try_expand_focused(), ExpandOutcome::Expanded);
658        let vis = tree.visible_nodes();
659        assert_eq!(vis[1].node.state, SelectionState::Project);
660    }
661
662    // --- focus navigation ---
663
664    #[test]
665    fn focus_next_walks_visible_list_then_wraps_to_root() {
666        let mut root = loaded_dir(
667            "root",
668            "/",
669            vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
670        );
671        root.expanded = true;
672        let mut tree = Tree::new(root);
673        assert_eq!(tree.focused().path, pb("/"));
674        tree.focus_next();
675        assert_eq!(tree.focused().path, pb("/a"));
676        tree.focus_next();
677        assert_eq!(tree.focused().path, pb("/b"));
678        tree.focus_next(); // wraps
679        assert_eq!(tree.focused().path, pb("/"));
680    }
681
682    #[test]
683    fn focus_prev_wraps_from_root_to_last_visible_node() {
684        let mut root = loaded_dir(
685            "root",
686            "/",
687            vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
688        );
689        root.expanded = true;
690        let mut tree = Tree::new(root);
691        tree.focus_prev();
692        assert_eq!(tree.focused().path, pb("/b"));
693    }
694
695    // --- AUX-003 cycle on the focused node ---
696
697    #[test]
698    fn aux_003_cycle_focused_advances_none_to_project() {
699        let tree_root = loaded_empty("root", "/");
700        let mut tree = Tree::new(tree_root);
701        let outcome = tree.cycle_focused();
702        assert_eq!(outcome, CycleOutcome::Advanced(SelectionState::Project));
703        assert_eq!(tree.focused().state, SelectionState::Project);
704    }
705
706    #[test]
707    fn aux_003_cycle_focused_advances_project_to_projects_parent_when_subdirs_exist() {
708        let root = loaded_dir(
709            "root",
710            "/",
711            vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
712        );
713        let mut tree = Tree::new(root);
714        tree.cycle_focused(); // -> Project
715        let outcome = tree.cycle_focused();
716        assert_eq!(
717            outcome,
718            CycleOutcome::Advanced(SelectionState::ProjectsParent),
719        );
720    }
721
722    #[test]
723    fn aux_003_cycle_focused_advances_projects_parent_back_to_none() {
724        let root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
725        let mut tree = Tree::new(root);
726        tree.cycle_focused(); // -> Project
727        tree.cycle_focused(); // -> ProjectsParent
728        let outcome = tree.cycle_focused();
729        assert_eq!(outcome, CycleOutcome::Advanced(SelectionState::None));
730    }
731
732    // --- try_expand_focused outcomes -----------------------------
733
734    #[test]
735    fn try_expand_succeeds_on_directory_with_subdirectories() {
736        let root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
737        let mut tree = Tree::new(root);
738        assert!(!tree.focused().expanded);
739        assert_eq!(tree.try_expand_focused(), ExpandOutcome::Expanded);
740        assert!(tree.focused().expanded);
741    }
742
743    #[test]
744    fn try_expand_returns_already_expanded_when_open() {
745        let mut root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
746        root.expanded = true;
747        let mut tree = Tree::new(root);
748        assert_eq!(tree.try_expand_focused(), ExpandOutcome::AlreadyExpanded);
749    }
750
751    #[test]
752    fn try_expand_refuses_when_no_subdirectories() {
753        // A loaded leaf MUST NOT expand: there is nothing to
754        // reveal. The expansion is refused and `expanded` stays
755        // false; the TUI driver fires the refusal signal.
756        let tree_root = loaded_empty("root", "/");
757        let mut tree = Tree::new(tree_root);
758        assert!(!tree.focused().expanded);
759        assert_eq!(tree.try_expand_focused(), ExpandOutcome::RefusedNoSubdirs);
760        assert!(!tree.focused().expanded);
761    }
762
763    #[test]
764    fn try_expand_signals_needs_load_when_children_unknown() {
765        let root = dir("root", "/");
766        let mut tree = Tree::new(root);
767        assert!(!tree.focused().children_loaded);
768        assert_eq!(
769            tree.try_expand_focused(),
770            ExpandOutcome::NeedsChildrenLoaded
771        );
772        assert!(!tree.focused().expanded);
773    }
774
775    // --- AUX-004 rule 1: empty-glob refusal ---
776
777    #[test]
778    fn aux_004_rule_1_skips_projects_parent_when_no_subdirectories() {
779        // On a leaf, ProjectsParent is unreachable; the cycle
780        // continues past it to None so the user can always
781        // escape Project. The RefusedEmptyGlob outcome tells the
782        // driver to fire the AUX-004 rule 1 refusal signal.
783        let tree_root = loaded_empty("root", "/");
784        let mut tree = Tree::new(tree_root);
785        tree.cycle_focused(); // -> Project
786        let outcome = tree.cycle_focused();
787        assert_eq!(outcome, CycleOutcome::RefusedEmptyGlob);
788        assert_eq!(tree.focused().state, SelectionState::None);
789    }
790
791    #[test]
792    fn aux_004_rule_1_request_signals_needs_load_when_children_unknown() {
793        let mut root = dir("root", "/");
794        // not loaded: children_loaded = false.
795        root.state = SelectionState::Project;
796        let mut tree = Tree::new(root);
797        let outcome = tree.cycle_focused();
798        assert_eq!(outcome, CycleOutcome::NeedsChildrenLoaded);
799        // The state was NOT changed.
800        assert_eq!(tree.focused().state, SelectionState::Project);
801    }
802
803    #[test]
804    fn populate_focused_children_marks_children_loaded() {
805        let root = dir("root", "/");
806        let mut tree = Tree::new(root);
807        assert!(tree.focused_needs_children_loaded());
808        tree.populate_focused_children(vec![loaded_empty("a", "/a")]);
809        assert!(!tree.focused_needs_children_loaded());
810        assert_eq!(tree.focused().children.len(), 1);
811    }
812
813    // --- AUX-004 rule 2: sub-tree lockout ---
814
815    #[test]
816    fn aux_004_rule_2_lockout_refuses_cycle_when_ancestor_is_projects_parent() {
817        let mut root = loaded_dir(
818            "root",
819            "/",
820            vec![loaded_dir(
821                "lib",
822                "/lib",
823                vec![loaded_empty("src", "/lib/src")],
824            )],
825        );
826        root.expanded = true;
827        root.children[0].expanded = true;
828        root.children[0].state = SelectionState::ProjectsParent;
829        let mut tree = Tree::new(root);
830        // Move focus to lib/src.
831        tree.focus_next(); // lib
832        tree.focus_next(); // lib/src
833        let outcome = tree.cycle_focused();
834        assert_eq!(outcome, CycleOutcome::RefusedSubtreeLock);
835        assert_eq!(tree.focused().state, SelectionState::None);
836    }
837
838    #[test]
839    fn aux_004_rule_2_cycling_into_projects_parent_clears_direct_child_state() {
840        // /lib/src holds Project when /lib cycles from Project
841        // into ProjectsParent. Without the clear, /lib/* would
842        // appear in workspace.yml alongside the shadowed literal
843        // /lib/src. After the cycle, /lib/src MUST be None.
844        let mut src = loaded_empty("src", "/lib/src");
845        src.state = SelectionState::Project;
846        let lib = loaded_dir("lib", "/lib", vec![src]);
847        let mut root = loaded_dir("root", "/", vec![lib]);
848        root.expanded = true;
849        let mut tree = Tree::new(root);
850        tree.focus_next(); // lib
851        assert_eq!(
852            tree.cycle_focused(),
853            CycleOutcome::Advanced(SelectionState::Project),
854        );
855        // At this point /lib is Project; /lib/src is still Project.
856        assert_eq!(tree.focused().children[0].state, SelectionState::Project);
857        // The second cycle moves /lib into ProjectsParent and
858        // clears /lib/src.
859        assert_eq!(
860            tree.cycle_focused(),
861            CycleOutcome::Advanced(SelectionState::ProjectsParent),
862        );
863        assert_eq!(tree.focused().state, SelectionState::ProjectsParent);
864        assert_eq!(tree.focused().children[0].state, SelectionState::None);
865    }
866
867    #[test]
868    fn aux_004_rule_2_clearing_recurses_through_subtree() {
869        // /a/b/c holds Project when /a cycles into ProjectsParent.
870        // The clear MUST cover transitive descendants, not just
871        // direct children: nested literals inside the glob's
872        // implicit project roots are as bad as redundant direct
873        // ones.
874        let mut c = loaded_empty("c", "/a/b/c");
875        c.state = SelectionState::Project;
876        let mut b = loaded_dir("b", "/a/b", vec![c]);
877        b.state = SelectionState::Project;
878        let a = loaded_dir("a", "/a", vec![b]);
879        let mut root = loaded_dir("root", "/", vec![a]);
880        root.expanded = true;
881        let mut tree = Tree::new(root);
882        tree.focus_next(); // /a
883        tree.cycle_focused(); // -> Project
884        tree.cycle_focused(); // -> ProjectsParent
885        assert_eq!(tree.focused().state, SelectionState::ProjectsParent);
886        assert_eq!(tree.focused().children[0].state, SelectionState::None);
887        assert_eq!(
888            tree.focused().children[0].children[0].state,
889            SelectionState::None,
890        );
891    }
892
893    #[test]
894    fn aux_004_rule_2_clearing_is_one_way_on_cycle_out() {
895        // After the parent cycles back out of ProjectsParent, the
896        // descendants stay None: the clear is destructive by
897        // design. The user re-selects from scratch.
898        let mut child = loaded_empty("child", "/parent/child");
899        child.state = SelectionState::Project;
900        let parent = loaded_dir("parent", "/parent", vec![child]);
901        let mut root = loaded_dir("root", "/", vec![parent]);
902        root.expanded = true;
903        let mut tree = Tree::new(root);
904        tree.focus_next(); // /parent
905        tree.cycle_focused(); // -> Project
906        tree.cycle_focused(); // -> ProjectsParent (clears child)
907        tree.cycle_focused(); // -> None (no restore)
908        assert_eq!(tree.focused().state, SelectionState::None);
909        assert_eq!(tree.focused().children[0].state, SelectionState::None);
910    }
911
912    #[test]
913    fn aux_004_rule_2_descendants_marked_locked_in_visible_nodes() {
914        let mut lib = loaded_dir("lib", "/lib", vec![loaded_empty("src", "/lib/src")]);
915        lib.state = SelectionState::ProjectsParent;
916        lib.expanded = true;
917        let mut root = loaded_dir("root", "/", vec![lib]);
918        root.expanded = true;
919        let tree = Tree::new(root);
920        let vis = tree.visible_nodes();
921        // root is not locked; lib is not locked (it IS the
922        // projects-parent itself); lib/src IS locked.
923        assert!(!vis[0].locked, "root not locked");
924        assert!(!vis[1].locked, "lib itself is not locked");
925        assert!(vis[2].locked, "lib/src locked under lib");
926    }
927
928    // --- AUX-005 step 1: project-root set ---
929
930    #[test]
931    fn aux_005_project_root_set_collects_project_nodes() {
932        let mut a = loaded_empty("a", "/a");
933        a.state = SelectionState::Project;
934        let mut b = loaded_empty("b", "/b");
935        b.state = SelectionState::Project;
936        let mut root = loaded_dir("root", "/", vec![a, b]);
937        root.expanded = true;
938        let tree = Tree::new(root);
939        assert_eq!(tree.project_root_set(), pbs(&["/a", "/b"]));
940    }
941
942    #[test]
943    fn aux_005_project_root_set_expands_projects_parent_to_direct_subdirs() {
944        let mut apps = loaded_dir(
945            "apps",
946            "/apps",
947            vec![
948                loaded_empty("api", "/apps/api"),
949                loaded_empty("web", "/apps/web"),
950            ],
951        );
952        apps.state = SelectionState::ProjectsParent;
953        apps.expanded = true;
954        let mut root = loaded_dir("root", "/", vec![apps]);
955        root.expanded = true;
956        let tree = Tree::new(root);
957        assert_eq!(tree.project_root_set(), pbs(&["/apps/api", "/apps/web"]),);
958    }
959
960    #[test]
961    fn aux_005_project_root_set_mixes_project_and_projects_parent() {
962        let mut lib = loaded_empty("lib", "/lib");
963        lib.state = SelectionState::Project;
964        let mut apps = loaded_dir("apps", "/apps", vec![loaded_empty("api", "/apps/api")]);
965        apps.state = SelectionState::ProjectsParent;
966        let mut root = loaded_dir("root", "/", vec![lib, apps]);
967        root.expanded = true;
968        let tree = Tree::new(root);
969        assert_eq!(tree.project_root_set(), pbs(&["/apps/api", "/lib"]),);
970    }
971
972    #[test]
973    fn aux_005_project_root_set_is_empty_when_nothing_selected() {
974        let tree = Tree::new(loaded_empty("root", "/"));
975        assert!(tree.project_root_set().is_empty());
976    }
977
978    // ---- helpers used only in tests ------------------------------
979
980    impl Tree {
981        fn set_focused_to_root_for_test(&mut self) {
982            self.focus_path.clear();
983        }
984    }
985}