use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Manager {
Npm,
Pnpm,
Yarn,
Bun,
}
impl Manager {
pub fn program(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Pnpm => "pnpm",
Self::Yarn => "yarn",
Self::Bun => "bun",
}
}
fn lockfiles(self) -> &'static [&'static str] {
match self {
Self::Npm => &["package-lock.json"],
Self::Pnpm => &["pnpm-lock.yaml"],
Self::Yarn => &["yarn.lock"],
Self::Bun => &["bun.lock", "bun.lockb"],
}
}
pub fn run(self, script: &str) -> String {
format!("{} run {script}", self.program())
}
const ALL: [Manager; 4] = [Self::Npm, Self::Pnpm, Self::Yarn, Self::Bun];
}
pub fn manager(repo: &Path, provision: &BTreeMap<String, bool>) -> Option<Manager> {
let provisioned: Vec<Manager> = Manager::ALL
.into_iter()
.filter(|m| provision.get(&crate::stack::key("node", m.program())) == Some(&true))
.collect();
if let [only] = provisioned[..] {
return Some(only);
}
let locked: Vec<Manager> = Manager::ALL
.into_iter()
.filter(|m| m.lockfiles().iter().any(|f| repo.join(f).exists()))
.collect();
match locked[..] {
[only] => return Some(only),
[_, ..] => return None,
[] => {}
}
let raw = std::fs::read_to_string(repo.join("package.json")).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
let declared = parsed.get("packageManager")?.as_str()?;
let name = declared.split('@').next().unwrap_or_default();
Manager::ALL.into_iter().find(|m| m.program() == name)
}
pub fn scripts(repo: &Path) -> BTreeSet<String> {
let Ok(raw) = std::fs::read_to_string(repo.join("package.json")) else {
return BTreeSet::new();
};
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&raw) else {
return BTreeSet::new();
};
parsed
.get("scripts")
.and_then(serde_json::Value::as_object)
.map(|s| s.keys().cloned().collect())
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Runner {
Make,
Just,
Task,
}
impl Runner {
pub fn program(self) -> &'static str {
match self {
Self::Make => "make",
Self::Just => "just",
Self::Task => "task",
}
}
fn files(self) -> &'static [&'static str] {
match self {
Self::Make => &["Makefile", "makefile", "GNUmakefile"],
Self::Just => &["justfile", "Justfile", ".justfile"],
Self::Task => &["Taskfile.yml", "Taskfile.yaml"],
}
}
pub fn run(self, target: &str) -> String {
format!("{} {target}", self.program())
}
const ALL: [Runner; 3] = [Self::Make, Self::Just, Self::Task];
}
pub fn runner(repo: &Path) -> Option<(Runner, BTreeSet<String>)> {
let mut found = Vec::new();
for which in Runner::ALL {
let Some(path) = which
.files()
.iter()
.map(|f| repo.join(f))
.find(|p| p.exists())
else {
continue;
};
let Ok(body) = std::fs::read_to_string(&path) else {
found.push((which, BTreeSet::new()));
continue;
};
found.push((which, which.targets(&body)));
}
match found.into_iter().collect::<Vec<_>>()[..] {
[(which, ref targets)] if !targets.is_empty() => Some((which, targets.clone())),
_ => None,
}
}
impl Runner {
fn targets(self, body: &str) -> BTreeSet<String> {
match self {
Self::Make | Self::Just => rule_heads(body),
Self::Task => taskfile_tasks(body).unwrap_or_default(),
}
}
}
fn rule_heads(body: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for line in body.lines() {
if line.starts_with([' ', '\t']) || line.trim_start().starts_with('#') {
continue;
}
let Some((heads, _)) = line.split_once(':') else {
continue;
};
let after = &line[heads.len() + 1..];
let assignment = heads
.split_whitespace()
.any(|w| w.starts_with('=') || w.ends_with('='));
if after.starts_with([':', '=']) || assignment || heads.trim().is_empty() {
continue;
}
for head in heads.split_whitespace() {
if is_target_name(head) {
out.insert(head.to_string());
} else {
break;
}
}
}
out
}
fn is_target_name(word: &str) -> bool {
!word.is_empty()
&& word.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')
&& word
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
}
fn taskfile_tasks(body: &str) -> Option<BTreeSet<String>> {
for line in body.lines() {
let t = line.trim_start();
if t.starts_with("includes:") || t.starts_with("<<:") {
return None;
}
if t.split_whitespace()
.any(|w| (w.starts_with('&') || w.starts_with('*')) && w.len() > 1)
{
return None;
}
}
let mut out = BTreeSet::new();
let mut inside = None;
for line in body.lines() {
if line.trim_end() == "tasks:" {
inside = Some(());
continue;
}
if line.starts_with("tasks:") && line.trim_end() != "tasks:" {
return None;
}
if inside.is_none() {
continue;
}
if !line.starts_with([' ', '\t']) {
if line.trim().is_empty() {
continue;
}
break; }
let depth = line.len() - line.trim_start().len();
let t = line.trim();
if depth <= 2 && !t.starts_with('#') {
if let Some((name, rest)) = t.split_once(':') {
if rest.trim().is_empty() && is_target_name(name) {
out.insert(name.to_string());
}
}
}
}
Some(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Derived {
pub name: String,
pub hook: crate::hook::Hook,
pub from: String,
}
const MOMENTS: [(crate::hook::Event, &str, &[&str]); 2] = [
(crate::hook::Event::TurnEnd, "test", &["test"]),
(crate::hook::Event::AfterTool, "format", &["format", "fmt"]),
];
pub fn hooks(
repo: &Path,
provision: &BTreeMap<String, bool>,
covered: &BTreeSet<String>,
) -> Vec<Derived> {
let mut out = Vec::new();
let runner = runner(repo).filter(|_| covered.is_empty());
let manager = manager(repo, provision).filter(|_| !covered.contains("node"));
let scripts = scripts(repo);
for (on, moment, names) in MOMENTS {
let found = runner
.as_ref()
.and_then(|(which, targets)| {
names.iter().find(|n| targets.contains(**n)).map(|n| {
(
which.program(),
which.run(n),
None,
format!("a `{n}` target in this repo's {}file", which.program()),
)
})
})
.or_else(|| {
let m = manager?;
let n = names.iter().find(|n| scripts.contains(**n))?;
Some((
m.program(),
m.run(n),
Some("node"),
format!("the `{n}` script in package.json, run with {}", m.program()),
))
});
let Some((source, command, stack, from)) = found else {
continue;
};
out.push(Derived {
name: format!("{source}-{moment}"),
hook: crate::hook::Hook {
on,
stack: stack.map(str::to_string),
tools: match on {
crate::hook::Event::AfterTool => vec![crate::hook::Tool::Edit],
_ => Vec::new(),
},
when: None,
action: crate::hook::Action::Run(command),
},
from,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn repo(files: &[(&str, &str)]) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("repo");
std::fs::create_dir_all(&root).unwrap();
for (name, body) in files {
let p = root.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
(dir, root)
}
fn provisioned(keys: &[&str]) -> BTreeMap<String, bool> {
keys.iter().map(|k| ((*k).to_string(), true)).collect()
}
#[test]
fn a_lockfile_names_the_package_manager() {
for (lock, want) in [
("pnpm-lock.yaml", Manager::Pnpm),
("yarn.lock", Manager::Yarn),
("bun.lock", Manager::Bun),
("bun.lockb", Manager::Bun),
("package-lock.json", Manager::Npm),
] {
let (_d, r) = repo(&[("package.json", "{}"), (lock, "")]);
assert_eq!(manager(&r, &BTreeMap::new()), Some(want), "for {lock}");
}
}
#[test]
fn a_lockfile_beats_a_declaration() {
let (_d, r) = repo(&[
("package.json", r#"{"packageManager":"pnpm@9.0.0"}"#),
("yarn.lock", ""),
]);
assert_eq!(manager(&r, &BTreeMap::new()), Some(Manager::Yarn));
}
#[test]
fn a_declaration_answers_when_no_lockfile_does() {
for (field, want) in [
("pnpm@9.0.0", Manager::Pnpm),
("yarn@4.1.0", Manager::Yarn),
("bun@1.1.0", Manager::Bun),
("npm@10.5.0", Manager::Npm),
] {
let (_d, r) = repo(&[(
"package.json",
&format!(r#"{{"packageManager":"{field}"}}"#),
)]);
assert_eq!(manager(&r, &BTreeMap::new()), Some(want), "for {field}");
}
}
#[test]
fn two_lockfiles_answer_nothing() {
let (_d, r) = repo(&[
("package.json", "{}"),
("yarn.lock", ""),
("pnpm-lock.yaml", ""),
]);
assert_eq!(manager(&r, &BTreeMap::new()), None);
let (_d, r) = repo(&[
("package.json", r#"{"packageManager":"pnpm@9.0.0"}"#),
("yarn.lock", ""),
("pnpm-lock.yaml", ""),
]);
assert_eq!(manager(&r, &BTreeMap::new()), None);
}
#[test]
fn what_the_image_has_outranks_what_the_repo_says() {
let (_d, r) = repo(&[("package.json", "{}"), ("yarn.lock", "")]);
assert_eq!(
manager(&r, &provisioned(&["node/pnpm"])),
Some(Manager::Pnpm),
"the sandbox has pnpm, so a yarn hook could not run in it"
);
let (_d, r) = repo(&[("package.json", "{}"), ("yarn.lock", ""), ("bun.lock", "")]);
assert_eq!(
manager(&r, &provisioned(&["node/bun"])),
Some(Manager::Bun),
"two lockfiles, but only one of them is in the image"
);
}
#[test]
fn a_provision_opt_out_never_names_the_manager() {
let (_d, r) = repo(&[("package.json", "{}"), ("yarn.lock", "")]);
let declined: BTreeMap<String, bool> =
[("node/pnpm".to_string(), false)].into_iter().collect();
assert_eq!(manager(&r, &declined), Some(Manager::Yarn));
}
#[test]
fn nothing_here_is_nothing_rather_than_a_default() {
let (_d, r) = repo(&[("README.md", "hello")]);
assert_eq!(manager(&r, &BTreeMap::new()), None);
let (_d, r) = repo(&[("package.json", "{ this is not json")]);
assert_eq!(
manager(&r, &BTreeMap::new()),
None,
"a file omh cannot read says nothing, and nothing is not npm"
);
}
#[test]
fn a_script_is_always_run_never_invoked_directly() {
for m in Manager::ALL {
let spelled = m.run("test");
assert_eq!(
spelled,
format!("{} run test", m.program()),
"every manager runs a script the same way"
);
}
assert_eq!(
Manager::Bun.run("test"),
"bun run test",
"`bun test` would silently ignore the project's own test script"
);
}
#[test]
fn a_makefile_declares_its_targets() {
let (_d, r) = repo(&[(
"Makefile",
".PHONY: test fmt\n\
CARGO := cargo\n\
test:\n\
\t$(CARGO) test\n\
fmt lint:\n\
\t$(CARGO) fmt\n",
)]);
let (which, targets) = runner(&r).expect("a Makefile is a runner");
assert_eq!(which, Runner::Make);
assert_eq!(
targets,
["test", "fmt", "lint"]
.map(String::from)
.into_iter()
.collect(),
"two targets on one line are two targets; `.PHONY` is not one, and \
neither is a `:=` assignment"
);
assert_eq!(which.run("test"), "make test");
}
#[test]
fn a_justfile_declares_its_recipes() {
let (_d, r) = repo(&[(
"justfile",
"# a comment\n\
export RUST_LOG := \"debug\"\n\
test:\n\
\tcargo test\n\
build target=\"debug\":\n\
\tcargo build\n",
)]);
let (which, targets) = runner(&r).expect("a justfile is a runner");
assert_eq!(which, Runner::Just);
assert_eq!(
targets,
["test", "build"].map(String::from).into_iter().collect()
);
assert_eq!(which.run("build"), "just build");
}
#[test]
fn a_taskfile_declares_its_tasks() {
let (_d, r) = repo(&[(
"Taskfile.yml",
"version: '3'\n\
\n\
tasks:\n\
\x20 test:\n\
\x20 cmds:\n\
\x20 - go test ./...\n\
\x20 lint:\n\
\x20 cmds:\n\
\x20 - golangci-lint run\n",
)]);
let (which, targets) = runner(&r).expect("a Taskfile is a runner");
assert_eq!(which, Runner::Task);
assert_eq!(
targets,
["test", "lint"].map(String::from).into_iter().collect()
);
}
#[test]
fn a_key_under_a_later_block_is_not_a_task() {
let (_d, r) = repo(&[(
"Taskfile.yml",
"version: '3'\n\
\n\
tasks:\n\
\x20 test:\n\
\x20 cmds:\n\
\x20 - go test ./...\n\
\n\
vars:\n\
\x20 BUILD_DATE:\n\
\x20 sh: date -u\n\
\x20 GREETING: hello\n",
)]);
assert_eq!(
runner(&r).expect("a Taskfile is a runner").1,
["test".to_string()].into_iter().collect(),
"`BUILD_DATE` is a variable, and `task BUILD_DATE` is not a command"
);
}
#[test]
fn a_key_carrying_a_value_is_not_read_as_a_task() {
let (_d, r) = repo(&[(
"Taskfile.yml",
"version: '3'\n\
tasks:\n\
\x20 test:\n\
\x20 cmds:\n\
\x20 - go test ./...\n\
\x20 hello: echo \"hi\"\n",
)]);
assert_eq!(
runner(&r).expect("a Taskfile is a runner").1,
["test".to_string()].into_iter().collect(),
"only the block-shaped task is read"
);
}
#[test]
fn a_taskfile_this_cannot_read_confidently_answers_nothing() {
for (why, body) in [
(
"includes pull in tasks that are not in this file",
"version: '3'\nincludes:\n docs: ./docs/Taskfile.yml\ntasks:\n test:\n cmds:\n - echo\n",
),
(
"an anchor means a task is assembled elsewhere",
"version: '3'\nx-base: &base\n silent: true\ntasks:\n test:\n <<: *base\n cmds:\n - echo\n",
),
(
"a flow mapping is a shape this does not read",
"version: '3'\ntasks: {test: {cmds: [echo]}}\n",
),
] {
let (_d, r) = repo(&[("Taskfile.yml", body)]);
assert_eq!(runner(&r), None, "{why}");
}
}
#[test]
fn an_assignment_is_not_a_target_however_its_value_is_spelled() {
let (_d, r) = repo(&[(
"Makefile",
"PATH_LIST = src:tests\n\
OTHER= a:b\n\
real:\n\
\techo\n",
)]);
assert_eq!(
runner(&r).expect("a Makefile is a runner").1,
["real"].map(String::from).into_iter().collect(),
"a colon in a variable's value does not make it a rule"
);
}
#[test]
fn a_keyword_before_an_assignment_does_not_make_it_a_rule() {
let (_d, r) = repo(&[(
"Makefile",
"export PYTHONPATH = src:tests\n\
override CFLAGS := -O2\n\
real:\n\
\techo\n",
)]);
assert_eq!(
runner(&r).expect("a Makefile is a runner").1,
["real"].map(String::from).into_iter().collect(),
"`export` and `override` shift the `=`, and neither is a target"
);
}
fn ran(d: &[Derived], name: &str) -> Option<String> {
d.iter()
.find(|h| h.name == name)
.map(|h| match &h.hook.action {
crate::hook::Action::Run(c) => c.clone(),
other => panic!("a derived hook runs a command, not {other:?}"),
})
}
#[test]
fn a_node_repo_gets_the_command_its_own_files_describe() {
let (_d, r) = repo(&[
(
"package.json",
r#"{"scripts":{"test":"vitest run","fmt":"prettier -w ."}}"#,
),
("pnpm-lock.yaml", ""),
]);
let got = hooks(&r, &BTreeMap::new(), &BTreeSet::new());
assert_eq!(ran(&got, "pnpm-test").as_deref(), Some("pnpm run test"));
assert_eq!(ran(&got, "pnpm-format").as_deref(), Some("pnpm run fmt"));
let test = got.iter().find(|h| h.name == "pnpm-test").unwrap();
assert_eq!(test.hook.on, crate::hook::Event::TurnEnd);
assert_eq!(
test.hook.stack.as_deref(),
Some("node"),
"so the drift report notices when the package.json goes"
);
assert!(
!test.from.is_empty(),
"`omh why` has to be able to say where this came from"
);
let fmt = got.iter().find(|h| h.name == "pnpm-format").unwrap();
assert_eq!(fmt.hook.on, crate::hook::Event::AfterTool);
assert_eq!(
fmt.hook.tools,
vec![crate::hook::Tool::Edit],
"formatting belongs to the edit that made it necessary"
);
}
#[test]
fn an_undeclared_script_produces_no_hook() {
let (_d, r) = repo(&[
("package.json", r#"{"scripts":{"build":"tsc"}}"#),
("pnpm-lock.yaml", ""),
]);
assert_eq!(
hooks(&r, &BTreeMap::new(), &BTreeSet::new()),
Vec::new(),
"a project with only a `build` script gets nothing — `build` is not \
a moment omh has an opinion about"
);
}
#[test]
fn nothing_is_derived_for_an_ecosystem_already_covered() {
let (_d, r) = repo(&[("Makefile", "test:\n\techo\nfmt:\n\techo\n")]);
let rust = ["rust".to_string()].into_iter().collect();
assert_eq!(
hooks(&r, &BTreeMap::new(), &rust),
Vec::new(),
"a runner answers for the whole project, so it applies only where \
no ecosystem hook does"
);
let got = hooks(&r, &BTreeMap::new(), &BTreeSet::new());
assert_eq!(ran(&got, "make-test").as_deref(), Some("make test"));
assert_eq!(ran(&got, "make-format").as_deref(), Some("make fmt"));
}
#[test]
fn no_text_from_the_repo_reaches_a_derived_command() {
let vocabulary: BTreeSet<String> = MOMENTS
.iter()
.flat_map(|(_, _, names)| names.iter())
.flat_map(|n| {
Manager::ALL
.into_iter()
.map(|m| m.run(n))
.chain(Runner::ALL.into_iter().map(|w| w.run(n)))
})
.collect();
let hostile = r#"{"scripts":{"test":"vitest run",
"a\"; rm -rf $HOME; #":"x", "a-test":"y"}}"#;
let (_d, r) = repo(&[("package.json", hostile), ("pnpm-lock.yaml", "")]);
let node = hooks(&r, &BTreeMap::new(), &BTreeSet::new());
assert_eq!(ran(&node, "pnpm-test").as_deref(), Some("pnpm run test"));
let (_d, r) = repo(&[(
"Makefile",
"a\"; rm -rf $HOME:\n\techo\na-test:\n\techo\ntest:\n\techo\n",
)]);
let make = hooks(&r, &BTreeMap::new(), &BTreeSet::new());
assert_eq!(ran(&make, "make-test").as_deref(), Some("make test"));
for d in node.iter().chain(make.iter()) {
let crate::hook::Action::Run(command) = &d.hook.action else {
panic!("a derived hook runs a command");
};
assert!(
vocabulary.contains(command),
"a command omh could not have spelled itself: {command}"
);
}
}
#[test]
fn a_derived_hook_survives_being_written_and_read_back() {
let (_d, r) = repo(&[
(
"package.json",
r#"{"scripts":{"test":"vitest run","fmt":"prettier -w ."}}"#,
),
("pnpm-lock.yaml", ""),
]);
for d in hooks(&r, &BTreeMap::new(), &BTreeSet::new()) {
let written = serde_json::to_string_pretty(&d.hook).unwrap();
assert_eq!(
crate::hook::Hook::parse(&written, &d.name).expect("must parse back"),
d.hook,
"what init writes is what the launcher reads"
);
}
}
#[test]
fn an_ecosystem_the_catalogue_covers_derives_nothing_from_its_scripts() {
let (_d, r) = repo(&[
("package.json", r#"{"scripts":{"test":"vitest run"}}"#),
("pnpm-lock.yaml", ""),
]);
let node = ["node".to_string()].into_iter().collect();
assert_eq!(
hooks(&r, &BTreeMap::new(), &node),
Vec::new(),
"a catalogue hook for node would already run this suite"
);
}
#[test]
fn a_hook_at_the_same_moment_does_not_cover_an_ecosystem() {
let (_d, r) = repo(&[
("package.json", r#"{"scripts":{"test":"vitest run"}}"#),
("pnpm-lock.yaml", ""),
]);
let rust = ["rust".to_string()].into_iter().collect();
let got = hooks(&r, &BTreeMap::new(), &rust);
assert_eq!(
ran(&got, "pnpm-test").as_deref(),
Some("pnpm run test"),
"a polyglot repo runs both suites: {got:?}"
);
}
#[test]
fn the_evidence_a_hook_names_is_the_evidence_it_came_from() {
let (_d, r) = repo(&[
("Makefile", "build:\n\techo\ndeploy:\n\techo\n"),
("package.json", r#"{"scripts":{"test":"vitest run"}}"#),
("pnpm-lock.yaml", ""),
]);
let got = hooks(&r, &BTreeMap::new(), &BTreeSet::new());
let test = got
.iter()
.find(|h| h.name == "pnpm-test")
.unwrap_or_else(|| panic!("the manager branch produced it: {got:?}"));
assert!(
test.from.contains("package.json"),
"a hook from `scripts.test` must say so: {}",
test.from
);
assert!(
!test.from.to_lowercase().contains("make"),
"and must not credit a Makefile whose targets it never used: {}",
test.from
);
}
#[test]
fn a_runner_outranks_a_script() {
let (_d, r) = repo(&[
("package.json", r#"{"scripts":{"test":"vitest run"}}"#),
("pnpm-lock.yaml", ""),
("Makefile", "test:\n\tpnpm run test --reporter dot\n"),
]);
let got = hooks(&r, &BTreeMap::new(), &BTreeSet::new());
assert_eq!(ran(&got, "make-test").as_deref(), Some("make test"));
assert!(
ran(&got, "pnpm-test").is_none(),
"one moment, one hook: {got:?}"
);
}
#[test]
fn a_script_with_no_manager_to_run_it_produces_no_hook() {
let (_d, r) = repo(&[
("package.json", r#"{"scripts":{"test":"vitest run"}}"#),
("yarn.lock", ""),
("pnpm-lock.yaml", ""),
]);
assert_eq!(hooks(&r, &BTreeMap::new(), &BTreeSet::new()), Vec::new());
}
#[test]
fn two_runners_answer_nothing() {
let (_d, r) = repo(&[
("Makefile", "test:\n\techo\n"),
("justfile", "test:\n\techo\n"),
]);
assert_eq!(runner(&r), None);
}
#[test]
fn a_runner_with_no_readable_targets_is_no_runner() {
let (_d, r) = repo(&[("Makefile", "CARGO := cargo\n# nothing here\n")]);
assert_eq!(runner(&r), None);
}
#[test]
fn each_taskfile_refusal_answers_nothing_on_its_own() {
for (why, body) in [
(
"an alias with no merge key still assembles a task elsewhere",
"version: '3'\nx-base: &base\n - echo\ntasks:\n test:\n cmds: *base\n",
),
(
"a merge key with no alias word is still a merge",
"version: '3'\ntasks:\n test:\n <<: {silent: true}\n cmds:\n - echo\n",
),
(
"an anchor alone means the document is assembled",
"version: '3'\ntasks:\n test: &t\n cmds:\n - echo\n",
),
] {
let (_d, r) = repo(&[("Taskfile.yml", body)]);
assert_eq!(runner(&r), None, "{why}");
}
}
#[test]
fn a_runner_answers_to_every_name_it_is_spelled_with() {
for (file, want) in [
("Makefile", Runner::Make),
("makefile", Runner::Make),
("GNUmakefile", Runner::Make),
("justfile", Runner::Just),
("Justfile", Runner::Just),
(".justfile", Runner::Just),
] {
let (_d, r) = repo(&[(file, "test:\n\techo\n")]);
assert_eq!(
runner(&r).map(|(w, _)| w),
Some(want),
"{file} is how this project spells its runner"
);
}
for file in ["Taskfile.yml", "Taskfile.yaml"] {
let (_d, r) = repo(&[(
file,
"version: '3'\ntasks:\n test:\n cmds:\n - echo\n",
)]);
assert_eq!(
runner(&r).map(|(w, _)| w),
Some(Runner::Task),
"{file} is how this project spells its runner"
);
}
}
#[test]
fn an_indented_line_is_a_recipe_body_never_a_rule() {
let (_d, r) = repo(&[(
"Makefile",
"deploy:\n\
\tscp build host:/srv/app\n\
test:\n\
\tdocker run -p 8080:80 img\n",
)]);
assert_eq!(
runner(&r).expect("a Makefile is a runner").1,
["deploy", "test"].map(String::from).into_iter().collect(),
"a colon inside a recipe does not declare a target"
);
}
#[test]
#[cfg(unix)]
fn an_unreadable_runner_still_counts_as_a_runner() {
use std::os::unix::fs::PermissionsExt;
let (_d, r) = repo(&[
("Makefile", "test:\n\techo\n"),
("justfile", "test:\n\techo\n"),
]);
std::fs::set_permissions(r.join("Makefile"), std::fs::Permissions::from_mode(0o000))
.unwrap();
let got = runner(&r);
std::fs::set_permissions(r.join("Makefile"), std::fs::Permissions::from_mode(0o644))
.unwrap();
assert_eq!(
got, None,
"a file omh could not open is not a file omh knows is absent"
);
}
#[test]
fn more_than_one_provisioned_manager_defers_to_the_repo() {
let (_d, r) = repo(&[("package.json", "{}"), ("yarn.lock", "")]);
assert_eq!(
manager(&r, &provisioned(&["node/npm", "node/pnpm"])),
Some(Manager::Yarn),
"the image has both, so the lockfile is what decides"
);
let (_d, r) = repo(&[
("package.json", "{}"),
("yarn.lock", ""),
("pnpm-lock.yaml", ""),
]);
assert_eq!(manager(&r, &provisioned(&["node/npm", "node/pnpm"])), None);
}
#[test]
fn the_provenance_names_the_file_the_command_came_from() {
let (_d, r) = repo(&[
("Makefile", "test:\n\techo\n"),
("package.json", "{\"scripts\":{\"test\":\"vitest run\"}}"),
("pnpm-lock.yaml", ""),
]);
let derived = hooks(&r, &BTreeMap::new(), &BTreeSet::new());
let from = &derived[0].from;
assert_eq!(derived[0].name, "make-test", "the runner supplied it");
assert!(
from.to_lowercase().contains("makefile") && !from.contains("package.json"),
"the Makefile supplied the command, so the Makefile is what \
`omh why` must name: {from}"
);
let (_d, r) = repo(&[
("package.json", "{\"scripts\":{\"test\":\"vitest run\"}}"),
("pnpm-lock.yaml", ""),
]);
let derived = hooks(&r, &BTreeMap::new(), &BTreeSet::new());
let from = &derived[0].from;
assert!(
from.contains("package.json") && from.contains("pnpm"),
"a hook from a script must name the file and the manager: {from}"
);
}
#[test]
fn no_script_outside_the_closed_list_becomes_a_hook() {
for script in ["build", "start", "dev", "check", "lint", "typecheck"] {
let (_d, r) = repo(&[
(
"package.json",
&format!("{{\"scripts\":{{\"{script}\":\"x\"}}}}"),
),
("pnpm-lock.yaml", ""),
]);
assert_eq!(
hooks(&r, &BTreeMap::new(), &BTreeSet::new()),
Vec::new(),
"`{script}` is not a moment omh has an opinion about"
);
}
}
}