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        for (name, content) in &self.source_files {
154            let path = root.join(name);
155            if let Some(parent) = path.parent() {
156                std::fs::create_dir_all(parent).unwrap();
157            }
158            std::fs::write(path, content).unwrap();
159        }
160        if let Some(yaml) = &self.scute_config {
161            std::fs::write(root.join(".scute.yml"), yaml).unwrap();
162        }
163        for (path, child) in self.children {
164            child.setup_at(&root.join(path));
165        }
166    }
167}
168
169fn setup_cargo_project(
170    root: &Path,
171    dependencies: &[(String, String)],
172    dev_dependencies: &[(String, String)],
173    members: &[(String, TestMember)],
174) {
175    let mut toml = if members.is_empty() {
176        String::from(
177            "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
178        )
179    } else {
180        format!(
181            "[workspace]\nmembers = [{}]\n",
182            members
183                .iter()
184                .map(|(path, _)| format!("\"{path}\""))
185                .collect::<Vec<_>>()
186                .join(", ")
187        )
188    };
189
190    append_cargo_deps(&mut toml, dependencies, dev_dependencies);
191
192    std::fs::write(root.join("Cargo.toml"), toml).unwrap();
193
194    if members.is_empty() {
195        let src = root.join("src");
196        std::fs::create_dir(&src).unwrap();
197        std::fs::write(src.join("lib.rs"), "").unwrap();
198    }
199
200    for (path, member) in members {
201        setup_cargo_member(root, path, member);
202    }
203}
204
205fn setup_cargo_member(root: &Path, path: &str, member: &TestMember) {
206    let member_dir = root.join(path);
207    std::fs::create_dir_all(member_dir.join("src")).unwrap();
208    std::fs::write(member_dir.join("src/lib.rs"), "").unwrap();
209
210    let name = Path::new(path).file_name().unwrap().to_string_lossy();
211    let mut toml =
212        format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
213    append_cargo_deps(&mut toml, &member.dependencies, &member.dev_dependencies);
214    std::fs::write(member_dir.join("Cargo.toml"), toml).unwrap();
215}
216
217enum JsToolchain {
218    Npm,
219    Pnpm,
220}
221
222impl JsToolchain {
223    fn command(&self) -> &str {
224        match self {
225            Self::Npm => "npm",
226            Self::Pnpm => "pnpm",
227        }
228    }
229}
230
231fn setup_js_project(
232    root: &Path,
233    dependencies: &[(String, String)],
234    dev_dependencies: &[(String, String)],
235    members: &[(String, TestMember)],
236    toolchain: &JsToolchain,
237) {
238    let mut pkg = serde_json::Map::new();
239    pkg.insert("name".into(), "test-project".into());
240    pkg.insert("version".into(), "1.0.0".into());
241
242    if !members.is_empty() {
243        match toolchain {
244            JsToolchain::Npm => {
245                let workspace_paths: Vec<serde_json::Value> = members
246                    .iter()
247                    .map(|(path, _)| serde_json::Value::String(path.clone()))
248                    .collect();
249                pkg.insert("workspaces".into(), workspace_paths.into());
250            }
251            JsToolchain::Pnpm => {
252                let yaml = members
253                    .iter()
254                    .map(|(path, _)| format!("  - {path}"))
255                    .collect::<Vec<_>>()
256                    .join("\n");
257                std::fs::write(
258                    root.join("pnpm-workspace.yaml"),
259                    format!("packages:\n{yaml}"),
260                )
261                .unwrap();
262            }
263        }
264    }
265
266    append_js_deps(&mut pkg, dependencies, dev_dependencies);
267
268    let json = serde_json::to_string_pretty(&pkg).unwrap();
269    std::fs::write(root.join("package.json"), json).unwrap();
270
271    for (path, member) in members {
272        setup_js_member(root, path, member);
273    }
274
275    let cmd = toolchain.command();
276    let output = std::process::Command::new(cmd)
277        .args(["install"])
278        .current_dir(root)
279        .output()
280        .unwrap_or_else(|_| panic!("{cmd} must be installed to run {cmd} integration tests"));
281
282    assert!(
283        output.status.success(),
284        "{cmd} install failed: {}",
285        String::from_utf8_lossy(&output.stderr)
286    );
287}
288
289fn setup_js_member(root: &Path, path: &str, member: &TestMember) {
290    let member_dir = root.join(path);
291    std::fs::create_dir_all(&member_dir).unwrap();
292
293    let basename = Path::new(path).file_name().unwrap().to_string_lossy();
294
295    let mut pkg = serde_json::Map::new();
296    pkg.insert("name".into(), format!("@test/{basename}").into());
297    pkg.insert("version".into(), "1.0.0".into());
298
299    append_js_deps(&mut pkg, &member.dependencies, &member.dev_dependencies);
300
301    let json = serde_json::to_string_pretty(&pkg).unwrap();
302    std::fs::write(member_dir.join("package.json"), json).unwrap();
303}
304
305fn append_js_deps(
306    pkg: &mut serde_json::Map<String, serde_json::Value>,
307    dependencies: &[(String, String)],
308    dev_dependencies: &[(String, String)],
309) {
310    for (key, deps) in [
311        ("dependencies", dependencies),
312        ("devDependencies", dev_dependencies),
313    ] {
314        if !deps.is_empty() {
315            let map: serde_json::Map<String, serde_json::Value> = deps
316                .iter()
317                .map(|(n, v)| (n.clone(), serde_json::Value::String(v.clone())))
318                .collect();
319            pkg.insert(key.into(), map.into());
320        }
321    }
322}
323
324fn init_git_repo(root: &Path) {
325    std::fs::create_dir(root.join(".git")).unwrap();
326    std::fs::write(root.join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
327}
328
329fn append_cargo_deps(
330    toml: &mut String,
331    dependencies: &[(String, String)],
332    dev_dependencies: &[(String, String)],
333) {
334    use std::fmt::Write;
335
336    for (section, deps) in [
337        ("[dependencies]", dependencies),
338        ("[dev-dependencies]", dev_dependencies),
339    ] {
340        if !deps.is_empty() {
341            writeln!(toml, "\n{section}").unwrap();
342            for (name, version) in deps {
343                writeln!(toml, "{name} = \"{version}\"").unwrap();
344            }
345        }
346    }
347}