use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Definition {
pub name: String,
pub marker: String,
#[serde(default, rename = "provide")]
pub provides: Vec<Provide>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Provide {
pub name: String,
pub needs: Vec<String>,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub install: Option<String>,
pub because: String,
#[serde(default, rename = "measured")]
pub measured: Vec<crate::base::Measured>,
}
pub fn detected<'a>(stacks: &'a [Definition], repo: &Path) -> Vec<&'a Definition> {
stacks
.iter()
.filter(|s| repo.join(&s.marker).exists())
.collect()
}
pub fn key(stack: &str, provide: &str) -> String {
format!("{stack}/{provide}")
}
pub fn reconcile(
shared: &std::collections::BTreeMap<String, bool>,
fired: &std::collections::BTreeSet<String>,
) -> std::collections::BTreeMap<String, bool> {
let mut out = std::collections::BTreeMap::new();
for (key, on) in shared {
if !on {
out.insert(key.clone(), false);
}
}
for key in fired {
out.entry(key.clone()).or_insert(true);
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Applies,
DoesNot,
CouldNotAnswer(Option<i32>),
}
pub fn verdict(o: &crate::doctor::Outcome) -> Verdict {
if o.ok {
return Verdict::Applies;
}
match o
.detail
.split_whitespace()
.next()
.and_then(|c| c.parse().ok())
{
Some(1) => Verdict::DoesNot,
code => Verdict::CouldNotAnswer(code),
}
}
pub fn predicate_script(candidates: &[(String, Option<&str>)]) -> String {
let mut out = String::from("#!/bin/sh\n");
for (key, when) in candidates {
let k = crate::doctor::single_quote(key);
match when {
None => out.push_str(&format!("printf 'ok\\t%s\\tapplies\\n' {k}\n")),
Some(pred) => out.push_str(&format!(
"if ( {pred} ) >/dev/null 2>&1; then printf 'ok\\t%s\\tapplies\\n' {k}; \
else c=$?; if [ \"$c\" -eq 1 ]; then \
printf 'fail\\t%s\\t1 does not apply\\n' {k}; else \
printf 'fail\\t%s\\t%s could not answer\\n' {k} \"$c\"; fi; fi\n"
)),
}
}
out
}
pub fn predicate_args(tag: &str, repo: &Path, script: &str) -> Vec<String> {
let workdir = crate::container_workdir();
vec![
"run".into(),
"--rm".into(),
"-v".into(),
format!("{}:{workdir}:ro", repo.display()),
"-w".into(),
workdir.into(),
tag.into(),
"sh".into(),
"-c".into(),
script.into(),
]
}
fn validate(def: &Definition, path: &Path) -> Result<()> {
let at = path.display();
ecosystem_name(&def.name, &at.to_string())?;
let marker = Path::new(&def.marker);
anyhow::ensure!(
!def.marker.trim().is_empty(),
"{at}: the stack has no marker, and an empty one matches every repo"
);
anyhow::ensure!(
marker.components().count() == 1
&& marker
.components()
.all(|c| matches!(c, std::path::Component::Normal(_))),
"{at}: marker `{}` must be one filename inside the repo — an absolute \
path matches every repo on this machine, and `..` leaves the checkout",
def.marker
);
let mut seen = std::collections::BTreeSet::new();
for p in &def.provides {
anyhow::ensure!(
!p.name.trim().is_empty(),
"{at}: a provide has no name, so it cannot be keyed or reported"
);
anyhow::ensure!(
!p.name.contains('/'),
"{at}: provide name `{}` contains `/`",
p.name
);
anyhow::ensure!(
seen.insert(p.name.as_str()),
"{at}: two provides are called `{}`",
p.name
);
anyhow::ensure!(
!p.because.trim().is_empty(),
"{at}: provide `{}` states no case — `omh why` has nothing to read",
p.name
);
anyhow::ensure!(
!p.needs.is_empty(),
"{at}: provide `{}` needs nothing, so nothing can verify it ran",
p.name
);
for need in &p.needs {
anyhow::ensure!(
!need.trim().is_empty(),
"{at}: provide `{}` has a blank `needs` entry",
p.name
);
anyhow::ensure!(
crate::detect::program(need) == Some(need.as_str()),
"{at}: provide `{}` needs `{need}`, which is not a program name \
— `needs` is what must resolve on PATH, not a command to run",
p.name
);
}
}
Ok(())
}
fn ecosystem_name(name: &str, at: &str) -> Result<()> {
anyhow::ensure!(!name.trim().is_empty(), "{at}: an ecosystem has no name");
anyhow::ensure!(
!name.contains('/') && !name.contains('\\'),
"{at}: ecosystem name `{name}` contains a separator, which is what \
divides a stack from a provide in a `[provision]` key — and what would \
put its file somewhere nothing reads"
);
anyhow::ensure!(
!name.starts_with('.'),
"{at}: ecosystem name `{name}` starts with `.`, so it is a path rather \
than a name"
);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Marker {
pub file: String,
pub stack: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Markers {
#[serde(default, rename = "marker")]
markers: Vec<Marker>,
}
pub fn markers(dir: &Path) -> Result<Vec<Marker>> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
};
for entry in entries {
let path = entry
.with_context(|| format!("reading {}", dir.display()))?
.path();
if path.extension().is_none_or(|e| e != "toml") {
continue;
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let parsed: Markers =
toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
for m in &parsed.markers {
let at = path.display().to_string();
anyhow::ensure!(!m.file.trim().is_empty(), "{at}: a marker needs a `file`");
ecosystem_name(&m.stack, &at)?;
let p = Path::new(&m.file);
anyhow::ensure!(
p.components().count() == 1
&& p.components()
.all(|c| matches!(c, std::path::Component::Normal(_))),
"{}: marker `{}` must be one filename inside the repo",
path.display(),
m.file
);
}
out.extend(parsed.markers);
}
out.sort_by(|a, b| a.stack.cmp(&b.stack));
Ok(out)
}
pub fn unclaimed<'a>(markers: &'a [Marker], stacks: &[Definition], repo: &Path) -> Vec<&'a Marker> {
markers
.iter()
.filter(|m| repo.join(&m.file).exists())
.filter(|m| !stacks.iter().any(|s| s.name == m.stack))
.collect()
}
pub fn load_all(catalogue: &Path, repo: &Path) -> Result<Vec<Definition>> {
let mut out = load_dir(catalogue)?;
for def in load_dir(repo)? {
if out.iter().any(|d| d.name == def.name) {
anyhow::bail!(
"{}: `{}` is a stack omh ships, so this file answers to nothing — \
it is not read, and it does not override omh's ({}). Rename it, \
or open a pull request against the one omh ships if it is wrong.",
repo.join(format!("{}.toml", def.name)).display(),
def.name,
catalogue.join(format!("{}.toml", def.name)).display()
);
}
out.push(def);
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn load_dir(dir: &Path) -> Result<Vec<Definition>> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
};
let mut out = Vec::new();
for entry in entries {
let path = entry
.with_context(|| format!("reading {}", dir.display()))?
.path();
if path.extension().is_none_or(|e| e != "toml") {
continue;
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let def: Definition =
toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
validate(&def, &path)?;
out.push(def);
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn dir_with(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for (name, body) in files {
std::fs::write(dir.path().join(name), body).unwrap();
}
dir
}
const MINIMAL: &str = r#"
name = "rust"
marker = "Cargo.toml"
[[provide]]
name = "toolchain"
needs = ["cargo"]
because = "cargo is how a rust project is built and tested"
"#;
fn shipped() -> Vec<Definition> {
load_dir(Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/stacks")))
.expect("the shipped stacks must load")
}
fn shipped_markers() -> Vec<Marker> {
markers(Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/markers")))
.expect("the shipped markers must load")
}
#[test]
fn no_marker_names_an_ecosystem_omh_already_ships() {
let stacks = shipped();
for m in shipped_markers() {
assert!(
!stacks.iter().any(|s| s.name == m.stack),
"`{}` is listed as unclaimed and omh ships a `{}` stack — \
delete the marker, or `init` will ask how to install what it \
just installed",
m.file,
m.stack
);
assert!(
!stacks.iter().any(|s| s.marker == m.file),
"`{}` is listed as unclaimed and a shipped stack already \
detects it",
m.file
);
}
}
#[test]
fn a_marker_names_an_ecosystem_the_way_a_stack_does() {
for (stack, why) in [
(
"elixir/otp",
"a `/` separates a stack from a provide in a key",
),
(
"../evil",
"and climbs out of the directory the answer belongs in",
),
(".hidden", "a leading dot is not a name"),
] {
let d = dir_with(&[(
"m.toml",
&format!("[[marker]]\nfile = \"mix.exs\"\nstack = \"{stack}\"\n"),
)]);
assert!(
markers(d.path()).is_err(),
"`{stack}` must be refused — {why}"
);
}
let good = dir_with(&[(
"m.toml",
"[[marker]]\nfile = \"mix.exs\"\nstack = \"elixir\"\n",
)]);
assert_eq!(markers(good.path()).unwrap().len(), 1);
}
#[test]
fn every_shipped_marker_names_a_file_and_an_ecosystem() {
let all = shipped_markers();
assert!(all.len() >= 5, "a list this short proves nothing: {all:?}");
for m in &all {
assert!(!m.file.trim().is_empty() && !m.stack.trim().is_empty());
assert_eq!(
Path::new(&m.file).components().count(),
1,
"`{}` is not one filename inside the repo",
m.file
);
}
let names: std::collections::BTreeSet<&str> =
all.iter().map(|m| m.stack.as_str()).collect();
assert_eq!(names.len(), all.len(), "two markers claim one ecosystem");
}
#[test]
fn a_marker_a_stack_now_claims_is_no_longer_unclaimed() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::write(repo.join("mix.exs"), "").unwrap();
let all = shipped_markers();
let asked: Vec<&str> = unclaimed(&all, &[], repo)
.iter()
.map(|m| m.stack.as_str())
.collect();
assert_eq!(asked, ["elixir"], "only the marker this repo actually has");
let answered: Vec<Definition> = toml::from_str::<Definition>(
"name = \"elixir\"\nmarker = \"mix.exs\"\n\n[[provide]]\n\
name = \"toolchain\"\nneeds = [\"mix\"]\n\
install = \"apt-get install -y elixir\"\nbecause = \"it builds\"\n",
)
.map(|d| vec![d])
.unwrap();
assert!(
unclaimed(&all, &answered, repo).is_empty(),
"a stack answering the question turns it off, whoever wrote it"
);
}
#[test]
fn a_repo_may_add_an_ecosystem_omh_does_not_ship() {
let catalogue = dir_with(&[("rust.toml", MINIMAL)]);
let repo = dir_with(&[(
"acme.toml",
"name = \"acme\"\nmarker = \"acme.yaml\"\n\n\
[[provide]]\nname = \"toolchain\"\nneeds = [\"acmec\"]\n\
install = \"install-acme\"\nbecause = \"the internal compiler\"\n",
)]);
let all = load_all(catalogue.path(), repo.path()).unwrap();
let names: Vec<&str> = all.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, ["acme", "rust"], "both, sorted, from both places");
}
#[test]
fn a_repo_may_not_answer_to_a_name_omh_ships() {
let catalogue = dir_with(&[("rust.toml", MINIMAL)]);
let repo = dir_with(&[("rust.toml", &MINIMAL.replace("Cargo.toml", "evil.toml"))]);
let err = format!(
"{:#}",
load_all(catalogue.path(), repo.path())
.expect_err("a repo may not redefine an ecosystem omh ships")
);
assert!(err.contains("rust"), "must name it: {err}");
assert!(
err.contains(&catalogue.path().display().to_string())
&& err.contains(&repo.path().display().to_string()),
"and both files, or the fix is a guess: {err}"
);
}
#[test]
fn every_shipped_hook_names_a_program_its_stack_provisions() {
let defs = shipped();
let mut checked = 0;
for file in crate::bundled::Shipped::Hooks.files() {
let hook = crate::hook::Hook::parse(file.contents, file.name)
.unwrap_or_else(|e| panic!("{}: {e:#}", file.name));
let Some(stack) = hook.stack.as_deref() else {
continue;
};
let def = defs
.iter()
.find(|d| d.name == stack)
.unwrap_or_else(|| panic!("{}: names stack `{stack}`, which omh does not ship — it could never apply anywhere", file.name));
let provisioned: Vec<&str> = def
.provides
.iter()
.flat_map(|p| p.needs.iter().map(String::as_str))
.collect();
for command in hook.runs() {
let Some(needed) = crate::detect::program(command) else {
panic!("{}: `{command}` names no program", file.name);
};
assert!(
provisioned.contains(&needed),
"{}: runs `{command}`, and no provide of `{stack}` installs \
`{needed}` — the hook would be held back in every repo that \
takes it. provisioned: {provisioned:?}",
file.name
);
checked += 1;
}
}
assert!(
checked >= 4,
"only {checked} commands were checked — a hook catalogue this small \
proves nothing about the join"
);
}
#[test]
fn every_provide_states_its_case() {
let stacks = shipped();
assert!(!stacks.is_empty(), "omh ships no stacks at all");
for s in &stacks {
assert!(!s.marker.trim().is_empty(), "{}: no marker", s.name);
assert!(
!s.provides.is_empty(),
"{}: provides nothing, so detecting it does nothing",
s.name
);
for p in &s.provides {
let label = format!("{}/{}", s.name, p.name);
assert!(!p.because.trim().is_empty(), "{label}: no `because`");
assert!(
!p.needs.is_empty(),
"{label}: needs nothing, so nothing can verify it ran"
);
crate::base::assert_measured_states_its_case(&label, &p.measured);
}
}
}
#[test]
fn a_repo_is_the_stack_whose_marker_it_holds() {
let stacks = shipped();
for s in &stacks {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join(&s.marker), "").unwrap();
let found: Vec<&str> = detected(&stacks, d.path())
.iter()
.map(|f| f.name.as_str())
.collect();
assert_eq!(
found,
[s.name.as_str()],
"a repo holding only {} is {} and nothing else",
s.marker,
s.name
);
}
}
#[test]
fn no_marker_is_no_stack_rather_than_a_guess() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("README.md"), "hello").unwrap();
assert!(detected(&shipped(), d.path()).is_empty());
}
#[test]
fn a_repo_can_be_more_than_one_stack() {
let stacks = shipped();
let d = tempfile::tempdir().unwrap();
for s in stacks.iter().take(2) {
std::fs::write(d.path().join(&s.marker), "").unwrap();
}
assert_eq!(detected(&stacks, d.path()).len(), 2);
}
#[test]
fn no_two_shipped_stacks_claim_the_same_name_or_marker() {
let stacks = shipped();
for (i, a) in stacks.iter().enumerate() {
for b in &stacks[i + 1..] {
assert_ne!(a.name, b.name, "two stacks called {}", a.name);
assert_ne!(
a.marker, b.marker,
"{} and {} both claim {}",
a.name, b.name, a.marker
);
}
}
}
fn shared(entries: &[(&str, bool)]) -> std::collections::BTreeMap<String, bool> {
entries.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}
fn fired(keys: &[&str]) -> std::collections::BTreeSet<String> {
keys.iter().map(|k| k.to_string()).collect()
}
#[test]
fn a_provision_key_is_stack_slash_provide() {
assert_eq!(key("rust", "linker"), "rust/linker");
}
#[test]
fn a_recorded_false_survives_re_resolution() {
let out = reconcile(&shared(&[("node/pnpm", false)]), &fired(&["node/pnpm"]));
assert_eq!(out.get("node/pnpm"), Some(&false));
}
#[test]
fn a_newly_fired_provide_is_recorded_true() {
let out = reconcile(&shared(&[]), &fired(&["rust/toolchain"]));
assert_eq!(out.get("rust/toolchain"), Some(&true));
}
#[test]
fn a_provide_that_stopped_applying_loses_its_entry() {
let out = reconcile(&shared(&[("node/yarn", true)]), &fired(&["node/pnpm"]));
assert_eq!(
out.get("node/yarn"),
None,
"the stale entry is gone: {out:?}"
);
assert_eq!(out.get("node/pnpm"), Some(&true));
}
#[test]
fn nothing_is_invented_for_a_provide_that_never_fired() {
let out = reconcile(&shared(&[]), &fired(&[]));
assert!(out.is_empty(), "invented: {out:?}");
}
fn ask(candidates: &[(&str, Option<&str>)], cwd: &Path) -> Vec<(String, Verdict)> {
let owned: Vec<(String, Option<&str>)> = candidates
.iter()
.map(|(k, w)| ((*k).to_string(), *w))
.collect();
let out = crate::doctor::run_probe_in(&predicate_script(&owned), cwd);
crate::doctor::parse(&out)
.iter()
.map(|o| (o.name.clone(), verdict(o)))
.collect()
}
#[test]
fn exit_zero_applies_and_exit_one_does_not() {
let d = tempfile::tempdir().unwrap();
let got = ask(&[("x/a", Some("true")), ("x/b", Some("false"))], d.path());
assert_eq!(
got,
vec![
("x/a".to_string(), Verdict::Applies),
("x/b".to_string(), Verdict::DoesNot),
]
);
}
#[test]
fn an_exit_above_one_could_not_answer_and_says_with_what_code() {
let d = tempfile::tempdir().unwrap();
let got = ask(
&[("x/misuse", Some("exit 2")), ("x/odd", Some("exit 7"))],
d.path(),
);
assert_eq!(
got,
vec![
("x/misuse".to_string(), Verdict::CouldNotAnswer(Some(2))),
("x/odd".to_string(), Verdict::CouldNotAnswer(Some(7))),
]
);
}
#[test]
fn a_predicate_that_ends_the_shell_does_not_silence_the_rest() {
let d = tempfile::tempdir().unwrap();
let got = ask(
&[("x/dies", Some("exit 3")), ("x/after", Some("true"))],
d.path(),
);
assert_eq!(
got,
vec![
("x/dies".to_string(), Verdict::CouldNotAnswer(Some(3))),
("x/after".to_string(), Verdict::Applies),
]
);
}
#[test]
fn a_provide_with_no_condition_applies() {
let d = tempfile::tempdir().unwrap();
let got = ask(&[("x/always", None)], d.path());
assert_eq!(got, vec![("x/always".to_string(), Verdict::Applies)]);
}
#[test]
fn a_predicate_reads_the_repo_it_runs_in() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("pnpm-lock.yaml"), "").unwrap();
let got = ask(
&[
("node/pnpm", Some("test -f pnpm-lock.yaml")),
("node/yarn", Some("test -f yarn.lock")),
],
d.path(),
);
assert_eq!(got[0].1, Verdict::Applies, "the lockfile is here");
assert_eq!(got[1].1, Verdict::DoesNot, "and this one is not");
}
#[test]
fn every_shipped_predicate_answers_in_the_protocol() {
for def in shipped() {
let candidates: Vec<(String, Option<&str>)> = def
.provides
.iter()
.map(|p| (key(&def.name, &p.name), p.when.as_deref()))
.collect();
if candidates.is_empty() {
continue;
}
for populated in [false, true] {
let d = tempfile::tempdir().unwrap();
if populated {
std::fs::write(d.path().join(&def.marker), "{}").unwrap();
}
let before = std::fs::read_dir(d.path()).unwrap().flatten().count();
let out = crate::doctor::run_probe_in(&predicate_script(&candidates), d.path());
let answered = crate::doctor::parse(&out);
assert_eq!(
answered.len(),
candidates.len(),
"{} answered {} of {} predicates: {out}",
def.name,
answered.len(),
candidates.len()
);
let after = std::fs::read_dir(d.path()).unwrap().flatten().count();
assert_eq!(
before, after,
"{}'s predicates wrote into the repo — they are mounted \
read-only in the sandbox, so this would fail there instead",
def.name
);
}
}
}
#[test]
fn predicates_see_the_repo_and_can_only_read_it() {
let args = predicate_args("omh/x:latest", Path::new("/host/wt"), "#!/bin/sh\ntrue\n");
let mounts: Vec<&String> = args
.iter()
.zip(args.iter().skip(1))
.filter(|(f, _)| *f == "-v")
.map(|(_, spec)| spec)
.collect();
assert_eq!(mounts.len(), 1, "exactly one mount: {args:?}");
assert!(
mounts[0].starts_with("/host/wt:") && mounts[0].ends_with(":ro"),
"and it is the repo, read-only: {}",
mounts[0]
);
assert!(
args.windows(2)
.any(|w| w[0] == "-w" && w[1] == crate::container_workdir()),
"a predicate written `test -f pnpm-lock.yaml` needs the repo as its \
working directory: {args:?}"
);
assert!(args.contains(&"--rm".to_string()), "{args:?}");
assert_eq!(args.last().map(String::as_str), Some("#!/bin/sh\ntrue\n"));
}
#[test]
fn a_predicate_that_prints_cannot_fabricate_a_verdict() {
let d = tempfile::tempdir().unwrap();
let forged = "ok\trust/toolchain\tapplies";
std::fs::write(d.path().join("package.json"), forged).unwrap();
let got = ask(&[("node/pnpm", Some("cat package.json"))], d.path());
assert_eq!(
got.len(),
1,
"the repo's content became a verdict of its own: {got:?}"
);
assert_eq!(
got[0].0, "node/pnpm",
"and it is the one omh asked: {got:?}"
);
}
#[test]
fn a_hostile_key_cannot_corrupt_the_run() {
let d = tempfile::tempdir().unwrap();
let hostile = "x/$(echo pwned)";
let owned = vec![
(hostile.to_string(), Some("true")),
("x/after".to_string(), Some("true")),
];
let out = crate::doctor::run_probe_in(&predicate_script(&owned), d.path());
assert!(
!out.lines().any(|l| l.trim() == "pwned"),
"a key was expanded as shell: {out}"
);
let answered = crate::doctor::parse(&out);
assert!(
answered.iter().any(|o| o.name == hostile),
"the key came back changed: {answered:?}"
);
assert!(
answered.iter().any(|o| o.name == "x/after"),
"and one hostile key must not cost the rest: {answered:?}"
);
}
#[test]
fn a_marker_that_is_not_one_filename_inside_the_repo_is_refused() {
for (marker, why) in [
("/etc/hostname", "absolute — `join` throws the repo away"),
("../../etc/hostname", "climbs out of the checkout"),
("", "joins to the repo root, which always exists"),
("a/b", "is a path rather than a marker"),
] {
let body = MINIMAL.replace("Cargo.toml", marker);
let d = dir_with(&[("rust.toml", &body)]);
let Err(e) = load_dir(d.path()) else {
panic!("accepted a marker that {why}: {marker:?}");
};
let err = format!("{e:#}");
assert!(err.contains("rust.toml"), "must name the file: {err}");
}
}
#[test]
fn a_name_that_would_collide_in_a_provision_key_is_refused() {
for (field, value) in [
("name = \"rust\"", "name = \"ru/st\""),
("name = \"rust\"", "name = \"\""),
] {
let body = MINIMAL.replacen(field, value, 1);
let d = dir_with(&[("rust.toml", &body)]);
assert!(
load_dir(d.path()).is_err(),
"accepted a stack name that cannot key a provide: {value}"
);
}
}
#[test]
fn two_provides_cannot_share_a_name() {
let body = format!(
"{MINIMAL}\n[[provide]]\nname = \"toolchain\"\nneeds = [\"rustc\"]\nbecause = \"again\"\n"
);
let d = dir_with(&[("rust.toml", &body)]);
let Err(e) = load_dir(d.path()) else {
panic!("accepted two provides called `toolchain`");
};
assert!(format!("{e:#}").contains("toolchain"), "must name it");
}
#[test]
fn a_needs_entry_that_is_not_a_program_name_is_refused() {
for (needs, why) in [
(r#"[""]"#, "blank"),
(r#"["cargo test"]"#, "carries arguments"),
("[]", "empty, so nothing can verify the provide"),
(r#"["cc", ""]"#, "blank, in second position"),
(
r#"["cc", "cargo test"]"#,
"carries arguments, in second position",
),
] {
let body = format!(
"{MINIMAL}\n[[provide]]\nname = \"linker\"\nneeds = {needs}\n\
because = \"rustc emits objects and something has to link them\"\n"
);
let d = dir_with(&[("rust.toml", &body)]);
let Err(e) = load_dir(d.path()) else {
panic!("a {why} `needs` was accepted: {body}");
};
let err = format!("{e:#}");
assert!(err.contains("rust.toml"), "must name the file: {err}");
assert!(
err.contains("linker"),
"and the provide, so the fix is findable: {err}"
);
}
}
#[test]
fn every_file_in_the_directory_is_a_stack() {
let d = dir_with(&[
("zebra.toml", &MINIMAL.replace("rust", "zebra")),
("alpha.toml", &MINIMAL.replace("rust", "alpha")),
]);
let found = load_dir(d.path()).unwrap();
let names: Vec<&str> = found.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
["alpha", "zebra"],
"every file, in a stable order — a stack directory is not a contest"
);
}
#[test]
fn a_missing_directory_is_no_stacks_rather_than_an_error() {
let d = tempfile::tempdir().unwrap();
let found = load_dir(&d.path().join("nothing-here")).unwrap();
assert!(found.is_empty(), "got {found:?}");
}
#[test]
fn a_key_omh_does_not_understand_is_refused_by_name() {
let d = dir_with(&[("rust.toml", &format!("mark = \"Cargo.toml\"\n{MINIMAL}"))]);
let err = format!("{:#}", load_dir(d.path()).unwrap_err());
assert!(err.contains("mark"), "must name the key: {err}");
assert!(err.contains("rust.toml"), "and the file: {err}");
}
#[test]
fn only_toml_files_are_read() {
let d = dir_with(&[
("rust.toml", MINIMAL),
("rust.toml.yours", "this is not toml at all {{{"),
("notes.md", "nor is this"),
]);
let found = load_dir(d.path()).unwrap();
assert_eq!(found.len(), 1, "got {found:?}");
}
#[test]
fn a_marker_needs_both_halves_and_a_file_inside_the_repo() {
for (why, body) in [
(
"no stack to name the file omh would write",
"[[marker]]\nfile = \"mix.exs\"\nstack = \"\"\n",
),
(
"no file to look for",
"[[marker]]\nfile = \"\"\nstack = \"elixir\"\n",
),
(
"an absolute path is in every repo on the machine",
"[[marker]]\nfile = \"/etc/passwd\"\nstack = \"elixir\"\n",
),
(
"a path is not one filename inside the repo",
"[[marker]]\nfile = \"../mix.exs\"\nstack = \"elixir\"\n",
),
] {
let dir = dir_with(&[("m.toml", body)]);
assert!(markers(dir.path()).is_err(), "{why}: accepted {body:?}");
}
}
#[test]
fn markers_are_returned_in_a_stable_order() {
let dir = dir_with(&[
(
"z.toml",
"[[marker]]\nfile = \"mix.exs\"\nstack = \"elixir\"\n",
),
(
"a.toml",
"[[marker]]\nfile = \"Gemfile\"\nstack = \"ruby\"\n\n\
[[marker]]\nfile = \"composer.json\"\nstack = \"php\"\n",
),
]);
let got: Vec<String> = markers(dir.path())
.unwrap()
.into_iter()
.map(|m| m.stack)
.collect();
assert_eq!(
got,
["elixir", "php", "ruby"],
"sorted by stack, whatever order the files were read in"
);
}
}