Skip to main content

xtask_todo_lib/devshell/vfs/
tree.rs

1//! [`Vfs`] — in-memory or host-backed virtual filesystem.
2
3use std::path::{Path, PathBuf};
4
5use super::copy_to_host::{copy_host_path_to_host_dir, copy_node_to_host};
6use super::error::VfsError;
7use super::node::Node;
8use super::path::resolve_path_with_cwd;
9
10pub struct Vfs {
11    root: Node,
12    cwd: String,
13    /// When set, project-tree operations use this host directory (logical `/` = this path).
14    /// Matches the Lima workspace mount so offline REPL and VM see the same files.
15    host_root: Option<PathBuf>,
16}
17
18impl Default for Vfs {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl Vfs {
25    #[must_use]
26    pub fn new() -> Self {
27        Self {
28            root: Node::Dir {
29                name: String::new(),
30                children: vec![],
31            },
32            cwd: "/".to_string(),
33            host_root: None,
34        }
35    }
36
37    /// Project tree backed by a host directory (same path as the Lima `workspace_parent` mount).
38    ///
39    /// # Errors
40    /// I/O errors from [`std::fs::create_dir_all`] or [`std::fs::canonicalize`].
41    pub fn new_host_root(root: impl AsRef<Path>) -> std::io::Result<Self> {
42        let root = root.as_ref();
43        std::fs::create_dir_all(root)?;
44        let root = root.canonicalize()?;
45        Ok(Self {
46            root: Node::Dir {
47                name: String::new(),
48                children: vec![],
49            },
50            cwd: "/".to_string(),
51            host_root: Some(root),
52        })
53    }
54
55    /// `true` when this instance uses the host directory ([`Self::new_host_root`]) instead of the in-memory tree.
56    #[must_use]
57    pub const fn is_host_backed(&self) -> bool {
58        self.host_root.is_some()
59    }
60
61    /// Construct VFS from root node and cwd (used by deserialization).
62    #[must_use]
63    pub const fn from_parts(root: Node, cwd: String) -> Self {
64        Self {
65            root,
66            cwd,
67            host_root: None,
68        }
69    }
70
71    fn logical_to_host_path(&self, abs_logical: &str) -> PathBuf {
72        let root = self.host_root.as_ref().unwrap();
73        let abs_logical = abs_logical.trim_end_matches('/');
74        let mut p = root.clone();
75        if abs_logical.is_empty() || abs_logical == "/" {
76            return p;
77        }
78        for seg in abs_logical.split('/').filter(|s| !s.is_empty()) {
79            p.push(seg);
80        }
81        p
82    }
83    #[must_use]
84    pub fn cwd(&self) -> &str {
85        &self.cwd
86    }
87    #[must_use]
88    pub const fn root(&self) -> &Node {
89        &self.root
90    }
91
92    /// Resolve an absolute path to a node. Path must be absolute (normalized). Trailing '/' is trimmed.
93    ///
94    /// # Errors
95    /// Returns `VfsError::InvalidPath` if any segment is missing.
96    pub fn resolve_absolute(&self, path: &str) -> Result<Node, VfsError> {
97        if self.host_root.is_some() {
98            let path = path.trim_end_matches('/');
99            let p = self.logical_to_host_path(path);
100            let meta = std::fs::metadata(&p).map_err(|_| VfsError::InvalidPath)?;
101            if meta.is_file() {
102                let content = std::fs::read(&p).map_err(VfsError::Io)?;
103                let name = p
104                    .file_name()
105                    .map(|s| s.to_string_lossy().into_owned())
106                    .unwrap_or_default();
107                return Ok(Node::File { name, content });
108            }
109            if meta.is_dir() {
110                let name = p
111                    .file_name()
112                    .map(|s| s.to_string_lossy().into_owned())
113                    .unwrap_or_default();
114                return Ok(Node::Dir {
115                    name,
116                    children: vec![],
117                });
118            }
119            return Err(VfsError::InvalidPath);
120        }
121        let path = path.trim_end_matches('/');
122        if path.is_empty() || path == "/" {
123            return Ok(self.root.clone());
124        }
125        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
126        let mut current = &self.root;
127        for segment in segments {
128            current = current.child(segment).ok_or(VfsError::InvalidPath)?;
129        }
130        Ok(current.clone())
131    }
132
133    /// 将任意路径(相对或绝对)归一化并解析为绝对路径字符串。
134    /// 相对路径先与 cwd 拼接再归一化,这样 ".." 能正确退到上级目录。
135    #[must_use]
136    pub fn resolve_to_absolute(&self, path: &str) -> String {
137        resolve_path_with_cwd(&self.cwd, path)
138    }
139
140    /// Create directory at path (`mkdir_all` style). Creates any missing parent directories.
141    ///
142    /// # Errors
143    /// Returns `VfsError::InvalidPath` if any path component exists and is not a directory.
144    pub fn mkdir(&mut self, path: &str) -> Result<(), VfsError> {
145        if self.host_root.is_some() {
146            let abs = self.resolve_to_absolute(path);
147            let p = self.logical_to_host_path(&abs);
148            std::fs::create_dir_all(&p).map_err(VfsError::Io)?;
149            return Ok(());
150        }
151        let abs = self.resolve_to_absolute(path);
152        let segments: Vec<&str> = abs.split('/').filter(|s| !s.is_empty()).collect();
153        if segments.is_empty() {
154            return Ok(());
155        }
156        let mut indices: Vec<usize> = vec![];
157        for segment in segments {
158            let current = Self::get_mut_at(&mut self.root, &indices);
159            match current {
160                Node::Dir { children, .. } => {
161                    let pos = children.iter().position(|c| c.name() == segment);
162                    if let Some(i) = pos {
163                        if !children[i].is_dir() {
164                            return Err(VfsError::InvalidPath);
165                        }
166                        indices.push(i);
167                    } else {
168                        children.push(Node::Dir {
169                            name: segment.to_string(),
170                            children: vec![],
171                        });
172                        indices.push(children.len() - 1);
173                    }
174                }
175                Node::File { .. } => return Err(VfsError::InvalidPath),
176            }
177        }
178        Ok(())
179    }
180
181    /// Create or overwrite a file at path. Parent directory must exist and be a directory.
182    ///
183    /// # Errors
184    /// Returns `VfsError::InvalidPath` if parent path does not exist or a component is not a directory.
185    pub fn write_file(&mut self, path: &str, content: &[u8]) -> Result<(), VfsError> {
186        if self.host_root.is_some() {
187            let abs = self.resolve_to_absolute(path);
188            let p = self.logical_to_host_path(&abs);
189            if let Some(parent) = p.parent() {
190                std::fs::create_dir_all(parent).map_err(VfsError::Io)?;
191            }
192            std::fs::write(&p, content).map_err(VfsError::Io)?;
193            return Ok(());
194        }
195        let abs = self.resolve_to_absolute(path);
196        let segments: Vec<&str> = abs.split('/').filter(|s| !s.is_empty()).collect();
197        let (parent_segments, file_name) = match segments.split_last() {
198            Some((last, rest)) => (rest, *last),
199            None => return Err(VfsError::InvalidPath), // path is "/" or empty
200        };
201        let mut indices: Vec<usize> = vec![];
202        for segment in parent_segments {
203            let current = Self::get_mut_at(&mut self.root, &indices);
204            match current {
205                Node::Dir { children, .. } => {
206                    let pos = children.iter().position(|c| c.name() == *segment);
207                    match pos {
208                        Some(i) => {
209                            if !children[i].is_dir() {
210                                return Err(VfsError::InvalidPath);
211                            }
212                            indices.push(i);
213                        }
214                        None => return Err(VfsError::InvalidPath), // parent path does not exist
215                    }
216                }
217                Node::File { .. } => return Err(VfsError::InvalidPath),
218            }
219        }
220        let parent = Self::get_mut_at(&mut self.root, &indices);
221        match parent {
222            Node::Dir { children, .. } => {
223                let pos = children.iter().position(|c| c.name() == file_name);
224                let node = Node::File {
225                    name: file_name.to_string(),
226                    content: content.to_vec(),
227                };
228                match pos {
229                    Some(i) => children[i] = node,
230                    None => children.push(node),
231                }
232                Ok(())
233            }
234            Node::File { .. } => Err(VfsError::InvalidPath),
235        }
236    }
237
238    /// Create an empty file at path (touch). Parent directory must exist.
239    ///
240    /// # Errors
241    /// Same as `write_file`.
242    pub fn touch(&mut self, path: &str) -> Result<(), VfsError> {
243        self.write_file(path, &[])
244    }
245
246    /// Read file content at path.
247    ///
248    /// # Errors
249    /// Returns `VfsError::InvalidPath` if path does not exist or is not a file.
250    pub fn read_file(&self, path: &str) -> Result<Vec<u8>, VfsError> {
251        if self.host_root.is_some() {
252            let abs = self.resolve_to_absolute(path);
253            let p = self.logical_to_host_path(&abs);
254            if !p.is_file() {
255                return Err(VfsError::InvalidPath);
256            }
257            return std::fs::read(&p).map_err(VfsError::Io);
258        }
259        let abs = self.resolve_to_absolute(path);
260        let n = self.resolve_absolute(&abs)?;
261        match n {
262            Node::File { content, .. } => Ok(content),
263            Node::Dir { .. } => Err(VfsError::InvalidPath),
264        }
265    }
266
267    /// List directory entries at path.
268    ///
269    /// # Errors
270    /// Returns `VfsError::InvalidPath` if path does not exist or is not a directory.
271    pub fn list_dir(&self, path: &str) -> Result<Vec<String>, VfsError> {
272        if self.host_root.is_some() {
273            let abs = self.resolve_to_absolute(path);
274            let p = self.logical_to_host_path(&abs);
275            if !p.is_dir() {
276                return Err(VfsError::InvalidPath);
277            }
278            let mut out = Vec::new();
279            for e in std::fs::read_dir(&p).map_err(VfsError::Io)? {
280                let e = e.map_err(VfsError::Io)?;
281                out.push(e.file_name().to_string_lossy().into_owned());
282            }
283            out.sort();
284            return Ok(out);
285        }
286        let abs = self.resolve_to_absolute(path);
287        let n = self.resolve_absolute(&abs)?;
288        match n {
289            Node::Dir { children, .. } => {
290                Ok(children.iter().map(|c| c.name().to_string()).collect())
291            }
292            Node::File { .. } => Err(VfsError::InvalidPath),
293        }
294    }
295
296    /// Set current working directory to path.
297    ///
298    /// # Errors
299    /// Returns `VfsError::InvalidPath` if path does not exist or is not a directory.
300    pub fn set_cwd(&mut self, path: &str) -> Result<(), VfsError> {
301        if self.host_root.is_some() {
302            let abs = self.resolve_to_absolute(path);
303            let p = self.logical_to_host_path(&abs);
304            let meta = std::fs::metadata(&p).map_err(|_| VfsError::InvalidPath)?;
305            if !meta.is_dir() {
306                return Err(VfsError::InvalidPath);
307            }
308            self.cwd = if abs == "/" { "/".to_string() } else { abs };
309            return Ok(());
310        }
311        let abs = self.resolve_to_absolute(path);
312        let n = self.resolve_absolute(&abs)?;
313        if !n.is_dir() {
314            return Err(VfsError::InvalidPath);
315        }
316        self.cwd = if abs == "/" { "/".to_string() } else { abs };
317        Ok(())
318    }
319
320    /// Mutable reference to node at path of child indices from the given node.
321    fn get_mut_at<'a>(node: &'a mut Node, path: &[usize]) -> &'a mut Node {
322        if path.is_empty() {
323            return node;
324        }
325        match node {
326            Node::Dir { children, .. } => {
327                let i = path[0];
328                Self::get_mut_at(&mut children[i], &path[1..])
329            }
330            Node::File { .. } => unreachable!("path must follow dirs only"),
331        }
332    }
333
334    /// Recursively copy the VFS subtree at `vfs_path` to the host directory `host_dir`.
335    /// For each Dir creates a directory; for each File writes file content.
336    ///
337    /// # Errors
338    /// Returns `VfsError::InvalidPath` if path does not exist; `VfsError::Io` on host I/O failure.
339    pub fn copy_tree_to_host(&self, vfs_path: &str, host_dir: &Path) -> Result<(), VfsError> {
340        let abs = self.resolve_to_absolute(vfs_path);
341        if self.host_root.is_some() {
342            let src = self.logical_to_host_path(&abs);
343            copy_host_path_to_host_dir(&src, host_dir)
344        } else {
345            let node = self.resolve_absolute(&abs)?;
346            copy_node_to_host(&node, host_dir)
347        }
348    }
349}