Skip to main content

scute_test_utils/
project.rs

1use std::path::Path;
2
3use tempfile::TempDir;
4
5enum ProjectKind {
6    Empty,
7    Cargo,
8    Npm,
9    Pnpm,
10}
11
12/// A throwaway project directory for integration tests.
13///
14/// Use [`cargo()`](Self::cargo), [`npm()`](Self::npm), or
15/// [`empty()`](Self::empty) to start, chain builder methods, then call
16/// [`build()`](Self::build) to materialize the directory. The returned
17/// [`TempDir`] is cleaned up on drop.
18pub struct TestProject {
19    kind: ProjectKind,
20    dependencies: Vec<(String, String)>,
21    dev_dependencies: Vec<(String, String)>,
22    members: Vec<(String, TestMember)>,
23    children: Vec<(String, TestProject)>,
24    source_files: Vec<(String, String)>,
25    scute_config: Option<String>,
26}
27
28/// A workspace member inside a [`TestProject`]. Created via [`TestProject::member`].
29pub struct TestMember {
30    dependencies: Vec<(String, String)>,
31    dev_dependencies: Vec<(String, String)>,
32}
33
34impl TestMember {
35    pub fn dependency(mut self, name: &str, version: &str) -> Self {
36        self.dependencies.push((name.into(), version.into()));
37        self
38    }
39
40    pub fn dev_dependency(mut self, name: &str, version: &str) -> Self {
41        self.dev_dependencies.push((name.into(), version.into()));
42        self
43    }
44}
45
46impl TestProject {
47    fn new(kind: ProjectKind) -> Self {
48        Self {
49            kind,
50            dependencies: Vec::new(),
51            dev_dependencies: Vec::new(),
52            members: Vec::new(),
53            children: Vec::new(),
54            source_files: Vec::new(),
55            scute_config: None,
56        }
57    }
58
59    /// A bare directory with no project scaffolding.
60    pub fn empty() -> Self {
61        Self::new(ProjectKind::Empty)
62    }
63
64    /// A minimal npm project. Runs `npm install` on build to resolve dependencies.
65    pub fn npm() -> Self {
66        Self::new(ProjectKind::Npm)
67    }
68
69    /// A minimal pnpm project. Runs `pnpm install` on build to resolve dependencies.
70    pub fn pnpm() -> Self {
71        Self::new(ProjectKind::Pnpm)
72    }
73
74    /// A minimal Cargo project with `Cargo.toml` and empty `src/lib.rs`.
75    pub fn cargo() -> Self {
76        Self::new(ProjectKind::Cargo)
77    }
78
79    pub fn dependency(mut self, name: &str, version: &str) -> Self {
80        self.dependencies.push((name.into(), version.into()));
81        self
82    }
83
84    pub fn dev_dependency(mut self, name: &str, version: &str) -> Self {
85        self.dev_dependencies.push((name.into(), version.into()));
86        self
87    }
88
89    /// Add a workspace member at the given relative path. The closure receives
90    /// an empty [`TestMember`] to configure with its own dependencies.
91    pub fn member(mut self, path: &str, build: impl FnOnce(TestMember) -> TestMember) -> Self {
92        let member = build(TestMember {
93            dependencies: Vec::new(),
94            dev_dependencies: Vec::new(),
95        });
96        self.members.push((path.into(), member));
97        self
98    }
99
100    pub fn source_file(mut self, name: &str, content: &str) -> Self {
101        self.source_files.push((name.into(), content.into()));
102        self
103    }
104
105    pub fn scute_config(mut self, yaml: &str) -> Self {
106        self.scute_config = Some(yaml.into());
107        self
108    }
109
110    /// Nest a child project at the given relative path inside this project.
111    pub fn nested(mut self, path: &str, child: TestProject) -> Self {
112        self.children.push((path.into(), child));
113        self
114    }
115
116    /// Materialize the project into a temporary directory.
117    ///
118    /// The directory is initialized as a git repo with a `.gitignore`
119    /// that excludes `node_modules/` and `target/`, matching what any
120    /// real project would have.
121    pub fn build(self) -> TempDir {
122        let dir = TempDir::new().unwrap();
123        init_git_repo(dir.path());
124        self.setup_at(dir.path());
125        dir
126    }
127
128    fn setup_at(self, root: &Path) {
129        std::fs::create_dir_all(root).unwrap();
130        match self.kind {
131            ProjectKind::Cargo => setup_cargo_project(
132                root,
133                &self.dependencies,
134                &self.dev_dependencies,
135                &self.members,
136            ),
137            ProjectKind::Npm => setup_js_project(
138                root,
139                &self.dependencies,
140                &self.dev_dependencies,
141                &self.members,
142                &JsToolchain::Npm,
143            ),
144            ProjectKind::Pnpm => setup_js_project(
145                root,
146                &self.dependencies,
147                &self.dev_dependencies,
148                &self.members,
149                &JsToolchain::Pnpm,
150            ),
151            ProjectKind::Empty => {}
152        }
153        write_source_files(root, &self.source_files);
154        if let Some(yaml) = &self.scute_config {
155            std::fs::write(root.join(".scute.yml"), yaml).unwrap();
156        }
157        for (path, child) in self.children {
158            child.setup_at(&root.join(path));
159        }
160    }
161}
162
163fn setup_cargo_project(
164    root: &Path,
165    dependencies: &[(String, String)],
166    dev_dependencies: &[(String, String)],
167    members: &[(String, TestMember)],
168) {
169    let mut toml = if members.is_empty() {
170        String::from(
171            "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
172        )
173    } else {
174        format!(
175            "[workspace]\nmembers = [{}]\n",
176            members
177                .iter()
178                .map(|(path, _)| format!("\"{path}\""))
179                .collect::<Vec<_>>()
180                .join(", ")
181        )
182    };
183
184    append_cargo_deps(&mut toml, dependencies, dev_dependencies);
185
186    std::fs::write(root.join("Cargo.toml"), toml).unwrap();
187
188    if members.is_empty() {
189        let src = root.join("src");
190        std::fs::create_dir(&src).unwrap();
191        std::fs::write(src.join("lib.rs"), "").unwrap();
192    }
193
194    for (path, member) in members {
195        setup_cargo_member(root, path, member);
196    }
197}
198
199fn setup_cargo_member(root: &Path, path: &str, member: &TestMember) {
200    let member_dir = root.join(path);
201    std::fs::create_dir_all(member_dir.join("src")).unwrap();
202    std::fs::write(member_dir.join("src/lib.rs"), "").unwrap();
203
204    let name = Path::new(path).file_name().unwrap().to_string_lossy();
205    let mut toml =
206        format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
207    append_cargo_deps(&mut toml, &member.dependencies, &member.dev_dependencies);
208    std::fs::write(member_dir.join("Cargo.toml"), toml).unwrap();
209}
210
211enum JsToolchain {
212    Npm,
213    Pnpm,
214}
215
216impl JsToolchain {
217    fn command(&self) -> &str {
218        match self {
219            Self::Npm => "npm",
220            Self::Pnpm => "pnpm",
221        }
222    }
223}
224
225fn setup_js_project(
226    root: &Path,
227    dependencies: &[(String, String)],
228    dev_dependencies: &[(String, String)],
229    members: &[(String, TestMember)],
230    toolchain: &JsToolchain,
231) {
232    let mut pkg = serde_json::Map::new();
233    pkg.insert("name".into(), "test-project".into());
234    pkg.insert("version".into(), "1.0.0".into());
235
236    if !members.is_empty() {
237        match toolchain {
238            JsToolchain::Npm => {
239                let workspace_paths: Vec<serde_json::Value> = members
240                    .iter()
241                    .map(|(path, _)| serde_json::Value::String(path.clone()))
242                    .collect();
243                pkg.insert("workspaces".into(), workspace_paths.into());
244            }
245            JsToolchain::Pnpm => {
246                let yaml = members
247                    .iter()
248                    .map(|(path, _)| format!("  - {path}"))
249                    .collect::<Vec<_>>()
250                    .join("\n");
251                std::fs::write(
252                    root.join("pnpm-workspace.yaml"),
253                    format!("packages:\n{yaml}"),
254                )
255                .unwrap();
256            }
257        }
258    }
259
260    append_js_deps(&mut pkg, dependencies, dev_dependencies);
261
262    let json = serde_json::to_string_pretty(&pkg).unwrap();
263    std::fs::write(root.join("package.json"), json).unwrap();
264
265    for (path, member) in members {
266        setup_js_member(root, path, member);
267    }
268
269    let cmd = toolchain.command();
270    let output = std::process::Command::new(cmd)
271        .args(["install"])
272        .current_dir(root)
273        .output()
274        .unwrap_or_else(|_| panic!("{cmd} must be installed to run {cmd} integration tests"));
275
276    assert!(
277        output.status.success(),
278        "{cmd} install failed: {}",
279        String::from_utf8_lossy(&output.stderr)
280    );
281}
282
283fn setup_js_member(root: &Path, path: &str, member: &TestMember) {
284    let member_dir = root.join(path);
285    std::fs::create_dir_all(&member_dir).unwrap();
286
287    let basename = Path::new(path).file_name().unwrap().to_string_lossy();
288
289    let mut pkg = serde_json::Map::new();
290    pkg.insert("name".into(), format!("@test/{basename}").into());
291    pkg.insert("version".into(), "1.0.0".into());
292
293    append_js_deps(&mut pkg, &member.dependencies, &member.dev_dependencies);
294
295    let json = serde_json::to_string_pretty(&pkg).unwrap();
296    std::fs::write(member_dir.join("package.json"), json).unwrap();
297}
298
299fn append_js_deps(
300    pkg: &mut serde_json::Map<String, serde_json::Value>,
301    dependencies: &[(String, String)],
302    dev_dependencies: &[(String, String)],
303) {
304    for (key, deps) in [
305        ("dependencies", dependencies),
306        ("devDependencies", dev_dependencies),
307    ] {
308        if !deps.is_empty() {
309            let map: serde_json::Map<String, serde_json::Value> = deps
310                .iter()
311                .map(|(n, v)| (n.clone(), serde_json::Value::String(v.clone())))
312                .collect();
313            pkg.insert(key.into(), map.into());
314        }
315    }
316}
317
318fn write_source_files(root: &Path, files: &[(String, String)]) {
319    for (name, content) in files {
320        let path = root.join(name);
321        if let Some(parent) = path.parent() {
322            std::fs::create_dir_all(parent).unwrap();
323        }
324        std::fs::write(path, content).unwrap();
325    }
326}
327
328fn init_git_repo(root: &Path) {
329    std::fs::create_dir(root.join(".git")).unwrap();
330    std::fs::write(root.join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
331}
332
333fn append_cargo_deps(
334    toml: &mut String,
335    dependencies: &[(String, String)],
336    dev_dependencies: &[(String, String)],
337) {
338    use std::fmt::Write;
339
340    for (section, deps) in [
341        ("[dependencies]", dependencies),
342        ("[dev-dependencies]", dev_dependencies),
343    ]
344    .into_iter()
345    .filter(|(_, deps)| !deps.is_empty())
346    {
347        writeln!(toml, "\n{section}").unwrap();
348        for (name, version) in deps {
349            writeln!(toml, "{name} = \"{version}\"").unwrap();
350        }
351    }
352}