Skip to main content

pmpx_testkit/
fixture.rs

1//! A directory of files, run through the real detection.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5
6use pmpx_detect::{select, Candidate, Preferences, Presence};
7use pmpx_plugin::{Context, SelectionReason};
8
9/// One project, on disk, detected the way the host detects it.
10///
11/// The files are written into a temporary directory that lives as long as the fixture. The detection is
12/// the real one: the same scoring, the same family and plugin choice, on the markers a manifest
13/// declares -- so a fixture test fails when the plugin's own markers stop matching, which is exactly the
14/// kind of breakage a hand-built context cannot catch.
15pub 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    /// An empty project.
31    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    /// Write one file, creating its directory if it has one.
42    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    /// Declare a plugin, with the markers its manifest would carry.
52    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    /// Pin a family to a plugin, as a `.pmpx.toml` would.
66    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    /// Order families, for the cases where two of them tie.
73    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    /// The files the plugin's manifest says it wants to see.
80    ///
81    /// Only these are read into the context -- the declaration is the allowlist, so a test that asks for
82    /// something undeclared gets a context without it, exactly as the host would.
83    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    /// The project directory.
94    pub fn path(&self) -> &Path {
95        self.dir.path()
96    }
97
98    /// Run the detection and build the context the host would hand over.
99    ///
100    /// The verb and the arguments are not part of a [`Context`]: they are what the plugin is *called*
101    /// with, so a test passes them to
102    /// [`command`](pmpx_plugin::PackageManager::command) itself.
103    ///
104    /// # Panics
105    /// When the detection picks nothing -- which in a fixture test almost always means the markers a
106    /// plugin declares do not match the files the test wrote, so the panic says which files are there.
107    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    /// The files the fixture contains, for a failure message.
152    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    /// The directory, as the decision asks about it.
160    fn presence(&self) -> TestDir<'_> {
161        TestDir {
162            root: self.dir.path(),
163        }
164    }
165}
166
167/// A directory, answering the decision's one question.
168struct 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
178/// Every file under `root`, relative to it, for a failure message.
179fn 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    /// The fixture's context is the one the real detection produces: the matched file, the score, and the
205    /// declared file's contents.
206    #[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    /// A file the manifest did not declare never reaches the context, however it is written in the
224    /// fixture: the declaration is the allowlist.
225    #[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    /// A pin decides, and the context says so.
237    #[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    /// Nothing matching is a panic that says what was there, because that is the failure mode a fixture
253    /// test is written to catch.
254    #[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}