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.flatten_into(String::new(), &mut out);
198        out.sort_by(|(a, _), (b, _)| a.cmp(b));
199        out
200    }
201
202    fn flatten_into(&self, prefix: String, out: &mut Vec<(String, Vec<u8>)>) {
203        match self {
204            FileTreeNode::File { contents } => {
205                // A File only reaches here with a non-empty prefix: the root is
206                // always a Directory, so every file is named by its parent.
207                if !prefix.is_empty() {
208                    out.push((prefix, contents.clone()));
209                }
210            }
211            FileTreeNode::Directory { files } => {
212                for (name, node) in files {
213                    let path = if prefix.is_empty() {
214                        name.clone()
215                    } else {
216                        format!("{}/{}", prefix, name)
217                    };
218                    node.flatten_into(path, out);
219                }
220            }
221        }
222    }
223
224    pub fn print_tree(&self) -> String {
225        self.print_tree_recursive("", "", true)
226    }
227
228    fn print_tree_recursive(&self, name: &str, prefix: &str, is_last: bool) -> String {
229        let mut result = String::new();
230
231        // Choose the appropriate tree characters
232        let connector = if is_last { "└── " } else { "├── " };
233        let extension = if is_last { "    " } else { "│   " };
234
235        match self {
236            FileTreeNode::File { .. } => {
237                result.push_str(&format!("{}{}{}\n", prefix, connector, name));
238            }
239            FileTreeNode::Directory { files } => {
240                // Add trailing slash for directories like `tree` does
241                result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
242
243                let child_prefix = format!("{}{}", prefix, extension);
244                let count = files.len();
245
246                for (i, (child_name, node)) in files.iter().enumerate() {
247                    let is_last_child = i == count - 1;
248                    result.push_str(&node.print_tree_recursive(
249                        child_name,
250                        &child_prefix,
251                        is_last_child,
252                    ));
253                }
254            }
255        }
256
257        result
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn sample() -> FileTreeNode {
266        let mut root = FileTreeNode::Directory {
267            files: std::collections::HashMap::new(),
268        };
269        root.insert(
270            "a/b.txt",
271            FileTreeNode::File {
272                contents: b"hi".to_vec(),
273            },
274        )
275        .unwrap();
276        root
277    }
278
279    #[test]
280    fn get_node_rejects_traversal_components() {
281        let t = sample();
282        // Normal lookups resolve.
283        assert!(t.get_file("a/b.txt").is_some());
284        // `..`, `.`, and absolute roots resolve to None rather than being
285        // silently dropped (which would make `a/../b.txt` navigate to `a/b.txt`).
286        assert!(t.get_node("a/../b.txt").is_none());
287        assert!(t.get_node("./a/b.txt").is_none());
288        assert!(t.get_node("/a/b.txt").is_none());
289    }
290}