1use std::collections::BTreeMap;
4use std::path::Path;
5
6use pmpx_detect::{select, Candidate, Preferences, Presence};
7use pmpx_plugin::{Context, SelectionReason};
8
9pub struct Fixture {
16 dir: tempfile::TempDir,
17 candidates: Vec<Candidate>,
18 pins: BTreeMap<String, String>,
19 declares: Vec<String>,
20 family_priority: Vec<String>,
21}
22
23impl Default for Fixture {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29impl Fixture {
30 pub fn new() -> Self {
32 Self {
33 dir: tempfile::tempdir().expect("should be able to create a temporary directory"),
34 candidates: Vec::new(),
35 pins: BTreeMap::new(),
36 declares: Vec::new(),
37 family_priority: Vec::new(),
38 }
39 }
40
41 pub fn file(self, name: &str, contents: &str) -> Self {
43 let path = self.dir.path().join(name);
44 if let Some(parent) = path.parent() {
45 std::fs::create_dir_all(parent).expect("should be able to create the file's directory");
46 }
47 std::fs::write(path, contents).expect("should be able to write the file");
48 self
49 }
50
51 pub fn plugin(self, name: &str, family: &str, strong: &[&str], weak: &[&str]) -> Self {
53 let mut fixture = self;
54 fixture.candidates.push(Candidate {
55 crate_name: format!("pmpx-plugin-{name}"),
56 name: name.to_string(),
57 family: family.to_string(),
58 strong: strong.iter().map(|s| s.to_string()).collect(),
59 weak: weak.iter().map(|s| s.to_string()).collect(),
60 problem: None,
61 });
62 fixture
63 }
64
65 pub fn pin(self, family: &str, plugin: &str) -> Self {
67 let mut fixture = self;
68 fixture.pins.insert(family.to_string(), plugin.to_string());
69 fixture
70 }
71
72 pub fn prefer(self, families: &[&str]) -> Self {
74 let mut fixture = self;
75 fixture.family_priority = families.iter().map(|s| s.to_string()).collect();
76 fixture
77 }
78
79 pub fn declares<I, S>(self, names: I) -> Self
84 where
85 I: IntoIterator<Item = S>,
86 S: Into<String>,
87 {
88 let mut fixture = self;
89 fixture.declares = names.into_iter().map(Into::into).collect();
90 fixture
91 }
92
93 pub fn path(&self) -> &Path {
95 self.dir.path()
96 }
97
98 pub fn context(&self) -> Context<'static> {
108 let selection = select(
109 &self.candidates,
110 &self.presence(),
111 &self.pins,
112 &Preferences {
113 family_priority: self.family_priority.clone(),
114 priority: Vec::new(),
115 },
116 None,
117 )
118 .unwrap_or_else(|failure| {
119 panic!(
120 "the fixture detected nothing: {}\nfiles in {}:\n{}",
121 failure.message(),
122 self.dir.path().display(),
123 self.listing()
124 )
125 });
126
127 let mut builder = Context::builder()
128 .project_root(self.dir.path())
129 .start_dir(self.dir.path())
130 .matched(selection.matched.iter().cloned())
131 .reason(match selection.reason {
132 pmpx_detect::Reason::Scored => SelectionReason::Scored,
133 pmpx_detect::Reason::Pinned => SelectionReason::Pinned,
134 pmpx_detect::Reason::Explicit => SelectionReason::Explicit,
135 })
136 .score(selection.score);
137
138 for (family, plugin) in &self.pins {
139 builder = builder.pin(family, plugin);
140 }
141
142 for name in &self.declares {
143 if let Ok(contents) = std::fs::read(self.dir.path().join(name)) {
144 builder = builder.file(name, contents);
145 }
146 }
147
148 builder.build()
149 }
150
151 fn listing(&self) -> String {
153 let mut names = Vec::new();
154 collect(self.dir.path(), self.dir.path(), &mut names);
155 names.sort();
156 names.join("\n")
157 }
158
159 fn presence(&self) -> TestDir<'_> {
161 TestDir {
162 root: self.dir.path(),
163 }
164 }
165}
166
167struct TestDir<'a> {
169 root: &'a Path,
170}
171
172impl Presence for TestDir<'_> {
173 fn has(&self, relative: &str) -> bool {
174 self.root.join(relative).exists()
175 }
176}
177
178fn collect(root: &Path, dir: &Path, out: &mut Vec<String>) {
180 let Ok(entries) = std::fs::read_dir(dir) else {
181 return;
182 };
183 for entry in entries.flatten() {
184 let path = entry.path();
185 if path.is_dir() {
186 collect(root, &path, out);
187 } else if let Ok(relative) = path.strip_prefix(root) {
188 out.push(relative.display().to_string());
189 }
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn plugin_fixture() -> Fixture {
198 Fixture::new()
199 .plugin("pnpm", "node", &["pnpm-lock.yaml"], &["package.json"])
200 .plugin("cargo", "rust", &["Cargo.lock"], &["Cargo.toml"])
201 .declares(["package.json"])
202 }
203
204 #[test]
207 fn the_fixture_runs_the_real_detection() {
208 let fixture = plugin_fixture()
209 .file("pnpm-lock.yaml", "")
210 .file("package.json", "{\"name\":\"x\"}");
211
212 let context = fixture.context();
213
214 assert!(context.has_matched("pnpm-lock.yaml"));
215 assert_eq!(context.reason, SelectionReason::Scored);
216 assert_eq!(context.score, 110, "a lockfile plus a manifest");
217 assert_eq!(
218 context.file_str("package.json").as_deref(),
219 Some("{\"name\":\"x\"}")
220 );
221 }
222
223 #[test]
226 fn an_undeclared_file_is_not_handed_over() {
227 let fixture = plugin_fixture()
228 .file("pnpm-lock.yaml", "")
229 .file("extra.toml", "x = 1");
230
231 let context = fixture.context();
232
233 assert!(context.file("extra.toml").is_none());
234 }
235
236 #[test]
238 fn a_pin_is_reported_as_the_reason() {
239 let fixture = Fixture::new()
240 .plugin("pnpm", "node", &["pnpm-lock.yaml"], &["package.json"])
241 .plugin("cargo", "rust", &[], &["Cargo.toml"])
242 .pin("rust", "cargo")
243 .file("package.json", "{}")
244 .file("Cargo.toml", "");
245
246 let context = fixture.context();
247
248 assert_eq!(context.reason, SelectionReason::Pinned);
249 assert_eq!(context.pinned_for("rust"), Some("cargo"));
250 }
251
252 #[test]
255 #[should_panic(expected = "the fixture detected nothing")]
256 fn nothing_matching_panics_with_the_listing() {
257 plugin_fixture().file("unrelated.txt", "").context();
258 }
259}