Skip to main content

microvms_app/control/
project.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Which manifest+lockfile pair a project directory holds (#74, #264).
3//!
4//! The rule is here and the filesystem read is `microvms_edges::control::project`, the split
5//! [`super::context`] makes: this decides from which names are present, and the edge reads the
6//! two files the decision names. Every surface's project option (the CLI's `build --project`,
7//! the bindings' `project_dir`) goes through the one rule, so each refusal reads the same.
8
9use super::artifact::Ecosystem;
10use crate::error::{Error, ErrorKind};
11
12/// The one ecosystem whose manifest and lockfile are both in `dir`, as `present` reports each
13/// file name, or the refusal naming the remedy.
14///
15/// Exactly one ecosystem, and both halves of its pair, because each miss has a different
16/// remedy and the refusal names it:
17///
18/// * **No pair at all**: the directory is not a project this feature understands, and the
19///   message lists all three pairs it looked for.
20/// * **A manifest without its lockfile**: the environment layer is keyed on the lockfile
21///   (#74), so there is nothing to key on, and the message names the command that writes one.
22/// * **A lockfile without its manifest**: the install step reads both, so the layer could
23///   never build. Most likely a partial copy.
24/// * **Two ecosystems at once**: one image bakes one layer, and nothing says which, so the
25///   refusal names both rather than picking one silently.
26///
27/// `dir` is the directory as the caller named it, for the messages.
28pub fn project_ecosystem(dir: &str, present: impl Fn(&str) -> bool) -> Result<Ecosystem, Error> {
29    let mut found = Vec::new();
30    for ecosystem in Ecosystem::ALL {
31        match (
32            present(ecosystem.manifest_name()),
33            present(ecosystem.lockfile_name()),
34        ) {
35            (true, true) => found.push(ecosystem),
36            (true, false) => {
37                return Err(Error::new(
38                    ErrorKind::Precondition,
39                    format!(
40                        "{dir} has {} but no {}: the environment layer is keyed on the \
41                         lockfile, so there is nothing to key on. Write one with `{}` and \
42                         rebuild.",
43                        ecosystem.manifest_name(),
44                        ecosystem.lockfile_name(),
45                        lock_command(ecosystem),
46                    ),
47                ));
48            }
49            (false, true) => {
50                return Err(Error::new(
51                    ErrorKind::Precondition,
52                    format!(
53                        "{dir} has {} but no {}: the install step reads both, so the layer \
54                         could never build. This usually means a partial copy of the project.",
55                        ecosystem.lockfile_name(),
56                        ecosystem.manifest_name(),
57                    ),
58                ));
59            }
60            (false, false) => {}
61        }
62    }
63    match found.as_slice() {
64        [ecosystem] => Ok(*ecosystem),
65        [] => Err(Error::new(
66            ErrorKind::Precondition,
67            format!(
68                "{dir} has no dependency files a project build understands. It looks for one \
69                 manifest+lockfile pair: pyproject.toml+uv.lock, \
70                 package.json+package-lock.json, or Cargo.toml+Cargo.lock."
71            ),
72        )),
73        several => Err(Error::new(
74            ErrorKind::Precondition,
75            format!(
76                "{dir} has dependency files for more than one ecosystem ({}), and one image \
77                 bakes one environment layer. Name the directory that owns the layer you want \
78                 baked.",
79                several
80                    .iter()
81                    .map(|ecosystem| ecosystem.lockfile_name())
82                    .collect::<Vec<_>>()
83                    .join(", "),
84            ),
85        )),
86    }
87}
88
89/// The command that writes `ecosystem`'s lockfile from its manifest.
90fn lock_command(ecosystem: Ecosystem) -> &'static str {
91    match ecosystem {
92        Ecosystem::Uv => "uv lock",
93        Ecosystem::Npm => "npm install --package-lock-only",
94        Ecosystem::Cargo => "cargo generate-lockfile",
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn holding(names: &[&'static str]) -> impl Fn(&str) -> bool {
103        let names = names.to_vec();
104        move |name| names.contains(&name)
105    }
106
107    /// **#74: a directory with one pair reads as that pair's ecosystem**, whichever of the
108    /// three it is.
109    #[test]
110    fn a_directory_with_one_pair_reads_as_its_ecosystem() {
111        for ecosystem in Ecosystem::ALL {
112            let found = project_ecosystem(
113                "proj",
114                holding(&[ecosystem.manifest_name(), ecosystem.lockfile_name()]),
115            )
116            .expect("one pair, one ecosystem");
117            assert_eq!(found, ecosystem);
118        }
119    }
120
121    /// **#74, the refusals, each naming its remedy.** A manifest without its lockfile has
122    /// nothing to key the layer on, and the message names the command that writes one. A
123    /// lockfile without its manifest could never install. No pair at all lists what was
124    /// looked for. Two ecosystems at once names both, because picking one silently bakes a
125    /// layer the caller did not choose.
126    ///
127    /// **Falsification**, run 2026-10-01, registered in
128    /// verify/guards/faults/project-files.toml. Let a manifest without its lockfile count as a
129    /// pair (`app-project-manifest-alone-counts`): red on the missing `uv lock` remedy.
130    #[test]
131    fn a_directory_that_cannot_key_a_layer_is_refused_naming_the_remedy() {
132        let error = project_ecosystem("proj", holding(&[])).expect_err("nothing to detect");
133        assert_eq!(error.kind(), ErrorKind::Precondition);
134        let message = error.to_string();
135        assert!(message.contains("pyproject.toml+uv.lock"), "{message}");
136        assert!(
137            message.contains("package.json+package-lock.json"),
138            "{message}"
139        );
140        assert!(message.contains("Cargo.toml+Cargo.lock"), "{message}");
141
142        let message = project_ecosystem("proj", holding(&["pyproject.toml"]))
143            .expect_err("no lockfile to key on")
144            .to_string();
145        assert!(message.contains("uv lock"), "{message}");
146        assert!(message.contains("keyed on the lockfile"), "{message}");
147
148        let message = project_ecosystem("proj", holding(&["uv.lock"]))
149            .expect_err("no manifest to install with")
150            .to_string();
151        assert!(message.contains("partial copy"), "{message}");
152
153        let message = project_ecosystem(
154            "proj",
155            holding(&[
156                "pyproject.toml",
157                "uv.lock",
158                "package.json",
159                "package-lock.json",
160            ]),
161        )
162        .expect_err("two layers, one image")
163        .to_string();
164        assert!(message.contains("uv.lock"), "{message}");
165        assert!(message.contains("package-lock.json"), "{message}");
166        assert!(
167            message.starts_with("proj "),
168            "names the directory: {message}"
169        );
170    }
171}