use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use runemark::Verdict;
use crate::manifest::Manifest;
use crate::project::PackageManager;
use crate::workspace::Member;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Check {
pub name: &'static str,
pub tool: &'static str,
pub scope: Option<String>,
program: PathBuf,
args: Vec<&'static str>,
dir: PathBuf,
}
impl Check {
pub fn command_line(&self) -> String {
let program = self.program.file_name().map_or_else(
|| self.tool.to_owned(),
|name| name.to_string_lossy().into_owned(),
);
std::iter::once(program)
.chain(self.args.iter().map(|arg| (*arg).to_owned()))
.collect::<Vec<_>>()
.join(" ")
}
pub fn label(&self) -> String {
match &self.scope {
Some(scope) => format!("{} ({scope})", self.name),
None => self.name.to_owned(),
}
}
}
#[derive(Debug, Clone)]
pub struct Report {
pub check: Check,
pub verdict: Verdict,
pub duration: Duration,
pub output: String,
}
impl Report {
pub fn passed(&self) -> bool {
self.verdict == Verdict::Passed
}
pub fn unusable(&self) -> bool {
self.verdict == Verdict::ActionRequired
}
}
struct Candidate {
name: &'static str,
tool: &'static str,
package: &'static str,
args: &'static [&'static str],
}
const CANDIDATES: &[Candidate] = &[
Candidate {
name: "TypeScript",
tool: "astro",
package: "@astrojs/check",
args: &["check"],
},
Candidate {
name: "TypeScript",
tool: "tsc",
package: "typescript",
args: &["--noEmit"],
},
Candidate {
name: "Lint & format",
tool: "biome",
package: "@biomejs/biome",
args: &["check", "."],
},
Candidate {
name: "Lint",
tool: "eslint",
package: "eslint",
args: &["."],
},
Candidate {
name: "Format",
tool: "prettier",
package: "prettier",
args: &["--check", "."],
},
Candidate {
name: "Tests",
tool: "vitest",
package: "vitest",
args: &["run"],
},
Candidate {
name: "Tests",
tool: "jest",
package: "jest",
args: &[],
},
Candidate {
name: "Dead code",
tool: "fallow",
package: "fallow",
args: &["dead-code"],
},
Candidate {
name: "Dead code",
tool: "knip",
package: "knip",
args: &[],
},
Candidate {
name: "Secrets",
tool: "nosecrets",
package: "@casoon/nosecrets",
args: &["scan", "."],
},
Candidate {
name: "Secrets",
tool: "nosecrets",
package: "nosecrets",
args: &["scan", "."],
},
Candidate {
name: "Secrets",
tool: "secretlint",
package: "secretlint",
args: &["**/*"],
},
];
impl Check {
pub fn detect_all(
manifest: &Manifest,
members: &[Member],
rust_root: Option<&Path>,
root: &Path,
manager: PackageManager,
) -> Vec<Self> {
let mut checks = Self::detect_in(manifest, root, None, manager);
checks.extend(Self::lockfile(root, manager));
for member in members {
checks.extend(Self::detect_in(
&member.manifest,
&member.path,
Some(member.name.clone()),
manager,
));
}
if let Some(rust_root) = rust_root {
checks.extend(Self::cargo(rust_root));
}
checks
}
fn cargo(root: &Path) -> Vec<Self> {
let build = |name: &'static str, args: Vec<&'static str>| Self {
name,
tool: "cargo",
scope: Some("rust".to_owned()),
program: PathBuf::from("cargo"),
args,
dir: root.to_path_buf(),
};
let lockfile = root
.join("Cargo.lock")
.exists()
.then(|| build("Lockfile", crate::cargo::LOCKFILE_CHECK.to_vec()));
let members = crate::cargo::virtual_members(root);
let per_member = members.iter().map(|member| Self {
name: "Docs",
tool: "cargo",
scope: Some(member.clone()),
program: PathBuf::from("cargo"),
args: vec![
"rustdoc",
"-p",
Box::leak(member.clone().into_boxed_str()),
"--all-features",
"--",
"-D",
"warnings",
],
dir: root.to_path_buf(),
});
crate::cargo::CHECKS
.iter()
.filter(|(name, _)| members.is_empty() || *name != "Docs")
.map(|(name, args)| build(name, args.to_vec()))
.chain(
crate::cargo::FEATURE_CHECKS
.iter()
.filter(|_| crate::cargo::has_optional_features(root))
.map(|(name, args)| build(name, args.to_vec())),
)
.chain(per_member)
.chain(lockfile)
.chain(crate::cargo::siblings(root).into_iter().map(|sibling| {
let (name, args) = crate::cargo::CHECKS
.iter()
.find(|(name, _)| *name == "Tests")
.expect("Tests is one of the Rust checks");
Self {
name,
tool: "cargo",
scope: Some(sibling.name),
program: PathBuf::from("cargo"),
args: args.to_vec(),
dir: sibling.path,
}
}))
.chain(
crate::cargo::OPTIONAL_CHECKS
.iter()
.filter(|(_, subcommand, _)| crate::cargo::has_subcommand(subcommand))
.map(|(name, _, args)| build(name, args.to_vec())),
)
.collect()
}
fn lockfile(root: &Path, manager: PackageManager) -> Option<Self> {
let args: &[&'static str] = match manager {
PackageManager::Pnpm => &[
"install",
"--frozen-lockfile",
"--lockfile-only",
"--ignore-scripts",
"--offline",
],
PackageManager::Npm => &["ci", "--dry-run", "--ignore-scripts"],
PackageManager::Bun => &["install", "--frozen-lockfile", "--dry-run"],
PackageManager::Yarn => return None,
};
PackageManager::lockfiles_in(root)
.iter()
.any(|(_, found)| *found == manager)
.then(|| Self {
name: "Lockfile",
tool: manager.program(),
scope: None,
program: PathBuf::from(manager.program()),
args: args.to_vec(),
dir: root.to_path_buf(),
})
}
pub fn builds(
manifest: &Manifest,
members: &[Member],
rust_root: Option<&Path>,
root: &Path,
manager: PackageManager,
) -> Vec<Self> {
let script = |dir: &Path, scope: Option<String>| Self {
name: "Build",
tool: manager.program(),
scope,
program: PathBuf::from(manager.program()),
args: vec!["run", "build"],
dir: dir.to_path_buf(),
};
let mut builds = Vec::new();
if manifest.scripts.contains_key("build") {
builds.push(script(root, None));
} else {
builds.extend(
members
.iter()
.filter(|member| member.manifest.scripts.contains_key("build"))
.map(|member| script(&member.path, Some(member.name.clone()))),
);
}
if let Some(rust_root) = rust_root {
let mut args = vec!["build", "--workspace"];
if rust_root.join("Cargo.lock").exists() {
args.push("--locked");
}
builds.push(Self {
name: "Build",
tool: "cargo",
scope: Some("rust".to_owned()),
program: PathBuf::from("cargo"),
args,
dir: rust_root.to_path_buf(),
});
}
builds
}
fn detect_in(
manifest: &Manifest,
dir: &Path,
scope: Option<String>,
manager: PackageManager,
) -> Vec<Self> {
let mut checks: Vec<Self> = Vec::new();
for candidate in CANDIDATES {
if checks.iter().any(|check| check.name == candidate.name) {
continue;
}
if !manifest.depends_on(candidate.package) {
continue;
}
let Some((program, prefix)) = resolve(dir, candidate.tool, manager) else {
continue;
};
checks.push(Self {
name: candidate.name,
tool: candidate.tool,
scope: scope.clone(),
program,
args: prefix
.into_iter()
.chain(candidate.args.iter().copied())
.collect(),
dir: dir.to_path_buf(),
});
}
checks
}
pub fn run(self) -> Report {
let started = Instant::now();
let result = Command::new(&self.program)
.args(&self.args)
.current_dir(&self.dir)
.output();
let (verdict, output) = match result {
Ok(output) => {
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stderr));
let verdict = if output.status.success() {
Verdict::Passed
} else {
Verdict::Failed
};
(verdict, text)
}
Err(error) => (
Verdict::ActionRequired,
format!("could not run {}: {error}", self.tool),
),
};
Report {
check: self,
verdict,
duration: started.elapsed(),
output: output.trim_end().to_owned(),
}
}
}
fn binary(root: &Path, tool: &str) -> Option<PathBuf> {
root.ancestors()
.map(|dir| dir.join("node_modules").join(".bin").join(tool))
.find(|path| path.exists())
}
fn resolve(
dir: &Path,
tool: &'static str,
manager: PackageManager,
) -> Option<(PathBuf, Vec<&'static str>)> {
if let Some(program) = binary(dir, tool) {
return Some((program, Vec::new()));
}
(manager == PackageManager::Yarn).then(|| (PathBuf::from("yarn"), vec!["run", tool]))
}
pub fn run_all(checks: Vec<Check>, mut on_report: impl FnMut(&Report)) -> Vec<Report> {
use std::sync::mpsc;
let limit = std::thread::available_parallelism()
.map_or(2, std::num::NonZero::get)
.clamp(1, 4);
let mut reports = Vec::with_capacity(checks.len());
let mut queue = checks.into_iter();
let (sender, receiver) = mpsc::channel();
let mut running = 0usize;
std::thread::scope(|scope| {
loop {
while running < limit {
let Some(check) = queue.next() else { break };
let sender = sender.clone();
running += 1;
scope.spawn(move || {
let _ = sender.send(check.run());
});
}
if running == 0 {
break;
}
let Ok(report) = receiver.recv() else { break };
running -= 1;
on_report(&report);
reports.push(report);
}
});
reports.sort_by_key(|report| report.check.label());
reports
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn resolve_prefers_a_filesystem_binary_over_yarn() {
let dir = tempfile::tempdir().expect("temp dir");
let bin = dir.path().join("node_modules").join(".bin");
fs::create_dir_all(&bin).expect("mkdir");
fs::write(bin.join("eslint"), "").expect("write");
let (program, prefix) =
resolve(dir.path(), "eslint", PackageManager::Yarn).expect("found on disk");
assert_eq!(program, bin.join("eslint"));
assert!(prefix.is_empty(), "a real binary needs no yarn prefix");
}
#[test]
fn resolve_falls_back_to_yarn_run_when_nothing_is_on_disk() {
let dir = tempfile::tempdir().expect("temp dir");
let (program, prefix) =
resolve(dir.path(), "eslint", PackageManager::Yarn).expect("resolved through yarn");
assert_eq!(program, PathBuf::from("yarn"));
assert_eq!(prefix, ["run", "eslint"]);
}
#[test]
fn resolve_does_not_guess_for_other_managers() {
let dir = tempfile::tempdir().expect("temp dir");
for manager in [
PackageManager::Npm,
PackageManager::Pnpm,
PackageManager::Bun,
] {
assert!(
resolve(dir.path(), "eslint", manager).is_none(),
"{manager:?} has no node_modules here and no yarn fallback to try"
);
}
}
#[test]
fn a_yarn_pnp_project_still_gets_its_checks() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(
dir.path().join("package.json"),
r#"{"name":"pnp","devDependencies":{"eslint":"9.0.0"}}"#,
)
.expect("write");
let manifest = Manifest::load(dir.path()).expect("manifest");
let checks = Check::detect_in(&manifest, dir.path(), None, PackageManager::Yarn);
let lint = checks
.iter()
.find(|check| check.name == "Lint")
.expect("eslint check is offered under Yarn PnP");
assert_eq!(lint.command_line(), "yarn run eslint .");
}
#[test]
fn a_lockfile_is_checked_by_the_manager_that_wrote_it() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("write");
let check = Check::lockfile(dir.path(), PackageManager::Pnpm).expect("checked");
assert_eq!(
check.command_line(),
"pnpm install --frozen-lockfile --lockfile-only --ignore-scripts --offline"
);
}
#[test]
fn no_lockfile_means_no_lockfile_check() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(Check::lockfile(dir.path(), PackageManager::Npm).is_none());
}
#[test]
fn another_managers_lockfile_is_not_this_ones() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(dir.path().join("package-lock.json"), "{}").expect("write");
assert!(Check::lockfile(dir.path(), PackageManager::Pnpm).is_none());
}
#[test]
fn yarn_gets_no_lockfile_check() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(dir.path().join("yarn.lock"), "").expect("write");
assert!(Check::lockfile(dir.path(), PackageManager::Yarn).is_none());
}
#[test]
fn a_cargo_lockfile_is_checked_only_where_one_is_committed() {
let dir = tempfile::tempdir().expect("temp dir");
let named = |checks: Vec<Check>| checks.iter().any(|check| check.name == "Lockfile");
assert!(
!named(Check::cargo(dir.path())),
"a library without Cargo.lock"
);
fs::write(dir.path().join("Cargo.lock"), "").expect("write");
let checks = Check::cargo(dir.path());
let lockfile = checks
.iter()
.find(|check| check.name == "Lockfile")
.expect("checked");
assert_eq!(lockfile.label(), "Lockfile (rust)");
assert_eq!(
lockfile.command_line(),
"cargo metadata --locked --format-version 1"
);
}
#[test]
fn the_same_pnp_project_offers_nothing_under_npm() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(
dir.path().join("package.json"),
r#"{"name":"solo","devDependencies":{"eslint":"9.0.0"}}"#,
)
.expect("write");
let manifest = Manifest::load(dir.path()).expect("manifest");
let checks = Check::detect_in(&manifest, dir.path(), None, PackageManager::Npm);
assert!(
checks.is_empty(),
"no installed eslint, no yarn to fall back to"
);
}
}