Skip to main content

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}