quillmark_core/quill/tree.rs
1//! In-memory file tree representation for quill bundles.
2use std::collections::HashMap;
3use std::error::Error as StdError;
4use std::path::{Path, PathBuf};
5/// A node in the file tree structure
6///
7/// Out-of-crate callers build these; `Quill::from_tree` takes one.
8/// `#[non_exhaustive]` leaves variant construction untouched, so the building
9/// direction is unaffected.
10///
11/// Symlinks are refused at the loader rather than modelled here.
12#[derive(Debug, Clone)]
13#[non_exhaustive]
14pub enum FileTreeNode {
15 /// A file with its contents
16 File {
17 /// The file contents as bytes or UTF-8 string
18 contents: Vec<u8>,
19 },
20 /// A directory containing other files and directories
21 Directory {
22 /// The files and subdirectories in this directory
23 files: HashMap<String, FileTreeNode>,
24 },
25}
26
27impl FileTreeNode {
28 /// Get a file or directory node by path
29 pub fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&FileTreeNode> {
30 let path = path.as_ref();
31
32 // Handle root path
33 if path == Path::new("") {
34 return Some(self);
35 }
36
37 // Collect path components, rejecting any non-Normal component so that
38 // `..`, `.`, and absolute roots resolve to `None` rather than being
39 // silently dropped. Dropping them makes `get_file("a/../b")` navigate to
40 // `a/b`, an asymmetry with `insert` (which rejects such paths) that
41 // could mask path handling that assumes `get_node` normalizes.
42 let mut components: Vec<&str> = Vec::new();
43 for c in path.components() {
44 match c {
45 std::path::Component::Normal(s) => match s.to_str() {
46 Some(s) => components.push(s),
47 None => return None,
48 },
49 _ => return None,
50 }
51 }
52
53 if components.is_empty() {
54 return Some(self);
55 }
56
57 // Navigate through the tree
58 let mut current_node = self;
59 for component in components {
60 match current_node {
61 FileTreeNode::Directory { files } => {
62 current_node = files.get(component)?;
63 }
64 FileTreeNode::File { .. } => {
65 return None; // Can't traverse into a file
66 }
67 }
68 }
69
70 Some(current_node)
71 }
72
73 /// Get file contents by path
74 pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
75 match self.get_node(path)? {
76 FileTreeNode::File { contents } => Some(contents.as_slice()),
77 FileTreeNode::Directory { .. } => None,
78 }
79 }
80
81 /// Check if a file exists at the given path
82 pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
83 matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
84 }
85
86 /// Check if a directory exists at the given path
87 pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
88 matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
89 }
90
91 /// List all files in a directory (non-recursive)
92 pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
93 match self.get_node(dir_path) {
94 Some(FileTreeNode::Directory { files }) => files
95 .iter()
96 .filter_map(|(name, node)| {
97 if matches!(node, FileTreeNode::File { .. }) {
98 Some(name.clone())
99 } else {
100 None
101 }
102 })
103 .collect(),
104 _ => Vec::new(),
105 }
106 }
107
108 /// List all subdirectories in a directory (non-recursive)
109 pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
110 match self.get_node(dir_path) {
111 Some(FileTreeNode::Directory { files }) => files
112 .iter()
113 .filter_map(|(name, node)| {
114 if matches!(node, FileTreeNode::Directory { .. }) {
115 Some(name.clone())
116 } else {
117 None
118 }
119 })
120 .collect(),
121 _ => Vec::new(),
122 }
123 }
124
125 /// List all directories in a directory, as paths joined onto `dir_path`.
126 /// The name twin is [`list_subdirectories`](Self::list_subdirectories);
127 /// results stay relative to the receiver either way.
128 pub fn list_directories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
129 let dir_path = dir_path.as_ref();
130 self.list_subdirectories(dir_path)
131 .iter()
132 .map(|name| {
133 if dir_path == Path::new("") {
134 PathBuf::from(name)
135 } else {
136 dir_path.join(name)
137 }
138 })
139 .collect()
140 }
141
142 /// Get all files matching a pattern (supports glob-style wildcards).
143 /// An invalid pattern matches nothing.
144 pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
145 let Ok(glob_pattern) = glob::Pattern::new(&pattern.as_ref().to_string_lossy()) else {
146 return Vec::new();
147 };
148 let mut matches = Vec::new();
149 // Paths only: the visitor lends the contents, so no bundle bytes are
150 // copied to answer a name query.
151 self.for_each_file(&mut |path, _| {
152 if glob_pattern.matches(path) {
153 matches.push(PathBuf::from(path));
154 }
155 });
156 matches.sort();
157 matches
158 }
159
160 /// Insert a file or directory at the given path
161 pub fn insert<P: AsRef<Path>>(
162 &mut self,
163 path: P,
164 node: FileTreeNode,
165 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
166 let path = path.as_ref();
167
168 // Validate and collect path components, rejecting any non-Normal component
169 // so that `..`, `.`, and absolute roots are errors rather than silent no-ops.
170 let mut components: Vec<String> = Vec::new();
171 for c in path.components() {
172 match c {
173 std::path::Component::Normal(s) => {
174 components.push(
175 s.to_str()
176 .ok_or("Path component is not valid UTF-8")?
177 .to_string(),
178 );
179 }
180 std::path::Component::ParentDir => {
181 return Err("Path traversal ('..') is not allowed".into());
182 }
183 std::path::Component::CurDir => {
184 return Err("Current-directory ('.') components are not allowed".into());
185 }
186 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
187 return Err("Absolute paths are not allowed; use a relative path".into());
188 }
189 }
190 }
191
192 if components.is_empty() {
193 return Err("Cannot insert at root path".into());
194 }
195
196 // Navigate to parent directory, creating directories as needed
197 let mut current_node = self;
198 for component in &components[..components.len() - 1] {
199 match current_node {
200 FileTreeNode::Directory { files } => {
201 current_node =
202 files
203 .entry(component.clone())
204 .or_insert_with(|| FileTreeNode::Directory {
205 files: HashMap::new(),
206 });
207 }
208 FileTreeNode::File { .. } => {
209 return Err("Cannot traverse into a file".into());
210 }
211 }
212 }
213
214 // Insert the new node
215 let filename = &components[components.len() - 1];
216 match current_node {
217 FileTreeNode::Directory { files } => {
218 files.insert(filename.clone(), node);
219 Ok(())
220 }
221 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
222 }
223 }
224
225 /// Flatten the tree into `(path, contents)` pairs: the inverse of building
226 /// a tree by `insert`-ing each path. Paths are `"/"`-joined and relative
227 /// (no leading slash), exactly the key shape the WASM `Quill.fromTree`
228 /// boundary consumes, so `from_tree(flatten(t))` round-trips every file.
229 /// Output is sorted by path for deterministic ordering (the construction
230 /// side stores children in a `HashMap`, which has no inherent order).
231 ///
232 /// Only files are emitted: an EMPTY directory yields no entry and so is not
233 /// reconstructed by a `flatten` → `insert` round trip. This is intentional
234 /// (quill bundles are file-addressed and nothing in load/render depends on
235 /// empty directories) but it means the round trip preserves file contents,
236 /// not exact directory structure.
237 pub fn flatten(&self) -> Vec<(String, Vec<u8>)> {
238 let mut out = Vec::new();
239 self.for_each_file(&mut |path, contents| out.push((path.to_string(), contents.to_vec())));
240 out.sort_by(|(a, _), (b, _)| a.cmp(b));
241 out
242 }
243
244 /// Visit every file in the tree with its `/`-joined path, depth-first in
245 /// `HashMap` order (so unordered: callers that need a stable sequence sort
246 /// the result). The one walk: [`flatten`](Self::flatten) copies out of it,
247 /// [`find_files`](Self::find_files) only reads the paths, and neither pays
248 /// for the other's work.
249 fn for_each_file(&self, visit: &mut impl FnMut(&str, &[u8])) {
250 self.walk_files(String::new(), visit);
251 }
252
253 fn walk_files(&self, prefix: String, visit: &mut impl FnMut(&str, &[u8])) {
254 match self {
255 FileTreeNode::File { contents } => {
256 // A File only reaches here with a non-empty prefix: the root is
257 // always a Directory, so every file is named by its parent.
258 if !prefix.is_empty() {
259 visit(&prefix, contents);
260 }
261 }
262 FileTreeNode::Directory { files } => {
263 for (name, node) in files {
264 let path = if prefix.is_empty() {
265 name.clone()
266 } else {
267 format!("{}/{}", prefix, name)
268 };
269 node.walk_files(path, visit);
270 }
271 }
272 }
273 }
274
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn sample() -> FileTreeNode {
282 let mut root = FileTreeNode::Directory {
283 files: std::collections::HashMap::new(),
284 };
285 root.insert(
286 "a/b.txt",
287 FileTreeNode::File {
288 contents: b"hi".to_vec(),
289 },
290 )
291 .unwrap();
292 root
293 }
294
295 #[test]
296 fn get_node_rejects_traversal_components() {
297 let t = sample();
298 // Normal lookups resolve.
299 assert!(t.get_file("a/b.txt").is_some());
300 // `..`, `.`, and absolute roots resolve to None rather than being
301 // silently dropped (which would make `a/../b.txt` navigate to `a/b.txt`).
302 assert!(t.get_node("a/../b.txt").is_none());
303 assert!(t.get_node("./a/b.txt").is_none());
304 assert!(t.get_node("/a/b.txt").is_none());
305 }
306}