use super::artifact::Ecosystem;
use crate::error::{Error, ErrorKind};
pub fn project_ecosystem(dir: &str, present: impl Fn(&str) -> bool) -> Result<Ecosystem, Error> {
let mut found = Vec::new();
for ecosystem in Ecosystem::ALL {
match (
present(ecosystem.manifest_name()),
present(ecosystem.lockfile_name()),
) {
(true, true) => found.push(ecosystem),
(true, false) => {
return Err(Error::new(
ErrorKind::Precondition,
format!(
"{dir} has {} but no {}: the environment layer is keyed on the \
lockfile, so there is nothing to key on. Write one with `{}` and \
rebuild.",
ecosystem.manifest_name(),
ecosystem.lockfile_name(),
lock_command(ecosystem),
),
));
}
(false, true) => {
return Err(Error::new(
ErrorKind::Precondition,
format!(
"{dir} has {} but no {}: the install step reads both, so the layer \
could never build. This usually means a partial copy of the project.",
ecosystem.lockfile_name(),
ecosystem.manifest_name(),
),
));
}
(false, false) => {}
}
}
match found.as_slice() {
[ecosystem] => Ok(*ecosystem),
[] => Err(Error::new(
ErrorKind::Precondition,
format!(
"{dir} has no dependency files a project build understands. It looks for one \
manifest+lockfile pair: pyproject.toml+uv.lock, \
package.json+package-lock.json, or Cargo.toml+Cargo.lock."
),
)),
several => Err(Error::new(
ErrorKind::Precondition,
format!(
"{dir} has dependency files for more than one ecosystem ({}), and one image \
bakes one environment layer. Name the directory that owns the layer you want \
baked.",
several
.iter()
.map(|ecosystem| ecosystem.lockfile_name())
.collect::<Vec<_>>()
.join(", "),
),
)),
}
}
fn lock_command(ecosystem: Ecosystem) -> &'static str {
match ecosystem {
Ecosystem::Uv => "uv lock",
Ecosystem::Npm => "npm install --package-lock-only",
Ecosystem::Cargo => "cargo generate-lockfile",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn holding(names: &[&'static str]) -> impl Fn(&str) -> bool {
let names = names.to_vec();
move |name| names.contains(&name)
}
#[test]
fn a_directory_with_one_pair_reads_as_its_ecosystem() {
for ecosystem in Ecosystem::ALL {
let found = project_ecosystem(
"proj",
holding(&[ecosystem.manifest_name(), ecosystem.lockfile_name()]),
)
.expect("one pair, one ecosystem");
assert_eq!(found, ecosystem);
}
}
#[test]
fn a_directory_that_cannot_key_a_layer_is_refused_naming_the_remedy() {
let error = project_ecosystem("proj", holding(&[])).expect_err("nothing to detect");
assert_eq!(error.kind(), ErrorKind::Precondition);
let message = error.to_string();
assert!(message.contains("pyproject.toml+uv.lock"), "{message}");
assert!(
message.contains("package.json+package-lock.json"),
"{message}"
);
assert!(message.contains("Cargo.toml+Cargo.lock"), "{message}");
let message = project_ecosystem("proj", holding(&["pyproject.toml"]))
.expect_err("no lockfile to key on")
.to_string();
assert!(message.contains("uv lock"), "{message}");
assert!(message.contains("keyed on the lockfile"), "{message}");
let message = project_ecosystem("proj", holding(&["uv.lock"]))
.expect_err("no manifest to install with")
.to_string();
assert!(message.contains("partial copy"), "{message}");
let message = project_ecosystem(
"proj",
holding(&[
"pyproject.toml",
"uv.lock",
"package.json",
"package-lock.json",
]),
)
.expect_err("two layers, one image")
.to_string();
assert!(message.contains("uv.lock"), "{message}");
assert!(message.contains("package-lock.json"), "{message}");
assert!(
message.starts_with("proj "),
"names the directory: {message}"
);
}
}