Skip to main content

termesh_workspace/
snapshot.rs

1//! The slice of workspace state offered to the agent as context (ADR-0005 §7,
2//! ARCHITECTURE.md §9.2).
3//!
4//! The standing question for anything the workspace learns is *"does the agent get to
5//! see this?"*. This is the answer for the file tree: a typed snapshot built by a pure
6//! function from the same tree the human is looking at.
7//!
8//! Building it from `FileTree` rather than from the filesystem is the whole point. The
9//! agent gets exactly what the human sees — same ignore rules, same expansion state, no
10//! `target/`, no `.git` — because there is only one source of truth. A second traversal
11//! would be a second chance to disagree.
12//!
13//! No `AgentService` exists until Phase 03, so this deliberately stops at the typed
14//! value: the serialization format and the point in the ACP turn at which it is attached
15//! are that phase's decisions, recorded in its own ADR.
16
17use std::path::{Path, PathBuf};
18
19use termesh_filesystem::FileTree;
20
21use crate::root::{ProjectKind, WorkspaceRoot};
22
23/// One entry in the tree as the agent sees it.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct TreeEntry {
26    /// Path relative to the workspace root — absolute paths leak the user's home
27    /// directory into agent context for no benefit.
28    pub path: PathBuf,
29    pub depth: usize,
30    pub is_dir: bool,
31    /// Directories the user has not opened. Flagged so the agent can tell "this is
32    /// empty" from "I have not looked inside", and ask for it if it needs to.
33    pub unexplored: bool,
34}
35
36/// What the agent is told about the workspace.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct WorkspaceSnapshot {
39    pub root: PathBuf,
40    pub project_kind: ProjectKind,
41    /// Every detected project kind, matching the root's marker-priority order.
42    pub project_kinds: Vec<ProjectKind>,
43    /// The currently visible tree — loaded, expanded, ignore-filtered.
44    pub visible_tree: Vec<TreeEntry>,
45    /// What the human currently has selected, relative to the root.
46    pub selection: Option<PathBuf>,
47}
48
49impl WorkspaceSnapshot {
50    /// Build the snapshot from the live tree. Pure — no I/O, so it is snapshot-testable
51    /// and cannot drift from what is on screen.
52    pub fn build(root: &WorkspaceRoot, tree: &FileTree) -> Self {
53        let rows = tree.visible_rows();
54        let selected = tree.selected();
55
56        let visible_tree = rows
57            .iter()
58            // Skip the root row itself; `root` already names it.
59            .filter(|r| r.id != tree.root())
60            .filter_map(|r| {
61                let full = tree.path_of(r.id)?;
62                Some(TreeEntry {
63                    path: relative_to(&root.path, full),
64                    // The root is depth 0, so its children start at 1; re-base to 0.
65                    depth: r.depth.saturating_sub(1),
66                    is_dir: r.is_expandable,
67                    unexplored: r.is_expandable && !r.expanded,
68                })
69            })
70            .collect();
71
72        let selection = tree
73            .path_of(selected)
74            .filter(|_| selected != tree.root())
75            .map(|p| relative_to(&root.path, p));
76
77        Self {
78            root: root.path.clone(),
79            project_kind: root.kind,
80            project_kinds: root.kinds.clone(),
81            visible_tree,
82            selection,
83        }
84    }
85
86    /// Number of entries the agent can currently see.
87    pub fn len(&self) -> usize {
88        self.visible_tree.len()
89    }
90
91    pub fn is_empty(&self) -> bool {
92        self.visible_tree.is_empty()
93    }
94}
95
96/// Strip the root prefix, falling back to the full path if it is not underneath.
97fn relative_to(root: &Path, path: &Path) -> PathBuf {
98    path.strip_prefix(root).unwrap_or(path).to_path_buf()
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use termesh_filesystem::{DirEntryInfo, EntryKind};
105
106    fn root() -> WorkspaceRoot {
107        WorkspaceRoot {
108            path: PathBuf::from("/proj"),
109            kind: ProjectKind::Rust,
110            kinds: vec![ProjectKind::Rust],
111            detected: true,
112        }
113    }
114
115    fn entry(name: &str, kind: EntryKind) -> DirEntryInfo {
116        DirEntryInfo { name: name.into(), path: PathBuf::from("/proj").join(name), kind }
117    }
118
119    /// A tree with `/proj` expanded and `src` present but unopened.
120    fn tree() -> FileTree {
121        let mut t = FileTree::new("/proj", "proj");
122        let _ = t.expand(t.root());
123        t.set_children(
124            t.root(),
125            vec![entry("src", EntryKind::Dir), entry("Cargo.toml", EntryKind::File)],
126        );
127        t
128    }
129
130    fn paths(s: &WorkspaceSnapshot) -> Vec<String> {
131        s.visible_tree.iter().map(|e| e.path.to_string_lossy().into_owned()).collect()
132    }
133
134    #[test]
135    fn the_snapshot_carries_the_root_and_project_kind() {
136        let s = WorkspaceSnapshot::build(&root(), &tree());
137        assert_eq!(s.root, Path::new("/proj"));
138        assert_eq!(s.project_kind, ProjectKind::Rust);
139        assert_eq!(s.project_kinds, vec![ProjectKind::Rust]);
140    }
141
142    #[test]
143    fn paths_are_relative_to_the_root() {
144        let s = WorkspaceSnapshot::build(&root(), &tree());
145        assert_eq!(paths(&s), ["src", "Cargo.toml"]);
146        assert!(
147            !paths(&s).iter().any(|p| p.starts_with('/')),
148            "absolute paths would leak the user's home directory into agent context"
149        );
150    }
151
152    #[test]
153    fn the_root_row_itself_is_not_repeated_as_an_entry() {
154        let s = WorkspaceSnapshot::build(&root(), &tree());
155        assert!(!paths(&s).contains(&"proj".to_string()));
156    }
157
158    #[test]
159    fn unopened_directories_are_flagged_as_unexplored() {
160        let s = WorkspaceSnapshot::build(&root(), &tree());
161        let src = s.visible_tree.iter().find(|e| e.path == Path::new("src")).unwrap();
162        assert!(src.is_dir);
163        assert!(src.unexplored, "the agent must be able to ask for what it cannot see");
164
165        let toml = s.visible_tree.iter().find(|e| e.path == Path::new("Cargo.toml")).unwrap();
166        assert!(!toml.unexplored, "files are never unexplored");
167    }
168
169    #[test]
170    fn an_expanded_directory_is_not_unexplored_and_its_children_appear() {
171        let mut t = tree();
172        let src = t.visible_rows()[1].id;
173        let _ = t.expand(src);
174        t.set_children(
175            src,
176            vec![DirEntryInfo {
177                name: "main.rs".into(),
178                path: "/proj/src/main.rs".into(),
179                kind: EntryKind::File,
180            }],
181        );
182
183        let s = WorkspaceSnapshot::build(&root(), &t);
184        assert_eq!(paths(&s), ["src", "src/main.rs", "Cargo.toml"]);
185        assert!(!s.visible_tree[0].unexplored);
186    }
187
188    #[test]
189    fn depth_is_rebased_so_the_roots_children_are_zero() {
190        let mut t = tree();
191        let src = t.visible_rows()[1].id;
192        let _ = t.expand(src);
193        t.set_children(
194            src,
195            vec![DirEntryInfo {
196                name: "main.rs".into(),
197                path: "/proj/src/main.rs".into(),
198                kind: EntryKind::File,
199            }],
200        );
201
202        let s = WorkspaceSnapshot::build(&root(), &t);
203        assert_eq!(s.visible_tree[0].depth, 0, "src");
204        assert_eq!(s.visible_tree[1].depth, 1, "src/main.rs");
205    }
206
207    #[test]
208    fn the_selection_is_reported_relative_to_the_root() {
209        let mut t = tree();
210        let toml = t.visible_rows()[2].id;
211        t.select(toml);
212
213        let s = WorkspaceSnapshot::build(&root(), &t);
214        assert_eq!(s.selection, Some(PathBuf::from("Cargo.toml")));
215    }
216
217    #[test]
218    fn selecting_the_root_reports_no_selection() {
219        let s = WorkspaceSnapshot::build(&root(), &tree());
220        assert_eq!(s.selection, None, "the root is not a meaningful selection");
221    }
222
223    #[test]
224    fn the_agent_sees_exactly_the_visible_rows_no_more() {
225        // The premise of the whole design: one source of truth, so what is filtered out
226        // of the human's view is filtered out of the agent's too.
227        let t = tree();
228        let s = WorkspaceSnapshot::build(&root(), &t);
229        assert_eq!(
230            s.len(),
231            t.visible_rows().len() - 1,
232            "every visible row but the root, and nothing else"
233        );
234    }
235
236    #[test]
237    fn a_collapsed_directory_hides_its_children_from_the_agent_too() {
238        let mut t = tree();
239        let src = t.visible_rows()[1].id;
240        let _ = t.expand(src);
241        t.set_children(
242            src,
243            vec![DirEntryInfo {
244                name: "main.rs".into(),
245                path: "/proj/src/main.rs".into(),
246                kind: EntryKind::File,
247            }],
248        );
249        t.collapse(src);
250
251        let s = WorkspaceSnapshot::build(&root(), &t);
252        assert_eq!(paths(&s), ["src", "Cargo.toml"]);
253        assert!(s.visible_tree[0].unexplored, "collapsed reads as unexplored");
254    }
255}