Skip to main content

xtask_todo_lib/devshell/vfs/
mod.rs

1//! Virtual filesystem for the devshell.
2
3use std::path::Path;
4
5/// Error from VFS operations (path not found, not a directory/file, or I/O).
6#[derive(Debug)]
7pub enum VfsError {
8    InvalidPath,
9    Io(std::io::Error),
10}
11
12impl std::fmt::Display for VfsError {
13    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14        match self {
15            Self::InvalidPath => f.write_str("invalid path"),
16            Self::Io(e) => write!(f, "io: {e}"),
17        }
18    }
19}
20
21impl std::error::Error for VfsError {
22    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
23        match self {
24            Self::Io(e) => Some(e),
25            Self::InvalidPath => None,
26        }
27    }
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub enum Node {
32    Dir { name: String, children: Vec<Self> },
33    File { name: String, content: Vec<u8> },
34}
35
36impl Node {
37    #[must_use]
38    pub fn name(&self) -> &str {
39        match self {
40            Self::Dir { name, .. } | Self::File { name, .. } => name,
41        }
42    }
43    #[must_use]
44    pub const fn is_dir(&self) -> bool {
45        matches!(self, Self::Dir { .. })
46    }
47    #[must_use]
48    pub const fn is_file(&self) -> bool {
49        matches!(self, Self::File { .. })
50    }
51
52    /// Returns a reference to the direct child with the given name, if any (Dir only).
53    #[must_use]
54    pub fn child(&self, name: &str) -> Option<&Self> {
55        match self {
56            Self::Dir { children, .. } => children.iter().find(|c| c.name() == name),
57            Self::File { .. } => None,
58        }
59    }
60}
61
62pub struct Vfs {
63    root: Node,
64    cwd: String,
65}
66
67impl Default for Vfs {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Vfs {
74    #[must_use]
75    pub fn new() -> Self {
76        Self {
77            root: Node::Dir {
78                name: String::new(),
79                children: vec![],
80            },
81            cwd: "/".to_string(),
82        }
83    }
84
85    /// Construct VFS from root node and cwd (used by deserialization).
86    #[must_use]
87    pub const fn from_parts(root: Node, cwd: String) -> Self {
88        Self { root, cwd }
89    }
90    #[must_use]
91    pub fn cwd(&self) -> &str {
92        &self.cwd
93    }
94    #[must_use]
95    pub const fn root(&self) -> &Node {
96        &self.root
97    }
98
99    /// Resolve an absolute path to a node. Path must be absolute (normalized). Trailing '/' is trimmed.
100    ///
101    /// # Errors
102    /// Returns `VfsError::InvalidPath` if any segment is missing.
103    pub fn resolve_absolute(&self, path: &str) -> Result<Node, VfsError> {
104        let path = path.trim_end_matches('/');
105        if path.is_empty() || path == "/" {
106            return Ok(self.root.clone());
107        }
108        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
109        let mut current = &self.root;
110        for segment in segments {
111            current = current.child(segment).ok_or(VfsError::InvalidPath)?;
112        }
113        Ok(current.clone())
114    }
115
116    /// 将任意路径(相对或绝对)归一化并解析为绝对路径字符串。
117    /// 相对路径先与 cwd 拼接再归一化,这样 ".." 能正确退到上级目录。
118    #[must_use]
119    pub fn resolve_to_absolute(&self, path: &str) -> String {
120        let path = path.trim();
121        let path_normalized = normalize_path(path);
122        // 绝对路径:直接归一化后返回
123        if path_normalized.starts_with('/') {
124            return path_normalized;
125        }
126        if path_normalized == "/" {
127            return self.cwd.clone();
128        }
129        // 相对路径:先与 cwd 拼接再归一化,避免单独 ".." 被归一成 "." 导致无法退回根目录
130        let base = self.cwd.trim_end_matches('/');
131        let p = path.trim_start_matches('/');
132        let combined = if base.is_empty() {
133            format!("/{p}")
134        } else {
135            format!("{base}/{p}")
136        };
137        let result = normalize_path(&combined);
138        if result.is_empty() || result == "." {
139            "/".to_string()
140        } else if result.starts_with('/') {
141            result
142        } else {
143            format!("/{result}")
144        }
145    }
146
147    /// Create directory at path (`mkdir_all` style). Creates any missing parent directories.
148    ///
149    /// # Errors
150    /// Returns `VfsError::InvalidPath` if any path component exists and is not a directory.
151    pub fn mkdir(&mut self, path: &str) -> Result<(), VfsError> {
152        let abs = self.resolve_to_absolute(path);
153        let segments: Vec<&str> = abs.split('/').filter(|s| !s.is_empty()).collect();
154        if segments.is_empty() {
155            return Ok(());
156        }
157        let mut indices: Vec<usize> = vec![];
158        for segment in segments {
159            let current = Self::get_mut_at(&mut self.root, &indices);
160            match current {
161                Node::Dir { children, .. } => {
162                    let pos = children.iter().position(|c| c.name() == segment);
163                    if let Some(i) = pos {
164                        if !children[i].is_dir() {
165                            return Err(VfsError::InvalidPath);
166                        }
167                        indices.push(i);
168                    } else {
169                        children.push(Node::Dir {
170                            name: segment.to_string(),
171                            children: vec![],
172                        });
173                        indices.push(children.len() - 1);
174                    }
175                }
176                Node::File { .. } => return Err(VfsError::InvalidPath),
177            }
178        }
179        Ok(())
180    }
181
182    /// Create or overwrite a file at path. Parent directory must exist and be a directory.
183    ///
184    /// # Errors
185    /// Returns `VfsError::InvalidPath` if parent path does not exist or a component is not a directory.
186    pub fn write_file(&mut self, path: &str, content: &[u8]) -> Result<(), VfsError> {
187        let abs = self.resolve_to_absolute(path);
188        let segments: Vec<&str> = abs.split('/').filter(|s| !s.is_empty()).collect();
189        let (parent_segments, file_name) = match segments.split_last() {
190            Some((last, rest)) => (rest, *last),
191            None => return Err(VfsError::InvalidPath), // path is "/" or empty
192        };
193        let mut indices: Vec<usize> = vec![];
194        for segment in parent_segments {
195            let current = Self::get_mut_at(&mut self.root, &indices);
196            match current {
197                Node::Dir { children, .. } => {
198                    let pos = children.iter().position(|c| c.name() == *segment);
199                    match pos {
200                        Some(i) => {
201                            if !children[i].is_dir() {
202                                return Err(VfsError::InvalidPath);
203                            }
204                            indices.push(i);
205                        }
206                        None => return Err(VfsError::InvalidPath), // parent path does not exist
207                    }
208                }
209                Node::File { .. } => return Err(VfsError::InvalidPath),
210            }
211        }
212        let parent = Self::get_mut_at(&mut self.root, &indices);
213        match parent {
214            Node::Dir { children, .. } => {
215                let pos = children.iter().position(|c| c.name() == file_name);
216                let node = Node::File {
217                    name: file_name.to_string(),
218                    content: content.to_vec(),
219                };
220                match pos {
221                    Some(i) => children[i] = node,
222                    None => children.push(node),
223                }
224                Ok(())
225            }
226            Node::File { .. } => Err(VfsError::InvalidPath),
227        }
228    }
229
230    /// Create an empty file at path (touch). Parent directory must exist.
231    ///
232    /// # Errors
233    /// Same as `write_file`.
234    pub fn touch(&mut self, path: &str) -> Result<(), VfsError> {
235        self.write_file(path, &[])
236    }
237
238    /// Read file content at path.
239    ///
240    /// # Errors
241    /// Returns `VfsError::InvalidPath` if path does not exist or is not a file.
242    pub fn read_file(&self, path: &str) -> Result<Vec<u8>, VfsError> {
243        let abs = self.resolve_to_absolute(path);
244        let n = self.resolve_absolute(&abs)?;
245        match n {
246            Node::File { content, .. } => Ok(content),
247            Node::Dir { .. } => Err(VfsError::InvalidPath),
248        }
249    }
250
251    /// List directory entries at path.
252    ///
253    /// # Errors
254    /// Returns `VfsError::InvalidPath` if path does not exist or is not a directory.
255    pub fn list_dir(&self, path: &str) -> Result<Vec<String>, VfsError> {
256        let abs = self.resolve_to_absolute(path);
257        let n = self.resolve_absolute(&abs)?;
258        match n {
259            Node::Dir { children, .. } => {
260                Ok(children.iter().map(|c| c.name().to_string()).collect())
261            }
262            Node::File { .. } => Err(VfsError::InvalidPath),
263        }
264    }
265
266    /// Set current working directory to path.
267    ///
268    /// # Errors
269    /// Returns `VfsError::InvalidPath` if path does not exist or is not a directory.
270    pub fn set_cwd(&mut self, path: &str) -> Result<(), VfsError> {
271        let abs = self.resolve_to_absolute(path);
272        let n = self.resolve_absolute(&abs)?;
273        if !n.is_dir() {
274            return Err(VfsError::InvalidPath);
275        }
276        self.cwd = if abs == "/" { "/".to_string() } else { abs };
277        Ok(())
278    }
279
280    /// Mutable reference to node at path of child indices from the given node.
281    fn get_mut_at<'a>(node: &'a mut Node, path: &[usize]) -> &'a mut Node {
282        if path.is_empty() {
283            return node;
284        }
285        match node {
286            Node::Dir { children, .. } => {
287                let i = path[0];
288                Self::get_mut_at(&mut children[i], &path[1..])
289            }
290            Node::File { .. } => unreachable!("path must follow dirs only"),
291        }
292    }
293
294    /// Recursively copy the VFS subtree at `vfs_path` to the host directory `host_dir`.
295    /// For each Dir creates a directory; for each File writes file content.
296    ///
297    /// # Errors
298    /// Returns `VfsError::InvalidPath` if path does not exist; `VfsError::Io` on host I/O failure.
299    pub fn copy_tree_to_host(&self, vfs_path: &str, host_dir: &Path) -> Result<(), VfsError> {
300        let abs = self.resolve_to_absolute(vfs_path);
301        let node = self.resolve_absolute(&abs)?;
302        copy_node_to_host(&node, host_dir)
303    }
304}
305
306/// Returns true if the name is safe to use as a single path component (no .. or separators).
307fn is_safe_component(name: &str) -> bool {
308    !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
309}
310
311/// Recursively copy a VFS node to the host path. Creates dirs and writes file contents.
312fn copy_node_to_host(node: &Node, host_path: &Path) -> Result<(), VfsError> {
313    match node {
314        Node::Dir { name, children } => {
315            let dir_path = if name.is_empty() {
316                host_path.to_path_buf()
317            } else {
318                if !is_safe_component(name) {
319                    return Err(VfsError::InvalidPath);
320                }
321                host_path.join(name)
322            };
323            std::fs::create_dir_all(&dir_path).map_err(VfsError::Io)?;
324            for child in children {
325                copy_node_to_host(child, &dir_path)?;
326            }
327            Ok(())
328        }
329        Node::File { name, content } => {
330            if !is_safe_component(name) {
331                return Err(VfsError::InvalidPath);
332            }
333            let file_path = host_path.join(name);
334            std::fs::write(&file_path, content).map_err(VfsError::Io)?;
335            Ok(())
336        }
337    }
338}
339
340/// Normalize a path to Unix style: backslash -> slash, strip Windows drive,
341/// resolve . and .., preserve absolute vs relative.
342#[must_use]
343pub fn normalize_path(input: &str) -> String {
344    let s = input.replace('\\', "/");
345
346    // Strip Windows drive letter prefix (e.g. C:) and treat as absolute.
347    let (rest, absolute) = if s.len() >= 2
348        && s.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
349        && s.chars().nth(1) == Some(':')
350    {
351        (&s[2..], true)
352    } else {
353        (s.as_str(), s.starts_with('/'))
354    };
355
356    let rest = rest.trim_start_matches('/');
357    let mut out: Vec<&str> = Vec::new();
358    for p in rest.split('/') {
359        match p {
360            "" | "." => {}
361            ".." => {
362                out.pop();
363            }
364            _ => out.push(p),
365        }
366    }
367
368    if absolute {
369        "/".to_string() + &out.join("/")
370    } else if out.is_empty() {
371        ".".to_string()
372    } else {
373        out.join("/")
374    }
375}
376
377#[cfg(test)]
378mod tests;