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;
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 /// Insert a file or directory at the given path
126 pub fn insert<P: AsRef<Path>>(
127 &mut self,
128 path: P,
129 node: FileTreeNode,
130 ) -> Result<(), Box<dyn StdError + Send + Sync>> {
131 let path = path.as_ref();
132
133 // Validate and collect path components, rejecting any non-Normal component
134 // so that `..`, `.`, and absolute roots are errors rather than silent no-ops.
135 let mut components: Vec<String> = Vec::new();
136 for c in path.components() {
137 match c {
138 std::path::Component::Normal(s) => {
139 components.push(
140 s.to_str()
141 .ok_or("Path component is not valid UTF-8")?
142 .to_string(),
143 );
144 }
145 std::path::Component::ParentDir => {
146 return Err("Path traversal ('..') is not allowed".into());
147 }
148 std::path::Component::CurDir => {
149 return Err("Current-directory ('.') components are not allowed".into());
150 }
151 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
152 return Err("Absolute paths are not allowed; use a relative path".into());
153 }
154 }
155 }
156
157 if components.is_empty() {
158 return Err("Cannot insert at root path".into());
159 }
160
161 // Navigate to parent directory, creating directories as needed
162 let mut current_node = self;
163 for component in &components[..components.len() - 1] {
164 match current_node {
165 FileTreeNode::Directory { files } => {
166 current_node =
167 files
168 .entry(component.clone())
169 .or_insert_with(|| FileTreeNode::Directory {
170 files: HashMap::new(),
171 });
172 }
173 FileTreeNode::File { .. } => {
174 return Err("Cannot traverse into a file".into());
175 }
176 }
177 }
178
179 // Insert the new node
180 let filename = &components[components.len() - 1];
181 match current_node {
182 FileTreeNode::Directory { files } => {
183 files.insert(filename.clone(), node);
184 Ok(())
185 }
186 FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
187 }
188 }
189
190 /// Flatten the tree into `(path, contents)` pairs: the inverse of building
191 /// a tree by `insert`-ing each path. Paths are `"/"`-joined and relative
192 /// (no leading slash), exactly the key shape the WASM `Quill.fromTree`
193 /// boundary consumes, so `from_tree(flatten(t))` round-trips every file.
194 /// Output is sorted by path for deterministic ordering (the construction
195 /// side stores children in a `HashMap`, which has no inherent order).
196 ///
197 /// Only files are emitted: an EMPTY directory yields no entry and so is not
198 /// reconstructed by a `flatten` → `insert` round trip. This is intentional
199 /// (quill bundles are file-addressed and nothing in load/render depends on
200 /// empty directories) but it means the round trip preserves file contents,
201 /// not exact directory structure.
202 pub fn flatten(&self) -> Vec<(String, Vec<u8>)> {
203 let mut out = Vec::new();
204 self.for_each_file(&mut |path, contents| out.push((path.to_string(), contents.to_vec())));
205 out.sort_by(|(a, _), (b, _)| a.cmp(b));
206 out
207 }
208
209 /// Visit every file in the tree with its `/`-joined path, depth-first in
210 /// `HashMap` order (so unordered: callers that need a stable sequence sort
211 /// the result). The one walk: [`flatten`](Self::flatten) copies out of it,
212 /// `Quill::find_files` only reads the paths, and neither pays for the
213 /// other's work.
214 pub(crate) fn for_each_file(&self, visit: &mut impl FnMut(&str, &[u8])) {
215 self.walk_files(String::new(), visit);
216 }
217
218 fn walk_files(&self, prefix: String, visit: &mut impl FnMut(&str, &[u8])) {
219 match self {
220 FileTreeNode::File { contents } => {
221 // A File only reaches here with a non-empty prefix: the root is
222 // always a Directory, so every file is named by its parent.
223 if !prefix.is_empty() {
224 visit(&prefix, contents);
225 }
226 }
227 FileTreeNode::Directory { files } => {
228 for (name, node) in files {
229 let path = if prefix.is_empty() {
230 name.clone()
231 } else {
232 format!("{}/{}", prefix, name)
233 };
234 node.walk_files(path, visit);
235 }
236 }
237 }
238 }
239
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 fn sample() -> FileTreeNode {
247 let mut root = FileTreeNode::Directory {
248 files: std::collections::HashMap::new(),
249 };
250 root.insert(
251 "a/b.txt",
252 FileTreeNode::File {
253 contents: b"hi".to_vec(),
254 },
255 )
256 .unwrap();
257 root
258 }
259
260 #[test]
261 fn get_node_rejects_traversal_components() {
262 let t = sample();
263 // Normal lookups resolve.
264 assert!(t.get_file("a/b.txt").is_some());
265 // `..`, `.`, and absolute roots resolve to None rather than being
266 // silently dropped (which would make `a/../b.txt` navigate to `a/b.txt`).
267 assert!(t.get_node("a/../b.txt").is_none());
268 assert!(t.get_node("./a/b.txt").is_none());
269 assert!(t.get_node("/a/b.txt").is_none());
270 }
271}