Skip to main content

dev_fixtures/
tree.rs

1//! File-tree builders.
2//!
3//! [`FileTree`] is a more general builder than [`TempProject`]: it
4//! materializes a tree under any caller-chosen root, supports
5//! Rust-workspace shortcuts, and can create symlinks where the
6//! platform supports them.
7//!
8//! [`TempProject`]: crate::TempProject
9
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13
14/// A staged file or directory entry.
15enum Entry {
16    File(PathBuf, Vec<u8>),
17    Dir(PathBuf),
18    #[cfg(unix)]
19    Symlink {
20        link: PathBuf,
21        target: PathBuf,
22    },
23}
24
25/// Builder for a tree of files and directories under a chosen root.
26///
27/// # Example
28///
29/// ```
30/// use dev_fixtures::tree::FileTree;
31/// let dir = tempfile::tempdir().unwrap();
32/// FileTree::new(dir.path())
33///     .file("README.md", "hello")
34///     .dir("src")
35///     .file("src/lib.rs", "pub fn x() {}")
36///     .build()
37///     .unwrap();
38/// assert!(dir.path().join("src/lib.rs").exists());
39/// ```
40pub struct FileTree {
41    root: PathBuf,
42    entries: Vec<Entry>,
43}
44
45impl FileTree {
46    /// Build a tree rooted at `root`. The directory MUST exist on
47    /// build; the builder does not create it.
48    pub fn new(root: impl Into<PathBuf>) -> Self {
49        Self {
50            root: root.into(),
51            entries: Vec::new(),
52        }
53    }
54
55    /// Stage a UTF-8 text file at `relative_path`.
56    pub fn file(mut self, relative_path: impl Into<PathBuf>, contents: impl Into<String>) -> Self {
57        self.entries.push(Entry::File(
58            relative_path.into(),
59            contents.into().into_bytes(),
60        ));
61        self
62    }
63
64    /// Stage a binary file at `relative_path`.
65    pub fn bytes(
66        mut self,
67        relative_path: impl Into<PathBuf>,
68        contents: impl Into<Vec<u8>>,
69    ) -> Self {
70        self.entries
71            .push(Entry::File(relative_path.into(), contents.into()));
72        self
73    }
74
75    /// Stage an empty directory at `relative_path`.
76    pub fn dir(mut self, relative_path: impl Into<PathBuf>) -> Self {
77        self.entries.push(Entry::Dir(relative_path.into()));
78        self
79    }
80
81    /// Stage a symlink. Available on Unix only; a no-op on Windows.
82    ///
83    /// `link` is the relative path of the symlink itself; `target` is
84    /// the path the symlink points to.
85    #[cfg_attr(not(unix), allow(unused_mut, unused_variables))]
86    pub fn symlink(mut self, link: impl Into<PathBuf>, target: impl Into<PathBuf>) -> Self {
87        #[cfg(unix)]
88        {
89            self.entries.push(Entry::Symlink {
90                link: link.into(),
91                target: target.into(),
92            });
93        }
94        // On Windows, silently no-op (symlinks need admin privilege).
95        self
96    }
97
98    /// Materialize the tree on disk.
99    pub fn build(self) -> io::Result<()> {
100        for e in &self.entries {
101            match e {
102                Entry::File(rel, bytes) => {
103                    let target = self.root.join(rel);
104                    if let Some(parent) = target.parent() {
105                        fs::create_dir_all(parent)?;
106                    }
107                    fs::write(&target, bytes)?;
108                }
109                Entry::Dir(rel) => {
110                    fs::create_dir_all(self.root.join(rel))?;
111                }
112                #[cfg(unix)]
113                Entry::Symlink { link, target } => {
114                    let link_path = self.root.join(link);
115                    if let Some(parent) = link_path.parent() {
116                        fs::create_dir_all(parent)?;
117                    }
118                    std::os::unix::fs::symlink(target, &link_path)?;
119                }
120            }
121        }
122        Ok(())
123    }
124}
125
126/// Convenience: build a minimal Rust crate layout under `root`.
127///
128/// Creates `Cargo.toml` and `src/lib.rs`. Returns the relative paths
129/// of the files written.
130///
131/// # Example
132///
133/// ```
134/// use dev_fixtures::tree::rust_crate;
135/// let dir = tempfile::tempdir().unwrap();
136/// rust_crate(dir.path(), "sample", "0.1.0").unwrap();
137/// assert!(dir.path().join("Cargo.toml").exists());
138/// assert!(dir.path().join("src/lib.rs").exists());
139/// ```
140pub fn rust_crate(root: &Path, name: &str, version: &str) -> io::Result<()> {
141    let cargo = format!(
142        "[package]\nname = \"{}\"\nversion = \"{}\"\nedition = \"2021\"\n\n[lib]\npath = \"src/lib.rs\"\n",
143        name, version
144    );
145    FileTree::new(root)
146        .file("Cargo.toml", cargo)
147        .file("src/lib.rs", "//! Sample crate.\n")
148        .build()
149}
150
151/// Convenience: build a multi-crate Rust workspace under `root`.
152///
153/// Creates a top-level `Cargo.toml` with `[workspace]` and a member
154/// crate per name in `members`. Each member gets its own
155/// `Cargo.toml` and `src/lib.rs`.
156///
157/// # Example
158///
159/// ```
160/// use dev_fixtures::tree::rust_workspace;
161/// let dir = tempfile::tempdir().unwrap();
162/// rust_workspace(dir.path(), &["a", "b"]).unwrap();
163/// assert!(dir.path().join("a/Cargo.toml").exists());
164/// assert!(dir.path().join("b/Cargo.toml").exists());
165/// ```
166pub fn rust_workspace(root: &Path, members: &[&str]) -> io::Result<()> {
167    let members_lines: String = members
168        .iter()
169        .map(|m| format!("    \"{}\",\n", m))
170        .collect();
171    let workspace_toml = format!(
172        "[workspace]\nresolver = \"2\"\nmembers = [\n{}]\n",
173        members_lines
174    );
175    FileTree::new(root)
176        .file("Cargo.toml", workspace_toml)
177        .build()?;
178    for m in members {
179        rust_crate(&root.join(m), m, "0.0.0")?;
180    }
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn build_basic_tree() {
190        let dir = tempfile::tempdir().unwrap();
191        FileTree::new(dir.path())
192            .file("a.txt", "hello")
193            .dir("empty")
194            .file("nested/b.txt", "world")
195            .build()
196            .unwrap();
197        assert!(dir.path().join("a.txt").exists());
198        assert!(dir.path().join("empty").is_dir());
199        assert_eq!(
200            fs::read_to_string(dir.path().join("nested/b.txt")).unwrap(),
201            "world"
202        );
203    }
204
205    #[test]
206    fn rust_crate_layout() {
207        let dir = tempfile::tempdir().unwrap();
208        rust_crate(dir.path(), "sample", "0.1.0").unwrap();
209        let cargo = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
210        assert!(cargo.contains("name = \"sample\""));
211        assert!(cargo.contains("version = \"0.1.0\""));
212        assert!(dir.path().join("src/lib.rs").exists());
213    }
214
215    #[test]
216    fn rust_workspace_layout() {
217        let dir = tempfile::tempdir().unwrap();
218        rust_workspace(dir.path(), &["alpha", "beta"]).unwrap();
219        let ws = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
220        assert!(ws.contains("\"alpha\""));
221        assert!(ws.contains("\"beta\""));
222        assert!(dir.path().join("alpha/Cargo.toml").exists());
223        assert!(dir.path().join("beta/src/lib.rs").exists());
224    }
225
226    #[cfg(unix)]
227    #[test]
228    fn symlink_unix() {
229        let dir = tempfile::tempdir().unwrap();
230        FileTree::new(dir.path())
231            .file("real.txt", "data")
232            .symlink("link.txt", "real.txt")
233            .build()
234            .unwrap();
235        assert!(dir.path().join("link.txt").exists());
236    }
237
238    #[cfg(windows)]
239    #[test]
240    fn symlink_no_op_on_windows() {
241        let dir = tempfile::tempdir().unwrap();
242        // Should succeed without creating anything.
243        FileTree::new(dir.path())
244            .symlink("link.txt", "real.txt")
245            .build()
246            .unwrap();
247        assert!(!dir.path().join("link.txt").exists());
248    }
249}