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        // Split path into components
31        let components: Vec<_> = path
32            .components()
33            .filter_map(|c| {
34                if let std::path::Component::Normal(s) = c {
35                    s.to_str()
36                } else {
37                    None
38                }
39            })
40            .collect();
41
42        if components.is_empty() {
43            return Some(self);
44        }
45
46        // Navigate through the tree
47        let mut current_node = self;
48        for component in components {
49            match current_node {
50                FileTreeNode::Directory { files } => {
51                    current_node = files.get(component)?;
52                }
53                FileTreeNode::File { .. } => {
54                    return None; // Can't traverse into a file
55                }
56            }
57        }
58
59        Some(current_node)
60    }
61
62    /// Get file contents by path
63    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
64        match self.get_node(path)? {
65            FileTreeNode::File { contents } => Some(contents.as_slice()),
66            FileTreeNode::Directory { .. } => None,
67        }
68    }
69
70    /// Check if a file exists at the given path
71    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
72        matches!(self.get_node(path), Some(FileTreeNode::File { .. }))
73    }
74
75    /// Check if a directory exists at the given path
76    pub fn dir_exists<P: AsRef<Path>>(&self, path: P) -> bool {
77        matches!(self.get_node(path), Some(FileTreeNode::Directory { .. }))
78    }
79
80    /// List all files in a directory (non-recursive)
81    pub fn list_files<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
82        match self.get_node(dir_path) {
83            Some(FileTreeNode::Directory { files }) => files
84                .iter()
85                .filter_map(|(name, node)| {
86                    if matches!(node, FileTreeNode::File { .. }) {
87                        Some(name.clone())
88                    } else {
89                        None
90                    }
91                })
92                .collect(),
93            _ => Vec::new(),
94        }
95    }
96
97    /// List all subdirectories in a directory (non-recursive)
98    pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<String> {
99        match self.get_node(dir_path) {
100            Some(FileTreeNode::Directory { files }) => files
101                .iter()
102                .filter_map(|(name, node)| {
103                    if matches!(node, FileTreeNode::Directory { .. }) {
104                        Some(name.clone())
105                    } else {
106                        None
107                    }
108                })
109                .collect(),
110            _ => Vec::new(),
111        }
112    }
113
114    /// Insert a file or directory at the given path
115    pub fn insert<P: AsRef<Path>>(
116        &mut self,
117        path: P,
118        node: FileTreeNode,
119    ) -> Result<(), Box<dyn StdError + Send + Sync>> {
120        let path = path.as_ref();
121
122        // Validate and collect path components, rejecting any non-Normal component
123        // so that `..`, `.`, and absolute roots are errors rather than silent no-ops.
124        let mut components: Vec<String> = Vec::new();
125        for c in path.components() {
126            match c {
127                std::path::Component::Normal(s) => {
128                    components.push(
129                        s.to_str()
130                            .ok_or("Path component is not valid UTF-8")?
131                            .to_string(),
132                    );
133                }
134                std::path::Component::ParentDir => {
135                    return Err("Path traversal ('..') is not allowed".into());
136                }
137                std::path::Component::CurDir => {
138                    return Err("Current-directory ('.') components are not allowed".into());
139                }
140                std::path::Component::RootDir | std::path::Component::Prefix(_) => {
141                    return Err("Absolute paths are not allowed; use a relative path".into());
142                }
143            }
144        }
145
146        if components.is_empty() {
147            return Err("Cannot insert at root path".into());
148        }
149
150        // Navigate to parent directory, creating directories as needed
151        let mut current_node = self;
152        for component in &components[..components.len() - 1] {
153            match current_node {
154                FileTreeNode::Directory { files } => {
155                    current_node =
156                        files
157                            .entry(component.clone())
158                            .or_insert_with(|| FileTreeNode::Directory {
159                                files: HashMap::new(),
160                            });
161                }
162                FileTreeNode::File { .. } => {
163                    return Err("Cannot traverse into a file".into());
164                }
165            }
166        }
167
168        // Insert the new node
169        let filename = &components[components.len() - 1];
170        match current_node {
171            FileTreeNode::Directory { files } => {
172                files.insert(filename.clone(), node);
173                Ok(())
174            }
175            FileTreeNode::File { .. } => Err("Cannot insert into a file".into()),
176        }
177    }
178
179    pub fn print_tree(&self) -> String {
180        self.print_tree_recursive("", "", true)
181    }
182
183    fn print_tree_recursive(&self, name: &str, prefix: &str, is_last: bool) -> String {
184        let mut result = String::new();
185
186        // Choose the appropriate tree characters
187        let connector = if is_last { "└── " } else { "├── " };
188        let extension = if is_last { "    " } else { "│   " };
189
190        match self {
191            FileTreeNode::File { .. } => {
192                result.push_str(&format!("{}{}{}\n", prefix, connector, name));
193            }
194            FileTreeNode::Directory { files } => {
195                // Add trailing slash for directories like `tree` does
196                result.push_str(&format!("{}{}{}/\n", prefix, connector, name));
197
198                let child_prefix = format!("{}{}", prefix, extension);
199                let count = files.len();
200
201                for (i, (child_name, node)) in files.iter().enumerate() {
202                    let is_last_child = i == count - 1;
203                    result.push_str(&node.print_tree_recursive(
204                        child_name,
205                        &child_prefix,
206                        is_last_child,
207                    ));
208                }
209            }
210        }
211
212        result
213    }
214}