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;
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}