use super::common::program;
use crate::check::Outcome;
use crate::git;
use std::process::{Command, Stdio};
const GATE: [&str; 3] = ["typecheck", "test:unit", "test"];
pub const JS_EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
fn is_js(file: &str) -> bool {
JS_EXTS.iter().any(|e| file.ends_with(e))
}
fn parent_of(path: &str) -> &str {
match path.rfind('/') {
Some(i) => &path[..i],
None => "",
}
}
pub fn defines_script(pkg_json: &str, script: &str) -> bool {
let Some(k) = pkg_json.find("\"scripts\"") else {
return false;
};
let Some(open_rel) = pkg_json[k..].find('{') else {
return false;
};
let open = k + open_rel;
let bytes = pkg_json.as_bytes();
let mut depth = 0usize;
let mut end = open;
let mut in_str = false;
let mut escaped = false;
for (i, &c) in bytes.iter().enumerate().skip(open) {
if in_str {
if escaped {
escaped = false;
} else if c == b'\\' {
escaped = true;
} else if c == b'"' {
in_str = false;
}
continue;
}
match c {
b'"' => in_str = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
}
if end <= open {
return false;
}
let body = &pkg_json[open..=end];
let needle = format!("\"{script}\"");
let mut from = 0;
while let Some(i) = body[from..].find(&needle) {
let at = from + i;
let after = &body[at + needle.len()..];
if after.trim_start().starts_with(':') {
return true;
}
from = at + needle.len();
}
false
}
pub fn package_dirs(ls_files: &[String]) -> Vec<String> {
let mut dirs: Vec<String> = ls_files
.iter()
.map(String::as_str)
.filter(|f| *f == "package.json" || f.ends_with("/package.json"))
.map(|f| parent_of(f).to_string())
.collect();
dirs.sort();
dirs.dedup();
dirs
}
pub fn packages_to_test(pkg_dirs: &[String], changed_dirs: &[String]) -> Vec<String> {
pkg_dirs
.iter()
.filter(|pkg| {
changed_dirs.iter().any(|dir| {
if pkg.is_empty() {
true
} else {
dir == *pkg || dir.starts_with(&format!("{pkg}/"))
}
})
})
.cloned()
.collect()
}
fn run_gate(root: &str, folder: &str) -> bool {
let dir = if folder.is_empty() {
root.to_string()
} else {
format!("{root}/{folder}")
};
let Ok(pkg) = std::fs::read_to_string(format!("{dir}/package.json")) else {
return true;
};
for script in GATE.iter().filter(|s| defines_script(&pkg, s)) {
let mut cmd = Command::new(program("npm"));
cmd.args(["run", script])
.current_dir(&dir)
.stdin(Stdio::null());
super::common::strip_git_env(&mut cmd);
let status = cmd.status();
match status {
Ok(status) if status.success() => {}
Ok(_) | Err(_) => return false,
}
}
true
}
pub fn run(refs: &[crate::pushrefs::PushRef]) -> Outcome {
let zero = git::stdout(&["hash-object", "--stdin"])
.map(|h| "0".repeat(h.len()))
.unwrap_or_else(|| "0".repeat(40));
let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
return Outcome::Passed;
};
let pkg_dirs = git::stdout_paths(&["ls-files"])
.map(|f| package_dirs(&f))
.unwrap_or_default();
for r in refs {
let local_oid = r.local_oid.as_str();
if local_oid == zero {
continue; }
let changed = crate::pushrefs::changed_files_for(r, &zero);
let changed_dirs: Vec<String> = changed
.iter()
.map(String::as_str)
.filter(|f| is_js(f))
.map(|f| parent_of(f).to_string())
.collect();
if changed_dirs.is_empty() {
continue;
}
let (run_in, _guard) = crate::pushed_tree::where_to_run(local_oid, &root);
let where_ = run_in.to_string_lossy().into_owned();
for folder in packages_to_test(&pkg_dirs, &changed_dirs) {
if !run_gate(&where_, &folder) {
return Outcome::Failed;
}
}
}
Outcome::Passed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_scripts_only_inside_the_scripts_object() {
let pkg = r#"{"name":"x","scripts":{"test":"vitest","typecheck":"tsc"},"devDependencies":{"lint":"1"}}"#;
assert!(defines_script(pkg, "test"));
assert!(defines_script(pkg, "typecheck"));
assert!(!defines_script(pkg, "test:unit"));
assert!(!defines_script(pkg, "lint"));
}
#[test]
fn survives_nested_objects_and_escaped_quotes() {
let pkg = r#"{"scripts":{"test":"echo \"hi\"","build":"x"},"other":{"test":"no"}}"#;
assert!(defines_script(pkg, "test"));
assert!(defines_script(pkg, "build"));
assert!(!defines_script(pkg, "other"));
}
#[test]
fn no_scripts_object_means_no_scripts() {
assert!(!defines_script(r#"{"name":"x"}"#, "test"));
}
#[test]
fn collects_package_directories() {
let ls: Vec<String> = [
"package.json",
"apps/web/package.json",
"apps/web/src/a.ts",
"README.md",
]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
package_dirs(&ls),
vec!["".to_string(), "apps/web".to_string()]
);
}
#[test]
fn selects_only_packages_containing_a_change() {
let pkgs = vec!["apps/web".to_string(), "apps/api".to_string()];
let changed = vec!["apps/web/src".to_string()];
assert_eq!(
packages_to_test(&pkgs, &changed),
vec!["apps/web".to_string()]
);
}
#[test]
fn the_repo_root_package_matches_any_change() {
let pkgs = vec!["".to_string()];
assert_eq!(
packages_to_test(&pkgs, &["src".to_string()]),
vec!["".to_string()]
);
}
}