1use std::path::{Path, PathBuf};
18
19use termesh_filesystem::FileTree;
20
21use crate::root::{ProjectKind, WorkspaceRoot};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct TreeEntry {
26 pub path: PathBuf,
29 pub depth: usize,
30 pub is_dir: bool,
31 pub unexplored: bool,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct WorkspaceSnapshot {
39 pub root: PathBuf,
40 pub project_kind: ProjectKind,
41 pub project_kinds: Vec<ProjectKind>,
43 pub visible_tree: Vec<TreeEntry>,
45 pub selection: Option<PathBuf>,
47}
48
49impl WorkspaceSnapshot {
50 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 .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 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 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
96fn 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 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 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}