use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::{CommandNode, RootSpec, RunContext};
#[derive(Debug, Clone)]
struct ScriptEntry {
path: PathBuf,
dependencies: Vec<String>,
}
pub fn collect_chain_env(chain: &[String], spec: &RootSpec) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
let mut map = &spec.commands;
for seg in chain {
let Some(node) = map.get(seg) else { break };
for (k, v) in &node.env {
env.insert(k.clone(), v.clone());
}
map = &node.commands;
}
env
}
pub fn collect_chain_metadata(
chain: &[String],
spec: &RootSpec,
) -> (Vec<String>, Vec<String>, Option<String>) {
let mut deps = Vec::new();
let mut requires = Vec::new();
let mut path = None;
let mut map = &spec.commands;
for seg in chain {
let Some(node) = map.get(seg) else { break };
deps.extend(node.dependencies.iter().cloned());
requires.extend(node.requires.iter().cloned());
if node.path.is_some() {
path = node.path.clone();
}
map = &node.commands;
}
deps.sort();
deps.dedup();
requires.sort();
requires.dedup();
(deps, requires, path)
}
fn resolve_path_str(raw: &str, ctx: &RunContext<'_>) -> Result<PathBuf> {
let p = Path::new(raw.trim());
if p.is_absolute() {
return p
.canonicalize()
.with_context(|| format!("resolve path {}", p.display()));
}
if let Ok(root) = std::env::var("JAN_SCRIPTS_ROOT") {
let candidate = PathBuf::from(root.trim()).join(p);
if candidate.is_dir() {
return candidate
.canonicalize()
.with_context(|| format!("resolve path {}", candidate.display()));
}
}
let spec_dir = PathBuf::from(&ctx.spec_root.spec_dir);
for base in [
ctx.cwd,
spec_dir.as_path(),
spec_dir.parent().unwrap_or(Path::new(".")),
] {
let candidate = base.join(p);
if candidate.is_dir() {
return candidate
.canonicalize()
.with_context(|| format!("resolve path {}", candidate.display()));
}
}
bail!(
"could not resolve script path `{}` (tried cwd, spec dir, spec parent, and JAN_SCRIPTS_ROOT)",
raw
);
}
fn index_scripts(spec: &RootSpec, ctx: &RunContext<'_>) -> Result<BTreeMap<String, ScriptEntry>> {
let mut index = BTreeMap::new();
index_commands(&spec.commands, ctx, &mut index)?;
Ok(index)
}
fn index_commands(
map: &BTreeMap<String, CommandNode>,
ctx: &RunContext<'_>,
index: &mut BTreeMap<String, ScriptEntry>,
) -> Result<()> {
for (name, node) in map {
if let Some(ref raw_path) = node.path {
if let Ok(path) = resolve_path_str(raw_path, ctx) {
let entry = ScriptEntry {
path,
dependencies: node.dependencies.clone(),
};
if let Some(prev) = index.get(name) {
if prev.path != entry.path {
bail!(
"duplicate script name `{name}` with different paths ({} vs {})",
prev.path.display(),
entry.path.display()
);
}
}
index.insert(name.clone(), entry);
}
}
index_commands(&node.commands, ctx, index)?;
}
Ok(())
}
fn visit_dependency(
name: &str,
index: &BTreeMap<String, ScriptEntry>,
visiting: &mut HashSet<String>,
visited: &mut HashSet<String>,
ordered: &mut Vec<PathBuf>,
) -> Result<()> {
if visited.contains(name) {
return Ok(());
}
if !visiting.insert(name.to_string()) {
bail!("cyclic script dependency involving `{name}`");
}
let Some(entry) = index.get(name) else {
return Ok(());
};
for dep in &entry.dependencies {
visit_dependency(dep, index, visiting, visited, ordered)?;
}
visiting.remove(name);
visited.insert(name.to_string());
ordered.push(entry.path.clone());
Ok(())
}
pub fn resolve_path_prefixes(
spec: &RootSpec,
chain: &[String],
ctx: &RunContext<'_>,
) -> Result<Vec<PathBuf>> {
let (dep_names, _, own_path) = collect_chain_metadata(chain, spec);
let index = index_scripts(spec, ctx)?;
let mut visiting = HashSet::new();
let mut visited = HashSet::new();
let mut dirs = Vec::new();
for name in &dep_names {
visit_dependency(name, &index, &mut visiting, &mut visited, &mut dirs)?;
}
if let Some(raw) = own_path {
if let Ok(own) = resolve_path_str(&raw, ctx) {
if !dirs.iter().any(|p| p == &own) {
dirs.push(own);
}
}
}
Ok(dirs)
}
pub fn prepend_path_env(dirs: &[PathBuf]) -> Result<String> {
let current = std::env::var("PATH").unwrap_or_default();
let sep = if cfg!(windows) { ";" } else { ":" };
let mut parts: Vec<String> = dirs
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
if !current.is_empty() {
parts.push(current);
}
Ok(parts.join(sep))
}
pub fn check_requires(requires: &[String]) -> Result<()> {
let mut missing = Vec::new();
for name in requires {
let name = name.trim();
if name.is_empty() {
continue;
}
if !utility_available(name) {
missing.push(name.to_string());
}
}
if missing.is_empty() {
return Ok(());
}
bail!(
"missing required utilities on PATH: {} (install them or adjust the spec `requires` list)",
missing.join(", ")
);
}
fn utility_available(name: &str) -> bool {
if which_in_path(name) {
return true;
}
Command::new(if cfg!(windows) { "where" } else { "which" })
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn which_in_path(name: &str) -> bool {
let path_var = match std::env::var("PATH") {
Ok(p) => p,
Err(_) => return false,
};
let sep = if cfg!(windows) { ';' } else { ':' };
for dir in path_var.split(sep) {
let candidate = Path::new(dir).join(name);
if candidate.is_file() {
return true;
}
#[cfg(windows)]
{
for ext in ["exe", "cmd", "bat", "com"] {
let with_ext = Path::new(dir).join(format!("{name}.{ext}"));
if with_ext.is_file() {
return true;
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ExecSpec, SpecRootIdentity};
use std::collections::BTreeMap;
use std::fs;
use tempfile::tempdir;
#[test]
fn transitive_dependencies_prepended_in_order() {
let dir = tempdir().unwrap();
let dep_a = dir.path().join("a");
let dep_b = dir.path().join("b");
let main = dir.path().join("main");
fs::create_dir_all(&dep_a).unwrap();
fs::create_dir_all(&dep_b).unwrap();
fs::create_dir_all(&main).unwrap();
let spec = RootSpec {
metadata: None,
commands: BTreeMap::from([
(
"a".into(),
CommandNode {
path: Some(dep_a.to_string_lossy().into_owned()),
dependencies: vec![],
..Default::default()
},
),
(
"b".into(),
CommandNode {
path: Some(dep_b.to_string_lossy().into_owned()),
dependencies: vec!["a".into()],
..Default::default()
},
),
(
"main".into(),
CommandNode {
path: Some(main.to_string_lossy().into_owned()),
dependencies: vec!["b".into()],
commands: BTreeMap::from([(
"run".into(),
CommandNode {
exec: Some(ExecSpec {
argv: vec!["echo".into()],
passthrough: false,
}),
..Default::default()
},
)]),
..Default::default()
},
),
]),
};
let identity = SpecRootIdentity {
spec_dir: dir.path().to_string_lossy().into_owned(),
root_yaml: "spec.yaml".into(),
};
let ctx = RunContext {
cwd: dir.path(),
db_path: None,
branch: "test".into(),
no_log: true,
spec_root: &identity,
};
let dirs = resolve_path_prefixes(&spec, &["main".into(), "run".into()], &ctx).unwrap();
assert_eq!(dirs.len(), 3);
assert_eq!(dirs[0], dep_a.canonicalize().unwrap());
assert_eq!(dirs[1], dep_b.canonicalize().unwrap());
assert_eq!(dirs[2], main.canonicalize().unwrap());
}
#[test]
fn cyclic_dependency_errors() {
let dir = tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
let spec = RootSpec {
metadata: None,
commands: BTreeMap::from([
(
"a".into(),
CommandNode {
path: Some(a.to_string_lossy().into_owned()),
dependencies: vec!["b".into()],
..Default::default()
},
),
(
"b".into(),
CommandNode {
path: Some(b.to_string_lossy().into_owned()),
dependencies: vec!["a".into()],
..Default::default()
},
),
(
"run".into(),
CommandNode {
dependencies: vec!["a".into()],
exec: Some(ExecSpec {
argv: vec!["echo".into()],
passthrough: false,
}),
..Default::default()
},
),
]),
};
let identity = SpecRootIdentity {
spec_dir: dir.path().to_string_lossy().into_owned(),
root_yaml: "spec.yaml".into(),
};
let ctx = RunContext {
cwd: dir.path(),
db_path: None,
branch: "test".into(),
no_log: true,
spec_root: &identity,
};
let err = resolve_path_prefixes(&spec, &["run".into()], &ctx).unwrap_err();
assert!(err.to_string().contains("cyclic"));
}
#[test]
fn chain_env_later_overrides_earlier() {
let spec = RootSpec {
metadata: None,
commands: BTreeMap::from([(
"a".into(),
CommandNode {
env: BTreeMap::from([("X".into(), "1".into()), ("Y".into(), "a".into())]),
commands: BTreeMap::from([(
"b".into(),
CommandNode {
env: BTreeMap::from([("X".into(), "2".into())]),
..Default::default()
},
)]),
..Default::default()
},
)]),
};
let env = collect_chain_env(&["a".into(), "b".into()], &spec);
assert_eq!(env.get("X").map(String::as_str), Some("2"));
assert_eq!(env.get("Y").map(String::as_str), Some("a"));
}
#[test]
fn check_requires_reports_missing() {
let err = check_requires(&["definitely-not-a-real-binary-xyz".to_string()]).unwrap_err();
assert!(err.to_string().contains("missing required utilities"));
}
}