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