microvms_app/control/
project.rs1use super::artifact::Ecosystem;
10use crate::error::{Error, ErrorKind};
11
12pub 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
89fn 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 #[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 #[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}