use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime};
use anyhow::{bail, Context, Result};
use serde_json::json;
use crate::remote::{self, cache_root};
use crate::spec_load;
use crate::{
CommandNode, GradleDeps, GradlePackages, PackagesSpec, PnpmDeps, PnpmPackages, RootSpec,
RunContext, UvDeps, UvPackages,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Manager {
Uv,
Pnpm,
Gradle,
}
impl Manager {
pub fn id(&self) -> &'static str {
match self {
Self::Uv => "uv",
Self::Pnpm => "pnpm",
Self::Gradle => "gradle",
}
}
pub fn envs_root(&self) -> Result<PathBuf> {
let d = cache_root()?.join("packages").join(self.id());
fs::create_dir_all(&d).with_context(|| format!("create {}", d.display()))?;
Ok(d)
}
pub fn bin_dir(&self, root: &Path) -> PathBuf {
match self {
Self::Uv => venv_bin_dir(root),
Self::Pnpm => root.join("node_modules").join(".bin"),
Self::Gradle => root.join("lib"),
}
}
}
#[derive(Debug, Clone)]
pub struct UvEnv {
pub bin_dir: PathBuf,
}
#[derive(Debug, Clone)]
pub struct PnpmEnv {
pub bin_dir: PathBuf,
pub modules_dir: PathBuf,
}
#[derive(Debug, Clone)]
pub struct GradleEnv {
pub lib_dir: PathBuf,
pub classpath: String,
}
#[derive(Debug, Clone, Default)]
pub struct EnsuredEnvs {
pub uv: Option<UvEnv>,
pub pnpm: Option<PnpmEnv>,
pub gradle: Option<GradleEnv>,
}
impl EnsuredEnvs {
pub fn is_empty(&self) -> bool {
self.uv.is_none() && self.pnpm.is_none() && self.gradle.is_none()
}
}
pub fn venv_python(bin_dir: &Path) -> PathBuf {
if cfg!(windows) {
return bin_dir.join("python.exe");
}
let p3 = bin_dir.join("python3");
if p3.is_file() {
p3
} else {
bin_dir.join("python")
}
}
pub fn collect_chain_packages(chain: &[String], spec: &RootSpec) -> PackagesSpec {
let mut pkgs = PackagesSpec::default();
let mut map = &spec.commands;
for seg in chain {
let Some(node) = map.get(seg) else { break };
pkgs.merge_from(node.packages.clone());
map = &node.commands;
}
pkgs
}
pub fn ensure_packages(pkgs: &PackagesSpec, ctx: &RunContext<'_>) -> Result<EnsuredEnvs> {
pkgs.validate(&chain_label(ctx))?;
let mut out = EnsuredEnvs::default();
if let Some(uv) = &pkgs.uv {
out.uv = Some(ensure_uv(uv, ctx)?);
}
if let Some(pnpm) = &pkgs.pnpm {
out.pnpm = Some(ensure_pnpm(pnpm, ctx)?);
}
if let Some(gradle) = &pkgs.gradle {
out.gradle = Some(ensure_gradle(gradle, ctx)?);
}
Ok(out)
}
fn chain_label(_ctx: &RunContext<'_>) -> String {
"run".to_string()
}
pub fn check_pinned_requirement(entry: &str) -> Result<()> {
let raw = entry.trim();
let mut head = raw;
if let Some(idx) = head.find(" #") {
head = &head[..idx];
}
if let Some(idx) = head.find(';') {
head = &head[..idx];
}
let head = head.trim().trim_end_matches('\\').trim();
if head.is_empty() {
bail!("packages.uv entry must not be empty");
}
if let Some((name, target)) = head.split_once('@') {
if name.trim().is_empty() {
bail!("packages.uv entry `{raw}` is missing a package name");
}
if !target.contains("#sha256=") {
bail!(
"packages.uv entry `{raw}` is not pinned: a direct reference needs a \
`#sha256=<hex>` fragment"
);
}
return Ok(());
}
let Some(idx) = head.find(['=', '<', '>', '!', '~']) else {
bail!("packages.uv entry `{raw}` must pin a version, e.g. `{head}==1.2.3`");
};
if head[..idx].trim().is_empty() {
bail!("packages.uv entry `{raw}` is missing a package name");
}
let spec = head[idx..].trim();
let version = spec
.strip_prefix("===")
.or_else(|| spec.strip_prefix("=="))
.map(str::trim)
.ok_or_else(|| {
anyhow::anyhow!(
"packages.uv entry `{raw}` must pin an exact version with `==`, not `{spec}`"
)
})?;
if version.is_empty() {
bail!("packages.uv entry `{raw}` is missing a version after `==`");
}
if version.contains(',') {
bail!("packages.uv entry `{raw}` must name exactly one version");
}
if version.contains('*') {
bail!("packages.uv entry `{raw}` must pin an exact version, not a wildcard");
}
Ok(())
}
fn check_requirements_pins(file: &Path) -> Result<()> {
let text = fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?;
for (i, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('-') {
let flag = line
.split_whitespace()
.next()
.unwrap_or(line)
.split('=')
.next()
.unwrap_or(line);
if matches!(flag, "-r" | "--requirement" | "-e" | "--editable") {
bail!(
"{}:{}: `{flag}` is not allowed in packages.uv requirements — list pinned \
requirements directly so the file hash covers them",
file.display(),
i + 1
);
}
continue;
}
let requirement = line.split(" --").next().unwrap_or(line);
check_pinned_requirement(requirement)
.with_context(|| format!("{}:{}", file.display(), i + 1))?;
}
Ok(())
}
pub fn uv_canonical(uv: &UvPackages, use_root: &Path) -> Result<String> {
let body = match &uv.deps {
UvDeps::List(pkgs) => {
let mut sorted: Vec<String> = pkgs.iter().map(|s| s.trim().to_string()).collect();
sorted.sort();
sorted.dedup();
for p in &sorted {
check_pinned_requirement(p)?;
}
format!("list:\n{}", sorted.join("\n"))
}
UvDeps::Project(rel) => {
let dir = spec_load::resolve_under_use_root_any(use_root, rel)?;
if !dir.is_dir() {
bail!("packages.uv.project is not a directory: {}", dir.display());
}
let pyproject = dir.join("pyproject.toml");
if !pyproject.is_file() {
bail!(
"packages.uv.project missing pyproject.toml: {}",
dir.display()
);
}
let lock = project_lock(&dir)?;
let digest = remote::sha256_file(&pyproject)?;
let lock_digest = remote::sha256_file(&lock)?;
format!(
"project:{}#{digest}\nlock:{lock_digest}",
dir.to_string_lossy()
)
}
UvDeps::Requirements(rel) => {
let file = spec_load::resolve_under_use_root(use_root, rel)?;
check_requirements_pins(&file)?;
let digest = remote::sha256_file(&file)?;
format!("requirements:{}#{}", file.to_string_lossy(), digest)
}
};
Ok(with_runtime_req(body, "python", uv.python.as_deref()))
}
fn project_lock(dir: &Path) -> Result<PathBuf> {
let lock = dir.join("uv.lock");
if !lock.is_file() {
bail!(
"packages.uv.project must be locked: no uv.lock in {} (run `uv lock`)",
dir.display()
);
}
Ok(lock)
}
pub fn check_pinned_npm_spec(entry: &str) -> Result<()> {
let raw = entry.trim();
if raw.is_empty() {
bail!("packages.pnpm entry must not be empty");
}
let Some(at) = raw.rfind('@').filter(|i| *i > 0) else {
bail!("packages.pnpm entry `{raw}` must pin a version, e.g. `{raw}@1.2.3`");
};
let (name, version) = (raw[..at].trim(), raw[at + 1..].trim());
if name.is_empty() {
bail!("packages.pnpm entry `{raw}` is missing a package name");
}
let bare = match name.strip_prefix('@') {
Some(scoped) => match scoped.split_once('/') {
Some((scope, rest)) if !scope.is_empty() && !rest.is_empty() => rest,
_ => bail!("packages.pnpm entry `{raw}` has a malformed scoped name `{name}`"),
},
None => name,
};
if bare.contains(['@', '/', ':']) {
bail!(
"packages.pnpm entry `{raw}` must name a registry package and version, not an alias \
or URL"
);
}
if version.is_empty() {
bail!("packages.pnpm entry `{raw}` is missing a version after `@`");
}
if version.contains(':') || version.contains('/') {
bail!(
"packages.pnpm entry `{raw}` is not a registry version: aliases, git, and file \
specs cannot be pinned by version"
);
}
if !version.starts_with(|c: char| c.is_ascii_digit()) {
bail!(
"packages.pnpm entry `{raw}` must pin an exact version, not `{version}` \
(ranges and dist-tags drift)"
);
}
if version.contains(['|', ' ', ',', '*', 'x', 'X']) {
bail!("packages.pnpm entry `{raw}` must name exactly one version, not `{version}`");
}
let core = version.split('+').next().unwrap_or(version);
let core = core.split('-').next().unwrap_or(core);
let parts: Vec<&str> = core.split('.').collect();
if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) {
bail!(
"packages.pnpm entry `{raw}` must pin a full `major.minor.patch` version, not \
`{version}`"
);
}
Ok(())
}
fn pnpm_lock(dir: &Path) -> Result<PathBuf> {
let lock = dir.join("pnpm-lock.yaml");
if !lock.is_file() {
bail!(
"packages.pnpm.project must be locked: no pnpm-lock.yaml in {} (run `pnpm install \
--lockfile-only`)",
dir.display()
);
}
Ok(lock)
}
pub fn pnpm_canonical(pnpm: &PnpmPackages, use_root: &Path) -> Result<String> {
let body = match &pnpm.deps {
PnpmDeps::List(pkgs) => {
let mut sorted: Vec<String> = pkgs.iter().map(|s| s.trim().to_string()).collect();
sorted.sort();
sorted.dedup();
for p in &sorted {
check_pinned_npm_spec(p)?;
}
format!("list:\n{}", sorted.join("\n"))
}
PnpmDeps::Project(rel) => {
let dir = spec_load::resolve_under_use_root_any(use_root, rel)?;
if !dir.is_dir() {
bail!(
"packages.pnpm.project is not a directory: {}",
dir.display()
);
}
let manifest = dir.join("package.json");
if !manifest.is_file() {
bail!(
"packages.pnpm.project missing package.json: {}",
dir.display()
);
}
let lock = pnpm_lock(&dir)?;
let digest = remote::sha256_file(&manifest)?;
let lock_digest = remote::sha256_file(&lock)?;
format!(
"project:{}#{digest}\nlock:{lock_digest}",
dir.to_string_lossy()
)
}
};
Ok(with_runtime_req(body, "node", pnpm.node.as_deref()))
}
pub fn canonical_for(
manager: Manager,
pkgs: &PackagesSpec,
use_root: &Path,
) -> Option<Result<String>> {
match manager {
Manager::Uv => pkgs.uv.as_ref().map(|uv| uv_canonical(uv, use_root)),
Manager::Pnpm => pkgs.pnpm.as_ref().map(|p| pnpm_canonical(p, use_root)),
Manager::Gradle => pkgs.gradle.as_ref().map(|g| gradle_canonical(g, use_root)),
}
}
pub fn check_pinned_maven_coord(entry: &str) -> Result<()> {
let raw = entry.trim();
if raw.is_empty() {
bail!("packages.gradle entry must not be empty");
}
let parts: Vec<&str> = raw.split(':').collect();
if parts.len() != 3 {
bail!(
"packages.gradle entry `{raw}` must be `group:artifact:version` (exactly three \
colon-separated fields)"
);
}
let (group, artifact, version) = (parts[0].trim(), parts[1].trim(), parts[2].trim());
if group.is_empty() || artifact.is_empty() {
bail!("packages.gradle entry `{raw}` is missing group or artifact");
}
if version.is_empty() {
bail!("packages.gradle entry `{raw}` must pin a version");
}
if version.contains(['[', ']', '(', ')', ',', '+', '*', ' '])
|| version.eq_ignore_ascii_case("latest.release")
|| version.eq_ignore_ascii_case("latest.integration")
|| version.eq_ignore_ascii_case("release")
{
bail!(
"packages.gradle entry `{raw}` must pin an exact version, not a range or dynamic \
version (`{version}`)"
);
}
if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) {
bail!("packages.gradle entry `{raw}` must pin an exact numeric version, not `{version}`");
}
Ok(())
}
fn gradle_lock(dir: &Path) -> Result<PathBuf> {
let lock = dir.join("gradle.lockfile");
if !lock.is_file() {
bail!(
"packages.gradle.project must be locked: no gradle.lockfile in {} (enable dependency \
locking and run `gradle dependencies --write-locks`)",
dir.display()
);
}
Ok(lock)
}
fn gradle_build_file(dir: &Path) -> Result<PathBuf> {
for name in ["build.gradle.kts", "build.gradle"] {
let p = dir.join(name);
if p.is_file() {
return Ok(p);
}
}
bail!(
"packages.gradle.project missing build.gradle(.kts): {}",
dir.display()
)
}
pub fn gradle_canonical(gradle: &GradlePackages, use_root: &Path) -> Result<String> {
let body = match &gradle.deps {
GradleDeps::List(pkgs) => {
let mut sorted: Vec<String> = pkgs.iter().map(|s| s.trim().to_string()).collect();
sorted.sort();
sorted.dedup();
for p in &sorted {
check_pinned_maven_coord(p)?;
}
format!("list:\n{}", sorted.join("\n"))
}
GradleDeps::Project(rel) => {
let dir = spec_load::resolve_under_use_root_any(use_root, rel)?;
if !dir.is_dir() {
bail!(
"packages.gradle.project is not a directory: {}",
dir.display()
);
}
let build = gradle_build_file(&dir)?;
let lock = gradle_lock(&dir)?;
let digest = remote::sha256_file(&build)?;
let lock_digest = remote::sha256_file(&lock)?;
format!(
"project:{}#{digest}\nlock:{lock_digest}",
dir.to_string_lossy()
)
}
};
Ok(with_runtime_req(body, "java", gradle.java.as_deref()))
}
fn with_runtime_req(body: String, key: &str, raw: Option<&str>) -> String {
match raw.map(str::trim).filter(|s| !s.is_empty()) {
Some(v) => match parse_min_version_constraint(v) {
Ok((label, _)) => format!("{key}:{label}\n{body}"),
Err(_) => format!("{key}:{v}\n{body}"),
},
None => body,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct MinVersion {
pub major: u64,
pub minor: u64,
pub patch: u64,
}
impl MinVersion {
pub fn new(major: u64, minor: u64, patch: u64) -> Self {
Self {
major,
minor,
patch,
}
}
pub fn display(&self) -> String {
if self.patch == 0 && self.minor == 0 {
format!("{}", self.major)
} else if self.patch == 0 {
format!("{}.{}", self.major, self.minor)
} else {
format!("{}.{}.{}", self.major, self.minor, self.patch)
}
}
}
pub fn parse_min_version_constraint(raw: &str) -> Result<(String, MinVersion)> {
let s = raw.trim();
if s.is_empty() {
bail!("version constraint must not be empty");
}
let s = s
.strip_prefix(">=")
.or_else(|| s.strip_prefix('='))
.unwrap_or(s)
.trim();
if s.starts_with(['<', '>', '~', '^', '*']) {
bail!("only bare versions or `>=` minima are supported (got `{raw}`)");
}
let ver = parse_dotted_version(s)
.with_context(|| format!("invalid version `{raw}` (expected like `21` or `3.11`)"))?;
let label = format!(">={}", ver.display());
Ok((label, ver))
}
fn parse_dotted_version(s: &str) -> Result<MinVersion> {
let s = s.trim().trim_start_matches('v');
let mut parts = s.split('.');
let major: u64 = parts
.next()
.ok_or_else(|| anyhow::anyhow!("missing major"))?
.parse()?;
let minor: u64 = match parts.next() {
Some(p) if !p.is_empty() => p
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.unwrap_or(0),
_ => 0,
};
let patch: u64 = match parts.next() {
Some(p) if !p.is_empty() => p
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.unwrap_or(0),
_ => 0,
};
Ok(MinVersion::new(major, minor, patch))
}
fn extract_version_from_tool_output(text: &str) -> Option<MinVersion> {
for caps in [
r#""(\d+(?:\.\d+){0,2})""#,
r#"version\s+(\d+(?:\.\d+){0,2})"#,
r#"\bv?(\d+\.\d+\.\d+)\b"#,
r#"\bv?(\d+\.\d+)\b"#,
r#"\bv?(\d+)\b"#,
] {
if let Some(re) = regex_first_capture(caps, text) {
if let Ok(mut ver) = parse_dotted_version(&re) {
if ver.major == 1 && ver.minor > 0 {
ver = MinVersion::new(ver.minor, 0, 0);
}
return Some(ver);
}
}
}
None
}
fn regex_first_capture(pat: &str, text: &str) -> Option<String> {
match pat {
r#""(\d+(?:\.\d+){0,2})""# => {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'"' {
let start = i + 1;
let mut j = start;
while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b'.') {
j += 1;
}
if j < bytes.len() && bytes[j] == b'"' && j > start {
return Some(text[start..j].to_string());
}
}
i += 1;
}
None
}
_ => {
let mut best: Option<String> = None;
for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.' || c == 'v')) {
let t = token.trim_start_matches('v');
if t.is_empty() || !t.chars().next().is_some_and(|c| c.is_ascii_digit()) {
continue;
}
if parse_dotted_version(t).is_ok() {
best = Some(t.to_string());
break;
}
}
best
}
}
}
fn ensure_host_version(tool: &str, label: &str, constraint: &str, output: &str) -> Result<()> {
let (need_label, need) = parse_min_version_constraint(constraint)?;
let Some(have) = extract_version_from_tool_output(output) else {
bail!(
"packages require {label} {need_label}, but could not parse `{tool}` version from:\n{output}"
);
};
if have < need {
bail!(
"packages require {label} {need_label}, but `{tool}` reports {} (upgrade the runtime or relax the constraint)",
have.display()
);
}
Ok(())
}
fn check_java_constraint(constraint: &str) -> Result<()> {
let output = Command::new("java")
.arg("-version")
.output()
.with_context(|| "spawn `java -version` (needed for packages.gradle.java)")?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
if !output.status.success() && text.trim().is_empty() {
bail!("packages.gradle.java requires `java` on PATH");
}
ensure_host_version("java", "JDK/Java", constraint, &text)
}
fn check_node_constraint(constraint: &str) -> Result<()> {
let output = Command::new("node")
.arg("--version")
.output()
.with_context(|| "spawn `node --version` (needed for packages.pnpm.node)")?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if !output.status.success() {
bail!("packages.pnpm.node requires `node` on PATH");
}
ensure_host_version("node", "Node.js", constraint, &text)
}
fn check_python_constraint_for_bin(python: &Path, constraint: &str) -> Result<()> {
let output = Command::new(python)
.arg("--version")
.output()
.with_context(|| format!("spawn `{} --version`", python.display()))?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if !output.status.success() {
bail!(
"packages.uv.python: could not run `{} --version`",
python.display()
);
}
ensure_host_version(&python.display().to_string(), "Python", constraint, &text)
}
fn uv_python_selector(constraint: &str) -> Result<String> {
let (_, ver) = parse_min_version_constraint(constraint)?;
if ver.minor == 0 && ver.patch == 0 {
Ok(ver.major.to_string())
} else {
Ok(format!("{}.{}", ver.major, ver.minor))
}
}
pub fn decl_hash(canonical: &str) -> String {
remote::sha256_hex(canonical.as_bytes())[..16].to_string()
}
fn venv_bin_dir(venv: &Path) -> PathBuf {
if cfg!(windows) {
venv.join("Scripts")
} else {
venv.join("bin")
}
}
fn tool_available(tool: &str) -> bool {
Command::new(tool)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn kotlin_tool_available(tool: &str) -> bool {
Command::new(tool)
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_tool(tool: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> {
let mut cmd = Command::new(tool);
cmd.args(args);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let output = cmd
.output()
.with_context(|| format!("spawn `{tool} {}`", args.join(" ")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if !stderr.trim().is_empty() {
stderr.trim()
} else {
stdout.trim()
};
bail!(
"`{tool} {}` failed (status {}): {detail}",
args.join(" "),
output.status
);
}
fn run_tool_inherit(tool: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> {
let mut cmd = Command::new(tool);
cmd.args(args);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd.stdin(std::process::Stdio::null());
let status = cmd
.status()
.with_context(|| format!("spawn `{tool} {}`", args.join(" ")))?;
if status.success() {
return Ok(());
}
bail!("`{tool} {}` failed (status {status})", args.join(" "));
}
fn run_uv(args: &[&str], cwd: Option<&Path>) -> Result<()> {
run_tool("uv", args, cwd)
}
fn ensure_uv(uv: &UvPackages, ctx: &RunContext<'_>) -> Result<UvEnv> {
let use_root = Path::new(&ctx.spec_root.spec_dir);
let canonical = uv_canonical(uv, use_root)?;
let hash = decl_hash(&canonical);
let root = Manager::Uv.envs_root()?.join(&hash);
let bin = venv_bin_dir(&root);
let marker = root.join(".jan-ready");
let python_ok = bin
.join(if cfg!(windows) {
"python.exe"
} else {
"python3"
})
.is_file()
|| bin
.join(if cfg!(windows) {
"python.exe"
} else {
"python"
})
.is_file();
if marker.is_file() && python_ok {
let prev = fs::read_to_string(&marker).unwrap_or_default();
if prev.trim() == canonical.trim() {
if let Some(req) = uv.python.as_deref() {
check_python_constraint_for_bin(&venv_python(&bin), req)?;
}
mark_env_used(&root);
return Ok(UvEnv { bin_dir: bin });
}
}
if !tool_available("uv") {
bail!("packages.uv requires `uv` on PATH (install from https://docs.astral.sh/uv/)");
}
fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
if !python_ok {
let root_s = root.to_string_lossy().into_owned();
if let Some(req) = uv.python.as_deref() {
let selector = uv_python_selector(req)?;
run_uv(&["venv", "--clear", "--python", &selector, &root_s], None)?;
} else {
run_uv(&["venv", "--clear", &root_s], None)?;
}
}
let python = venv_python(&bin);
if let Some(req) = uv.python.as_deref() {
check_python_constraint_for_bin(&python, req)?;
}
let python_s = python.to_string_lossy();
match &uv.deps {
UvDeps::List(pkgs) => {
let req = root.join("requirements.txt");
let mut sorted: Vec<String> = pkgs.iter().map(|s| s.trim().to_string()).collect();
sorted.sort();
sorted.dedup();
fs::write(&req, format!("{}\n", sorted.join("\n")))
.with_context(|| format!("write {}", req.display()))?;
run_uv(
&[
"pip",
"install",
"--python",
python_s.as_ref(),
"-r",
req.to_str().unwrap(),
],
None,
)?;
}
UvDeps::Requirements(rel) => {
let file = spec_load::resolve_under_use_root(use_root, rel)?;
run_uv(
&[
"pip",
"install",
"--python",
python_s.as_ref(),
"-r",
file.to_str().unwrap(),
],
None,
)?;
}
UvDeps::Project(rel) => {
let dir = spec_load::resolve_under_use_root_any(use_root, rel)?;
project_lock(&dir)?;
let exported = root.join("requirements.txt");
run_uv(
&[
"export",
"--frozen",
"--no-hashes",
"--no-emit-project",
"--format",
"requirements.txt",
"--project",
dir.to_str().unwrap(),
"-o",
exported.to_str().unwrap(),
],
None,
)?;
run_uv(
&[
"pip",
"install",
"--python",
python_s.as_ref(),
"-r",
exported.to_str().unwrap(),
],
None,
)?;
run_uv(
&[
"pip",
"install",
"--python",
python_s.as_ref(),
"--no-deps",
dir.to_str().unwrap(),
],
None,
)?;
}
}
fs::write(&marker, format!("{canonical}\n"))
.with_context(|| format!("write {}", marker.display()))?;
mark_env_used(&root);
Ok(UvEnv { bin_dir: bin })
}
fn ensure_pnpm(pnpm: &PnpmPackages, ctx: &RunContext<'_>) -> Result<PnpmEnv> {
let use_root = Path::new(&ctx.spec_root.spec_dir);
let canonical = pnpm_canonical(pnpm, use_root)?;
let hash = decl_hash(&canonical);
let root = Manager::Pnpm.envs_root()?.join(&hash);
let modules = root.join("node_modules");
let bin = Manager::Pnpm.bin_dir(&root);
let marker = root.join(".jan-ready");
if marker.is_file() && modules.is_dir() {
let prev = fs::read_to_string(&marker).unwrap_or_default();
if prev.trim() == canonical.trim() {
if let Some(req) = pnpm.node.as_deref() {
check_node_constraint(req)?;
}
mark_env_used(&root);
return Ok(PnpmEnv {
bin_dir: bin,
modules_dir: modules,
});
}
}
if let Some(req) = pnpm.node.as_deref() {
check_node_constraint(req)?;
}
if !tool_available("pnpm") {
bail!("packages.pnpm requires `pnpm` on PATH (install from https://pnpm.io/installation)");
}
fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
match &pnpm.deps {
PnpmDeps::List(pkgs) => {
let mut sorted: Vec<String> = pkgs.iter().map(|s| s.trim().to_string()).collect();
sorted.sort();
sorted.dedup();
let mut deps = BTreeMap::new();
for spec in &sorted {
let at = spec.rfind('@').filter(|i| *i > 0).unwrap_or(spec.len());
deps.insert(spec[..at].to_string(), spec[at + 1..].to_string());
}
write_pnpm_manifest(&root, &deps)?;
run_tool("pnpm", &["install", "--dir", root.to_str().unwrap()], None)?;
}
PnpmDeps::Project(rel) => {
let dir = spec_load::resolve_under_use_root_any(use_root, rel)?;
let lock = pnpm_lock(&dir)?;
fs::copy(dir.join("package.json"), root.join("package.json"))
.with_context(|| format!("copy package.json from {} to env", dir.display()))?;
fs::copy(&lock, root.join("pnpm-lock.yaml"))
.with_context(|| format!("copy {} to env", lock.display()))?;
run_tool(
"pnpm",
&[
"install",
"--frozen-lockfile",
"--ignore-scripts",
"--dir",
root.to_str().unwrap(),
],
None,
)?;
}
}
fs::write(&marker, format!("{canonical}\n"))
.with_context(|| format!("write {}", marker.display()))?;
mark_env_used(&root);
Ok(PnpmEnv {
bin_dir: bin,
modules_dir: modules,
})
}
fn classpath_separator() -> &'static str {
if cfg!(windows) {
";"
} else {
":"
}
}
fn write_classpath_file(root: &Path, lib: &Path) -> Result<String> {
let mut jars = Vec::new();
if lib.is_dir() {
for entry in fs::read_dir(lib).with_context(|| format!("read {}", lib.display()))? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("jar") {
let canon = path
.canonicalize()
.with_context(|| format!("canonicalize {}", path.display()))?;
jars.push(canon);
}
}
}
jars.sort();
if jars.is_empty() {
bail!("packages.gradle resolved no jars under {}", lib.display());
}
let classpath = jars
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(classpath_separator());
fs::write(root.join("classpath"), format!("{classpath}\n"))
.with_context(|| format!("write {}/classpath", root.display()))?;
Ok(classpath)
}
fn read_classpath_file(root: &Path) -> Option<String> {
fs::read_to_string(root.join("classpath"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn write_gradle_list_project(root: &Path, coords: &[String]) -> Result<()> {
let mut build = String::from(
"plugins {\n id 'java-library'\n}\n\nrepositories {\n mavenCentral()\n}\n\ndependencies {\n",
);
for coord in coords {
build.push_str(&format!(" implementation '{coord}'\n"));
}
build.push_str(
"}\n\ntasks.register('janCopyDeps', Copy) {\n from configurations.runtimeClasspath\n into layout.projectDirectory.dir('lib')\n duplicatesStrategy = DuplicatesStrategy.INCLUDE\n}\n",
);
fs::write(root.join("build.gradle"), build)
.with_context(|| format!("write {}/build.gradle", root.display()))?;
fs::write(
root.join("settings.gradle"),
"rootProject.name = 'jan-packages-env'\n",
)
.with_context(|| format!("write {}/settings.gradle", root.display()))?;
Ok(())
}
fn write_gradle_init_script(path: &Path, lib_dir: &Path) -> Result<()> {
let lib = lib_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("non-utf8 lib dir"))?
.replace('\\', "/");
let body = format!(
r#"
gradle.projectsLoaded {{
rootProject.allprojects {{ project ->
project.afterEvaluate {{
def runtime = project.configurations.findByName('runtimeClasspath')
if (runtime == null) {{
return
}}
project.tasks.register('janCopyDeps', Copy) {{
from runtime
into project.file('{lib}')
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}}
}}
}}
}}
"#
);
fs::write(path, body).with_context(|| format!("write {}", path.display()))
}
fn ensure_gradle(gradle: &GradlePackages, ctx: &RunContext<'_>) -> Result<GradleEnv> {
let use_root = Path::new(&ctx.spec_root.spec_dir);
let canonical = gradle_canonical(gradle, use_root)?;
let hash = decl_hash(&canonical);
let root = Manager::Gradle.envs_root()?.join(&hash);
let lib = root.join("lib");
let marker = root.join(".jan-ready");
if marker.is_file() && lib.is_dir() {
let prev = fs::read_to_string(&marker).unwrap_or_default();
if prev.trim() == canonical.trim() {
if let Some(classpath) = read_classpath_file(&root) {
if let Some(req) = gradle.java.as_deref() {
check_java_constraint(req)?;
}
mark_env_used(&root);
return Ok(GradleEnv {
lib_dir: lib,
classpath,
});
}
}
}
if let Some(req) = gradle.java.as_deref() {
check_java_constraint(req)?;
}
if !tool_available("gradle") {
bail!(
"packages.gradle requires `gradle` on PATH (install from https://gradle.org/install/)"
);
}
let gradle_ver = gradle_version_string();
fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
if lib.is_dir() {
let _ = fs::remove_dir_all(&lib);
}
fs::create_dir_all(&lib).with_context(|| format!("create {}", lib.display()))?;
match &gradle.deps {
GradleDeps::List(pkgs) => {
let mut sorted: Vec<String> = pkgs.iter().map(|s| s.trim().to_string()).collect();
sorted.sort();
sorted.dedup();
eprintln!(
"jan: packages.gradle: resolving {} coordinate(s) → {}",
sorted.len(),
root.display()
);
for coord in &sorted {
eprintln!("jan: - {coord}");
}
write_gradle_list_project(&root, &sorted)?;
eprintln!(
"jan: packages.gradle: running `gradle janCopyDeps` (downloads on first resolve)…"
);
run_tool_inherit(
"gradle",
&[
"--console=plain",
"--project-dir",
root.to_str().unwrap(),
"janCopyDeps",
],
None,
)?;
}
GradleDeps::Project(rel) => {
let dir = spec_load::resolve_under_use_root_any(use_root, rel)?;
gradle_build_file(&dir)?;
gradle_lock(&dir)?;
let init = root.join("jan-init.gradle");
write_gradle_init_script(&init, &lib)?;
eprintln!(
"jan: packages.gradle: resolving project `{}` → {}",
rel,
root.display()
);
eprintln!(
"jan: packages.gradle: running `gradle janCopyDeps` for {} (downloads on first resolve)…",
dir.display()
);
run_tool_inherit(
"gradle",
&[
"--console=plain",
"--project-dir",
dir.to_str().unwrap(),
"--init-script",
init.to_str().unwrap(),
"janCopyDeps",
],
None,
)?;
}
}
let classpath = write_classpath_file(&root, &lib)?;
let jar_count = classpath
.split(classpath_separator())
.filter(|s| !s.is_empty())
.count();
if let Some(ver) = &gradle_ver {
let _ = fs::write(root.join(".jan-runtime"), format!("{ver}\n"));
}
fs::write(&marker, format!("{canonical}\n"))
.with_context(|| format!("write {}", marker.display()))?;
mark_env_used(&root);
eprintln!(
"jan: packages.gradle: ready ({jar_count} jar{}) at {}",
if jar_count == 1 { "" } else { "s" },
root.display()
);
Ok(GradleEnv {
lib_dir: lib,
classpath,
})
}
fn gradle_version_string() -> Option<String> {
let output = Command::new("gradle").args(["--version"]).output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("Gradle ") {
let ver = rest.trim();
if !ver.is_empty() {
return Some(format!("gradle@{ver}"));
}
}
}
None
}
fn write_pnpm_manifest(root: &Path, deps: &BTreeMap<String, String>) -> Result<()> {
let manifest = json!({
"name": "jan-packages-env",
"version": "0.0.0",
"private": true,
"dependencies": deps,
});
let path = root.join("package.json");
fs::write(
&path,
format!("{}\n", serde_json::to_string_pretty(&manifest)?),
)
.with_context(|| format!("write {}", path.display()))
}
pub fn prepend_env_paths(envs: &EnsuredEnvs, path_override: Option<String>) -> Result<String> {
let sep = if cfg!(windows) { ";" } else { ":" };
let mut parts = Vec::new();
if let Some(uv) = &envs.uv {
parts.push(uv.bin_dir.to_string_lossy().into_owned());
}
if let Some(pnpm) = &envs.pnpm {
parts.push(pnpm.bin_dir.to_string_lossy().into_owned());
}
if let Some(rest) = path_override {
if !rest.is_empty() {
parts.push(rest);
}
} else {
let current = std::env::var("PATH").unwrap_or_default();
if !current.is_empty() {
parts.push(current);
}
}
Ok(parts.join(sep))
}
pub fn node_path_for(envs: &EnsuredEnvs) -> Option<String> {
let pnpm = envs.pnpm.as_ref()?;
let sep = if cfg!(windows) { ";" } else { ":" };
let mut parts = vec![pnpm.modules_dir.to_string_lossy().into_owned()];
if let Ok(existing) = std::env::var("NODE_PATH") {
if !existing.is_empty() {
parts.push(existing);
}
}
Some(parts.join(sep))
}
pub fn classpath_for(envs: &EnsuredEnvs) -> Option<String> {
let gradle = envs.gradle.as_ref()?;
let sep = classpath_separator();
let mut parts = vec![gradle.classpath.clone()];
if let Ok(existing) = std::env::var("CLASSPATH") {
if !existing.is_empty() {
parts.push(existing);
}
}
Some(parts.join(sep))
}
pub fn inject_jvm_classpath(argv: &mut Vec<String>, envs: &EnsuredEnvs) {
let Some(gradle) = envs.gradle.as_ref() else {
return;
};
if argv.is_empty() {
return;
}
let prog = jvm_tool_basename(&argv[0]);
if !matches!(prog.as_str(), "java" | "javac" | "kotlin" | "kotlinc") {
return;
}
if argv_has_classpath(argv) {
return;
}
argv.insert(1, "-cp".into());
argv.insert(2, gradle.classpath.clone());
}
fn jvm_tool_basename(program: &str) -> String {
let name = Path::new(program)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(program);
let lower = name.to_ascii_lowercase();
lower.strip_suffix(".exe").unwrap_or(&lower).to_string()
}
fn argv_has_classpath(argv: &[String]) -> bool {
argv.iter().any(|a| {
let t = a.as_str();
t == "-cp" || t == "-classpath" || t.starts_with("-cp=") || t.starts_with("-classpath=")
})
}
pub fn prepare_kotlin_argv(
use_root: &Path,
kotlin_field: &str,
envs: &EnsuredEnvs,
main_args: &[String],
) -> Result<Vec<String>> {
let classpath = envs
.gradle
.as_ref()
.map(|g| g.classpath.as_str())
.unwrap_or("");
let trimmed = kotlin_field.trim();
if crate::ExecSpec::kotlin_value_is_path(trimmed) {
let src = spec_load::resolve_under_use_root(use_root, trimmed)?;
let lower = trimmed.to_ascii_lowercase();
if lower.ends_with(".kts") {
return prepare_kotlin_script(&src, classpath, main_args);
}
let src_bytes = fs::read(&src).with_context(|| format!("read {}", src.display()))?;
let type_name = kotlin_type_name(&src);
return prepare_kotlin_compiled_source(
&src_bytes,
&type_name,
&src.display().to_string(),
classpath,
envs,
main_args,
);
}
prepare_kotlin_compiled_source(
trimmed.as_bytes(),
"Inline",
"exec.kotlin (inline)",
classpath,
envs,
main_args,
)
}
#[derive(Clone, Copy, Debug)]
pub enum ShellKind {
Bash,
Sh,
Zsh,
}
impl ShellKind {
fn program(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Sh => "sh",
Self::Zsh => "zsh",
}
}
fn c_flag(self) -> &'static str {
match self {
Self::Bash => "-lc",
Self::Sh | Self::Zsh => "-c",
}
}
fn value_is_path(self, raw: &str) -> bool {
match self {
Self::Bash => crate::ExecSpec::bash_value_is_path(raw),
Self::Sh => crate::ExecSpec::sh_value_is_path(raw),
Self::Zsh => crate::ExecSpec::zsh_value_is_path(raw),
}
}
}
pub fn prepare_shell_argv(
kind: ShellKind,
use_root: &Path,
field: &str,
argv0: &str,
main_args: &[String],
) -> Result<Vec<String>> {
let trimmed = field.trim();
let mut argv = vec![kind.program().to_string()];
if kind.value_is_path(trimmed) {
let src = spec_load::resolve_under_use_root(use_root, trimmed)?;
argv.push(src.to_string_lossy().into_owned());
argv.extend(main_args.iter().cloned());
} else {
argv.push(kind.c_flag().into());
argv.push(trimmed.to_string());
argv.push(argv0.to_string());
argv.extend(main_args.iter().cloned());
}
Ok(argv)
}
pub fn prepare_python_argv(
use_root: &Path,
python_field: &str,
main_args: &[String],
) -> Result<Vec<String>> {
let trimmed = python_field.trim();
let mut argv = vec!["python3".into()];
if crate::ExecSpec::python_value_is_path(trimmed) {
let src = spec_load::resolve_under_use_root(use_root, trimmed)?;
argv.push(src.to_string_lossy().into_owned());
} else {
argv.push("-c".into());
argv.push(trimmed.to_string());
}
argv.extend(main_args.iter().cloned());
Ok(argv)
}
pub fn prepare_node_argv(
use_root: &Path,
node_field: &str,
main_args: &[String],
) -> Result<Vec<String>> {
let trimmed = node_field.trim();
let mut argv = vec!["node".into()];
if crate::ExecSpec::node_value_is_path(trimmed) {
let src = spec_load::resolve_under_use_root(use_root, trimmed)?;
argv.push(src.to_string_lossy().into_owned());
} else {
argv.push("-e".into());
argv.push(trimmed.to_string());
}
argv.extend(main_args.iter().cloned());
Ok(argv)
}
fn prepare_kotlin_script(src: &Path, classpath: &str, main_args: &[String]) -> Result<Vec<String>> {
if !kotlin_tool_available("kotlinc") {
bail!("exec.kotlin requires `kotlinc` on PATH (Kotlin command-line compiler)");
}
let mut argv = vec!["kotlinc".into(), "-script".into()];
if !classpath.is_empty() {
argv.push("-cp".into());
argv.push(classpath.to_string());
}
argv.push(src.to_string_lossy().into_owned());
if !main_args.is_empty() {
argv.push("--".into());
argv.extend(main_args.iter().cloned());
}
Ok(argv)
}
fn prepare_kotlin_compiled_source(
src_bytes: &[u8],
type_name: &str,
display: &str,
classpath: &str,
envs: &EnsuredEnvs,
main_args: &[String],
) -> Result<Vec<String>> {
let src_text = String::from_utf8_lossy(src_bytes);
let package = kotlin_package_prefix(&src_text);
let main_class = match &package {
Some(pkg) => format!("{pkg}.{type_name}Kt"),
None => format!("{type_name}Kt"),
};
let mut key = src_bytes.to_vec();
key.push(0);
key.extend_from_slice(classpath.as_bytes());
key.push(0);
key.extend_from_slice(main_class.as_bytes());
let digest = remote::sha256_hex(&key);
let short = &digest[..16];
let cache = kotlin_compile_cache(envs, short)?;
let classes = cache.join("classes");
let marker = cache.join(".jan-ready");
let class_file = kotlin_class_file(&classes, package.as_deref(), &format!("{type_name}Kt"));
let warm = marker.is_file()
&& fs::read_to_string(&marker).unwrap_or_default().trim() == digest
&& class_file.is_file();
if !warm {
if !kotlin_tool_available("kotlinc") {
bail!("exec.kotlin requires `kotlinc` on PATH to compile `{display}`");
}
if cache.exists() {
let _ = fs::remove_dir_all(&cache);
}
fs::create_dir_all(&classes).with_context(|| format!("create {}", classes.display()))?;
let staged = cache.join(format!("{type_name}.kt"));
fs::write(&staged, src_bytes).with_context(|| format!("stage {}", staged.display()))?;
eprintln!(
"jan: exec.kotlin: compiling {display} → {}",
classes.display()
);
let classes_s = classes.to_string_lossy().into_owned();
let staged_s = staged.to_string_lossy().into_owned();
let mut args: Vec<&str> = vec!["-d", &classes_s];
if !classpath.is_empty() {
args.push("-cp");
args.push(classpath);
}
args.push(&staged_s);
run_tool_inherit("kotlinc", &args, Some(&cache))?;
fs::write(&marker, format!("{digest}\n"))
.with_context(|| format!("write {}", marker.display()))?;
if !class_file.is_file() {
bail!(
"exec.kotlin: compile succeeded but missing {}; expected top-level `fun main` in `{type_name}.kt`",
class_file.display()
);
}
}
if !kotlin_tool_available("kotlin") {
bail!("exec.kotlin requires `kotlin` on PATH to run compiled `.kt` sources");
}
let sep = classpath_separator();
let mut run_cp = String::new();
if !classpath.is_empty() {
run_cp.push_str(classpath);
run_cp.push_str(sep);
}
run_cp.push_str(&classes.to_string_lossy());
let mut argv = vec!["kotlin".into(), "-cp".into(), run_cp, main_class];
argv.extend(main_args.iter().cloned());
Ok(argv)
}
fn kotlin_compile_cache(envs: &EnsuredEnvs, short_hash: &str) -> Result<PathBuf> {
if let Some(gradle) = &envs.gradle {
let root = gradle
.lib_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("gradle lib_dir has no parent"))?;
return Ok(root.join("kotlin").join(short_hash));
}
let d = cache_root()?.join("packages").join("kotlin");
fs::create_dir_all(&d).with_context(|| format!("create {}", d.display()))?;
Ok(d.join(short_hash))
}
fn kotlin_type_name(src: &Path) -> String {
let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("Main");
let pascal: String = stem
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect();
if pascal.is_empty() {
"Main".into()
} else {
pascal
}
}
fn kotlin_package_prefix(src: &str) -> Option<String> {
for line in src.lines() {
let t = line.trim();
if t.is_empty() || t.starts_with("//") {
continue;
}
if t.starts_with("/*") {
continue;
}
if let Some(rest) = t.strip_prefix("package ") {
let pkg = rest.trim().trim_end_matches(';').trim();
if !pkg.is_empty() {
return Some(pkg.to_string());
}
}
break;
}
None
}
fn kotlin_class_file(classes: &Path, package: Option<&str>, class_name: &str) -> PathBuf {
let mut p = classes.to_path_buf();
if let Some(pkg) = package {
for part in pkg.split('.').filter(|s| !s.is_empty()) {
p.push(part);
}
}
p.push(format!("{class_name}.class"));
p
}
pub fn resolve_program_with_envs(
program: &str,
envs: &EnsuredEnvs,
path_dirs: &[PathBuf],
) -> Result<PathBuf> {
let uv = envs.uv.as_ref();
if let Some(pnpm) = &envs.pnpm {
let trimmed = program.trim();
if !trimmed.contains('/') && !trimmed.contains('\\') {
let candidate = pnpm.bin_dir.join(trimmed);
if candidate.is_file() {
return Ok(candidate);
}
}
}
resolve_program_with_uv(program, uv, path_dirs)
}
fn resolve_program_with_uv(
program: &str,
uv: Option<&UvEnv>,
path_dirs: &[PathBuf],
) -> Result<PathBuf> {
let trimmed = program.trim();
let base = Path::new(trimmed)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(trimmed);
if let Some(env) = uv {
if matches!(base, "python" | "python3" | "python.exe") {
let candidate = if base.starts_with("python3") && !cfg!(windows) {
let p3 = env.bin_dir.join("python3");
if p3.is_file() {
p3
} else {
env.bin_dir.join("python")
}
} else if cfg!(windows) {
env.bin_dir.join("python.exe")
} else {
env.bin_dir.join(base)
};
if candidate.is_file() {
return Ok(candidate);
}
}
}
crate::deps::resolve_program(program, path_dirs)
}
#[derive(Debug, Clone)]
pub struct EnvInfo {
pub manager: Manager,
pub hash: String,
pub root: PathBuf,
pub canonical: Option<String>,
pub ready: bool,
pub runtime: Option<String>,
pub size_bytes: u64,
pub modified: Option<SystemTime>,
pub last_used: Option<SystemTime>,
}
impl EnvInfo {
fn canonical_body(&self) -> Option<&str> {
let c = self.canonical.as_deref()?;
let first = c.lines().next()?;
if first.starts_with("python:") || first.starts_with("node:") || first.starts_with("java:")
{
return c.find('\n').map(|i| c[i + 1..].trim_start_matches('\r'));
}
Some(c)
}
pub fn kind(&self) -> &'static str {
match self.canonical_body() {
Some(c) if c.starts_with("list:") => "list",
Some(c) if c.starts_with("project:") => "project",
Some(c) if c.starts_with("requirements:") => "requirements",
_ => "unknown",
}
}
pub fn bin_dir(&self) -> PathBuf {
self.manager.bin_dir(&self.root)
}
pub fn installed(&self) -> Vec<(String, String)> {
match self.manager {
Manager::Uv => installed_distributions(&self.root),
Manager::Pnpm => installed_node_modules(&self.root),
Manager::Gradle => installed_gradle_jars(&self.root),
}
}
pub fn declaration(&self) -> String {
let Some(canonical) = self.canonical.as_deref() else {
return "(no .jan-ready marker)".to_string();
};
let mut prefix = String::new();
let body = if let Some(first) = canonical.lines().next() {
if first.starts_with("python:")
|| first.starts_with("node:")
|| first.starts_with("java:")
{
prefix = format!("{}; ", first);
canonical
.find('\n')
.map(|i| &canonical[i + 1..])
.unwrap_or("")
} else {
canonical
}
} else {
canonical
};
if let Some(rest) = body.strip_prefix("list:") {
let pkgs: Vec<&str> = rest
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
return format!("{prefix}{}", pkgs.join(", "));
}
let line = body.lines().next().unwrap_or(body);
let without_kind = line.split_once(':').map(|(_, r)| r).unwrap_or(line);
let path = without_kind
.rsplit_once('#')
.map(|(p, _)| p.to_string())
.unwrap_or_else(|| without_kind.to_string());
format!("{prefix}{path}")
}
}
#[derive(Debug, Clone)]
pub struct Declaration {
pub manager: Manager,
pub chain: Vec<String>,
pub hash: Option<String>,
pub summary: String,
pub error: Option<String>,
}
impl Declaration {
pub fn chain_str(&self) -> String {
self.chain.join(" ")
}
fn key(&self) -> Option<(Manager, String)> {
self.hash.clone().map(|h| (self.manager, h))
}
}
fn uv_summary(uv: &UvPackages) -> String {
let mut base = match &uv.deps {
UvDeps::List(pkgs) => pkgs.join(", "),
UvDeps::Project(p) => format!("project {p}"),
UvDeps::Requirements(r) => format!("requirements {r}"),
};
if let Some(py) = &uv.python {
base = format!("python {py}; {base}");
}
base
}
fn pnpm_summary(pnpm: &PnpmPackages) -> String {
let mut base = match &pnpm.deps {
PnpmDeps::List(pkgs) => pkgs.join(", "),
PnpmDeps::Project(p) => format!("project {p}"),
};
if let Some(node) = &pnpm.node {
base = format!("node {node}; {base}");
}
base
}
fn gradle_summary(gradle: &GradlePackages) -> String {
let mut base = match &gradle.deps {
GradleDeps::List(pkgs) => pkgs.join(", "),
GradleDeps::Project(p) => format!("project {p}"),
};
if let Some(java) = &gradle.java {
base = format!("java {java}; {base}");
}
base
}
fn summary_for(manager: Manager, pkgs: &PackagesSpec) -> Option<String> {
match manager {
Manager::Uv => pkgs.uv.as_ref().map(uv_summary),
Manager::Pnpm => pkgs.pnpm.as_ref().map(pnpm_summary),
Manager::Gradle => pkgs.gradle.as_ref().map(gradle_summary),
}
}
fn read_pnpm_runtime(root: &Path) -> Option<String> {
let text = fs::read_to_string(root.join("node_modules").join(".modules.yaml")).ok()?;
let scrub = |s: &str| {
s.trim()
.trim_end_matches(',')
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
};
let mut fallback = None;
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
match scrub(key).as_str() {
"nodeVersion" => {
let v = scrub(value);
if !v.is_empty() {
return Some(v);
}
}
"packageManager" => {
let v = scrub(value);
if !v.is_empty() {
fallback = Some(v);
}
}
_ => {}
}
}
fallback
}
fn read_gradle_runtime(root: &Path) -> Option<String> {
fs::read_to_string(root.join(".jan-runtime"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn read_pyvenv_version(root: &Path) -> Option<String> {
let text = fs::read_to_string(root.join("pyvenv.cfg")).ok()?;
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
if matches!(key.trim(), "version" | "version_info") {
let v = value.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}
fn dir_size(path: &Path) -> u64 {
let Ok(entries) = fs::read_dir(path) else {
return 0;
};
let mut total = 0u64;
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
total += dir_size(&entry.path());
} else {
total += meta.len();
}
}
total
}
fn mark_env_used(root: &Path) {
let path = root.join(".jan-used");
if let Err(e) = File::create(&path) {
eprintln!(
"jan: warning: could not update last-used marker {}: {e}",
path.display()
);
}
}
fn env_last_used(root: &Path, ready_marker: &Path) -> Option<SystemTime> {
let used = root.join(".jan-used");
if let Ok(meta) = used.metadata() {
if let Ok(m) = meta.modified() {
return Some(m);
}
}
ready_marker.metadata().and_then(|m| m.modified()).ok()
}
fn human_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
fn age_label(modified: Option<SystemTime>) -> String {
let Some(ts) = modified else {
return "-".to_string();
};
let Ok(elapsed) = SystemTime::now().duration_since(ts) else {
return "just now".to_string();
};
let secs = elapsed.as_secs();
if secs < 60 {
return format!("{secs}s ago");
}
if secs < 3600 {
return format!("{}m ago", secs / 60);
}
if secs < 86_400 {
return format!("{}h ago", secs / 3600);
}
format!("{}d ago", secs / 86_400)
}
pub fn discover_envs() -> Result<Vec<EnvInfo>> {
let mut out = Vec::new();
let packages_root = cache_root()?.join("packages");
for manager in [Manager::Uv, Manager::Pnpm, Manager::Gradle] {
let root = packages_root.join(manager.id());
let Ok(entries) = fs::read_dir(&root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let hash = entry.file_name().to_string_lossy().into_owned();
let marker = path.join(".jan-ready");
let canonical = fs::read_to_string(&marker)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let (installed_ok, runtime) = match manager {
Manager::Uv => {
let bin = venv_bin_dir(&path);
let ok = bin.join("python3").is_file()
|| bin.join("python").is_file()
|| bin.join("python.exe").is_file();
(ok, read_pyvenv_version(&path))
}
Manager::Pnpm => (path.join("node_modules").is_dir(), read_pnpm_runtime(&path)),
Manager::Gradle => {
let lib = path.join("lib");
let ok = lib.is_dir()
&& fs::read_dir(&lib)
.map(|rd| {
rd.flatten().any(|e| {
e.path().extension().and_then(|s| s.to_str()) == Some("jar")
})
})
.unwrap_or(false);
(ok, read_gradle_runtime(&path))
}
};
out.push(EnvInfo {
manager,
hash,
runtime,
size_bytes: dir_size(&path),
modified: marker.metadata().and_then(|m| m.modified()).ok(),
last_used: env_last_used(&path, &marker),
ready: canonical.is_some() && installed_ok,
canonical,
root: path,
});
}
}
out.sort_by(|a, b| (a.manager, &a.hash).cmp(&(b.manager, &b.hash)));
Ok(out)
}
fn print_cache_banner() -> Result<()> {
let root = cache_root()?;
if let Ok(override_dir) = std::env::var("JAN_CACHE_DIR") {
if !override_dir.trim().is_empty() {
println!(
"cache: {} (JAN_CACHE_DIR — unset to use ~/.cache/jan)",
root.display()
);
return Ok(());
}
}
println!("cache: {}", root.display());
Ok(())
}
fn format_older_than(older_than: Duration) -> String {
let secs = older_than.as_secs();
if secs == 0 {
"0".into()
} else if secs.is_multiple_of(86_400) {
format!("{}d", secs / 86_400)
} else if secs.is_multiple_of(3600) {
format!("{}h", secs / 3600)
} else if secs.is_multiple_of(60) {
format!("{}m", secs / 60)
} else {
format!("{secs}s")
}
}
pub fn installed_distributions(root: &Path) -> Vec<(String, String)> {
let mut site_dirs = Vec::new();
if cfg!(windows) {
site_dirs.push(root.join("Lib").join("site-packages"));
} else if let Ok(entries) = fs::read_dir(root.join("lib")) {
for entry in entries.flatten() {
let candidate = entry.path().join("site-packages");
if candidate.is_dir() {
site_dirs.push(candidate);
}
}
}
let mut out: BTreeMap<String, String> = BTreeMap::new();
for dir in site_dirs {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some(stem) = name.strip_suffix(".dist-info") else {
continue;
};
let (dist, version) = match stem.rsplit_once('-') {
Some((d, v)) => (d.to_string(), v.to_string()),
None => (stem.to_string(), String::new()),
};
out.insert(dist, version);
}
}
out.into_iter().collect()
}
pub fn installed_node_modules(root: &Path) -> Vec<(String, String)> {
let modules = root.join("node_modules");
let mut out: BTreeMap<String, String> = BTreeMap::new();
let Ok(entries) = fs::read_dir(&modules) else {
return Vec::new();
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
if name.starts_with('@') {
let Ok(scoped) = fs::read_dir(entry.path()) else {
continue;
};
for inner in scoped.flatten() {
let full = format!("{name}/{}", inner.file_name().to_string_lossy());
out.insert(full, package_json_version(&inner.path()));
}
continue;
}
out.insert(name, package_json_version(&entry.path()));
}
out.into_iter().collect()
}
pub fn installed_gradle_jars(root: &Path) -> Vec<(String, String)> {
let lib = root.join("lib");
let mut out: BTreeMap<String, String> = BTreeMap::new();
let Ok(entries) = fs::read_dir(&lib) else {
return Vec::new();
};
for entry in entries.flatten() {
let fname = entry.file_name().to_string_lossy().into_owned();
let Some(stem) = fname.strip_suffix(".jar") else {
continue;
};
let (name, version) = match stem.rfind('-') {
Some(idx)
if stem[idx + 1..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit()) =>
{
(&stem[..idx], &stem[idx + 1..])
}
_ => (stem, ""),
};
out.insert(name.to_string(), version.to_string());
}
out.into_iter().collect()
}
fn package_json_version(dir: &Path) -> String {
let Ok(text) = fs::read_to_string(dir.join("package.json")) else {
return String::new();
};
serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("version")?.as_str().map(str::to_string))
.unwrap_or_default()
}
pub fn package_declarations(spec: &RootSpec, use_root: &Path) -> Vec<Declaration> {
let mut out = Vec::new();
walk_declarations(&spec.commands, &mut Vec::new(), spec, use_root, &mut out);
out.sort_by(|a, b| (&a.chain, a.manager).cmp(&(&b.chain, b.manager)));
out
}
fn walk_declarations(
map: &BTreeMap<String, CommandNode>,
chain: &mut Vec<String>,
spec: &RootSpec,
use_root: &Path,
out: &mut Vec<Declaration>,
) {
for (name, node) in map {
chain.push(name.clone());
if node.exec.is_some() {
let merged = collect_chain_packages(chain, spec);
for manager in [Manager::Uv, Manager::Pnpm, Manager::Gradle] {
let Some(canonical) = canonical_for(manager, &merged, use_root) else {
continue;
};
let (hash, error) = match canonical {
Ok(c) => (Some(decl_hash(&c)), None),
Err(e) => (None, Some(format!("{e:#}"))),
};
out.push(Declaration {
manager,
chain: chain.clone(),
hash,
summary: summary_for(manager, &merged).unwrap_or_default(),
error,
});
}
}
walk_declarations(&node.commands, chain, spec, use_root, out);
chain.pop();
}
}
fn print_packages_help() {
print!(
"\
packages — inspect and prune package-manager environments (uv, pnpm, gradle)
USAGE:
jan packages [list] [--uv | --pnpm | --gradle] [--json] [--orphans]
jan packages show <HASH|SCRIPT> [--json]
jan packages path <HASH|SCRIPT> [--bin | --python | --modules | --lib | --classpath]
jan packages prune [options]
DESCRIPTION:
Envs live under `<cache>/packages/<manager>/<hash>/`, one per distinct
`packages.uv` / `packages.pnpm` / `packages.gradle` declaration. `list`
cross-references them with the preferred tree so you can see which script
each env serves, which declarations have no env yet, and which envs are
orphaned. Each successful run updates `.jan-used` so prune can keep hot
caches and drop stale ones.
`prune` removes cached envs that are unused by the preferred tree, older
than a threshold, and (optionally) larger than a size floor. Without
`--yes` it only prints what would be deleted.
OPTIONS:
--uv Only uv envs and declarations
--pnpm Only pnpm envs and declarations
--gradle Only gradle envs and declarations
--json Machine-readable output
--orphans With `list`: only envs no script in the tree references
With `prune`: same (default for prune)
--all With `prune`: also consider envs still referenced by the tree
--older-than <DUR> With `prune`: min age since last use (default `30d`;
forms: `90`, `30d`, `12h`, `45m`, `0` = any age)
--min-size <SIZE> With `prune`: only envs at least this large
(default `0`; forms: `100M`, `1G`, `512KiB`, `1024`)
--yes, -y With `prune`: actually delete (otherwise dry-run)
--dry-run, -n With `prune`: preview only (default without `--yes`)
--bin With `path`: print the bin directory (venv bin / node_modules/.bin / lib)
--python With `path`: print the venv python executable (uv only)
--modules With `path`: print the node_modules directory (pnpm only)
--lib With `path`: print the jar lib directory (gradle only)
--classpath With `path`: print the resolved classpath string (gradle only)
-h, --help Prints help
"
);
}
pub fn dispatch_packages(args: &[OsString], spec: &RootSpec, use_root: &Path) -> Result<i32> {
let tokens: Vec<String> = args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let mut sub: Option<String> = None;
let mut selector: Option<String> = None;
let mut json = false;
let mut orphans_only = false;
let mut prune_all = false;
let mut yes = false;
let mut dry_run = false;
let mut older_than: Option<String> = None;
let mut min_size: Option<String> = None;
let mut want_bin = false;
let mut want_python = false;
let mut want_modules = false;
let mut want_lib = false;
let mut want_classpath = false;
let mut only: Option<Manager> = None;
let mut i = 0usize;
while i < tokens.len() {
let s = tokens[i].as_str();
match s {
"--help" | "-h" => {
print_packages_help();
return Ok(0);
}
"--json" => json = true,
"--orphans" => orphans_only = true,
"--all" => prune_all = true,
"--yes" | "-y" => yes = true,
"--dry-run" | "-n" => dry_run = true,
"--bin" => want_bin = true,
"--python" => want_python = true,
"--modules" => want_modules = true,
"--lib" => want_lib = true,
"--classpath" => want_classpath = true,
"--uv" => only = Some(Manager::Uv),
"--pnpm" => only = Some(Manager::Pnpm),
"--gradle" => only = Some(Manager::Gradle),
"--older-than" => {
i += 1;
let Some(v) = tokens.get(i) else {
bail!("missing value after `--older-than`");
};
older_than = Some(v.clone());
}
"--min-size" => {
i += 1;
let Some(v) = tokens.get(i) else {
bail!("missing value after `--min-size`");
};
min_size = Some(v.clone());
}
other if other.starts_with('-') => bail!("unknown packages flag `{other}`"),
other if sub.is_none() => sub = Some(other.to_string()),
other if selector.is_none() => selector = Some(other.to_string()),
other => bail!("unexpected packages argument `{other}`"),
}
i += 1;
}
if [
want_bin,
want_python,
want_modules,
want_lib,
want_classpath,
]
.iter()
.filter(|v| **v)
.count()
> 1
{
bail!("pass at most one of `--bin` / `--python` / `--modules` / `--lib` / `--classpath`");
}
match sub.as_deref().unwrap_or("list") {
"list" => packages_list(spec, use_root, only, json, orphans_only),
"show" => {
let Some(sel) = selector else {
print_packages_help();
bail!("missing <HASH|SCRIPT> for `jan packages show`");
};
packages_show(spec, use_root, only, &sel, json)
}
"path" => {
let Some(sel) = selector else {
print_packages_help();
bail!("missing <HASH|SCRIPT> for `jan packages path`");
};
packages_path(
spec,
use_root,
only,
&sel,
want_bin,
want_python,
want_modules,
want_lib,
want_classpath,
)
}
"prune" => {
if selector.is_some() {
bail!("`jan packages prune` does not take a hash/script selector");
}
if want_bin || want_python || want_modules || want_lib || want_classpath {
bail!("path flags are not valid with `jan packages prune`");
}
if prune_all && orphans_only {
bail!("pass only one of `--all` and `--orphans`");
}
let older = parse_age_duration(older_than.as_deref().unwrap_or("30d"))?;
let min = parse_byte_size(min_size.as_deref().unwrap_or("0"))?;
let orphans = !prune_all;
let do_delete = yes && !dry_run;
packages_prune(spec, use_root, only, json, orphans, older, min, do_delete)
}
other => {
print_packages_help();
bail!("unknown packages subcommand `{other}` (list | show | path | prune)")
}
}
}
fn users_by_env(decls: &[Declaration]) -> BTreeMap<(Manager, String), Vec<String>> {
let mut out: BTreeMap<(Manager, String), Vec<String>> = BTreeMap::new();
for decl in decls {
if let Some(key) = decl.key() {
out.entry(key).or_default().push(decl.chain_str());
}
}
out
}
fn packages_list(
spec: &RootSpec,
use_root: &Path,
only: Option<Manager>,
json: bool,
orphans_only: bool,
) -> Result<i32> {
let envs: Vec<EnvInfo> = discover_envs()?
.into_iter()
.filter(|e| only.is_none_or(|m| e.manager == m))
.collect();
let decls: Vec<Declaration> = package_declarations(spec, use_root)
.into_iter()
.filter(|d| only.is_none_or(|m| d.manager == m))
.collect();
let users = users_by_env(&decls);
let built: Vec<&EnvInfo> = envs
.iter()
.filter(|e| !orphans_only || !users.contains_key(&(e.manager, e.hash.clone())))
.collect();
let is_built = |d: &Declaration| match &d.hash {
Some(h) => envs.iter().any(|e| e.manager == d.manager && &e.hash == h),
None => false,
};
if json {
let envs_json: Vec<_> = built
.iter()
.map(|e| {
json!({
"manager": e.manager.id(),
"hash": e.hash,
"kind": e.kind(),
"declaration": e.declaration(),
"root": e.root.to_string_lossy(),
"bin": e.bin_dir().to_string_lossy(),
"runtime": e.runtime,
"size_bytes": e.size_bytes,
"ready": e.ready,
"built_age": age_label(e.modified),
"used_age": age_label(e.last_used),
"used_by": users
.get(&(e.manager, e.hash.clone()))
.cloned()
.unwrap_or_default(),
})
})
.collect();
let missing: Vec<_> = decls
.iter()
.filter(|d| !is_built(d))
.map(|d| {
json!({
"manager": d.manager.id(),
"chain": d.chain_str(),
"hash": d.hash,
"declaration": d.summary,
"error": d.error,
})
})
.collect();
let mut roots = BTreeMap::new();
for manager in [Manager::Uv, Manager::Pnpm, Manager::Gradle] {
if only.is_none_or(|m| m == manager) {
roots.insert(
manager.id(),
cache_root()?
.join("packages")
.join(manager.id())
.to_string_lossy()
.into_owned(),
);
}
}
println!(
"{}",
serde_json::to_string_pretty(&json!({
"cache": cache_root()?.to_string_lossy(),
"jan_cache_dir": std::env::var("JAN_CACHE_DIR").ok(),
"roots": roots,
"envs": envs_json,
"not_built": missing,
}))?
);
return Ok(0);
}
print_cache_banner()?;
for manager in [Manager::Uv, Manager::Pnpm, Manager::Gradle] {
if only.is_none_or(|m| m == manager) {
let root = cache_root()?.join("packages").join(manager.id());
println!("{} envs: {}", manager.id(), root.display());
}
}
if built.is_empty() {
if orphans_only {
println!("(no orphaned envs)");
} else {
println!("(none built yet — run a script that declares `packages:`)");
}
} else {
println!();
println!(
"{:<7} {:<18} {:<13} {:<14} {:<10} {:<11} USED BY",
"MGR", "HASH", "KIND", "RUNTIME", "SIZE", "USED"
);
for env in &built {
let used = users
.get(&(env.manager, env.hash.clone()))
.cloned()
.unwrap_or_default();
let used_label = if used.is_empty() {
"(orphan)".to_string()
} else {
used.join(", ")
};
let kind = if env.ready {
env.kind().to_string()
} else {
format!("{} !", env.kind())
};
println!(
"{:<7} {:<18} {:<13} {:<14} {:<10} {:<11} {}",
env.manager.id(),
env.hash,
kind,
env.runtime.as_deref().unwrap_or("-"),
human_size(env.size_bytes),
age_label(env.last_used),
used_label
);
}
}
if !orphans_only {
let missing: Vec<&Declaration> = decls.iter().filter(|d| !is_built(d)).collect();
if !missing.is_empty() {
println!();
println!("declared but not built yet:");
for decl in missing {
let head = format!("{} [{}]", decl.chain_str(), decl.manager.id());
match (&decl.hash, &decl.error) {
(Some(h), _) => println!(" {head} — {} (hash {h})", decl.summary),
(None, Some(e)) => println!(" {head} — {} (unresolved: {e})", decl.summary),
(None, None) => println!(" {head} — {}", decl.summary),
}
}
}
}
Ok(0)
}
fn resolve_selector<'a>(
envs: &'a [EnvInfo],
decls: &[Declaration],
selector: &str,
) -> Result<&'a EnvInfo> {
let sel = selector.trim();
if sel.is_empty() {
bail!("empty selector");
}
let hash_matches: Vec<&EnvInfo> = envs.iter().filter(|e| e.hash.starts_with(sel)).collect();
if hash_matches.len() == 1 {
return Ok(hash_matches[0]);
}
if hash_matches.len() > 1 {
let names: Vec<String> = hash_matches
.iter()
.map(|e| format!("{} {}", e.manager.id(), e.hash))
.collect();
bail!("ambiguous hash prefix `{sel}`: {}", names.join(", "));
}
let chain_matches: Vec<&Declaration> = decls
.iter()
.filter(|d| {
let chain = d.chain_str();
chain == sel
|| chain.split(' ').any(|seg| seg.eq_ignore_ascii_case(sel))
|| chain.eq_ignore_ascii_case(sel)
})
.collect();
if chain_matches.is_empty() {
bail!("no package env or script matched `{sel}` (try `jan packages list`)");
}
let keys: Vec<(Manager, String)> = chain_matches
.iter()
.filter_map(|d| d.key())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
if keys.is_empty() {
let detail = chain_matches
.iter()
.filter_map(|d| d.error.clone())
.next()
.unwrap_or_else(|| "declaration could not be resolved".to_string());
bail!("`{sel}` declares packages but it does not resolve: {detail}");
}
if keys.len() > 1 {
let names: Vec<String> = keys
.iter()
.map(|(m, h)| format!("{} {h}", m.id()))
.collect();
bail!(
"`{sel}` matches several envs — narrow with `--uv` / `--pnpm` or a hash: {}",
names.join(", ")
);
}
let (manager, hash) = &keys[0];
envs.iter()
.find(|e| e.manager == *manager && &e.hash == hash)
.ok_or_else(|| {
anyhow::anyhow!(
"{} env for `{sel}` (hash {hash}) is not built yet — run the script once first",
manager.id()
)
})
}
fn packages_show(
spec: &RootSpec,
use_root: &Path,
only: Option<Manager>,
selector: &str,
json: bool,
) -> Result<i32> {
let envs: Vec<EnvInfo> = discover_envs()?
.into_iter()
.filter(|e| only.is_none_or(|m| e.manager == m))
.collect();
let decls: Vec<Declaration> = package_declarations(spec, use_root)
.into_iter()
.filter(|d| only.is_none_or(|m| d.manager == m))
.collect();
let env = resolve_selector(&envs, &decls, selector)?;
let users = users_by_env(&decls)
.get(&(env.manager, env.hash.clone()))
.cloned()
.unwrap_or_default();
let installed = env.installed();
let bin = env.bin_dir();
if json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"manager": env.manager.id(),
"hash": env.hash,
"kind": env.kind(),
"declaration": env.declaration(),
"canonical": env.canonical,
"root": env.root.to_string_lossy(),
"bin": bin.to_string_lossy(),
"runtime": env.runtime,
"size_bytes": env.size_bytes,
"ready": env.ready,
"built_age": age_label(env.modified),
"used_age": age_label(env.last_used),
"used_by": users,
"installed": installed
.iter()
.map(|(n, v)| json!({ "name": n, "version": v }))
.collect::<Vec<_>>(),
}))?
);
return Ok(0);
}
let (runtime_label, pin_sep) = match env.manager {
Manager::Uv => ("python", "=="),
Manager::Pnpm => ("runtime", "@"),
Manager::Gradle => ("runtime", ":"),
};
println!("manager: {}", env.manager.id());
println!("hash: {}", env.hash);
println!("kind: {}", env.kind());
println!("declaration: {}", env.declaration());
println!("root: {}", env.root.display());
if env.manager == Manager::Gradle {
println!("lib: {}", env.bin_dir().display());
if let Some(cp) = read_classpath_file(&env.root) {
println!("classpath: {cp}");
}
} else {
println!("bin: {}", bin.display());
}
println!("{runtime_label}: {}", env.runtime.as_deref().unwrap_or("-"));
println!("size: {}", human_size(env.size_bytes));
println!("built: {}", age_label(env.modified));
println!("last used: {}", age_label(env.last_used));
println!("ready: {}", if env.ready { "yes" } else { "no" });
if users.is_empty() {
println!("used by: (orphan — no script in the preferred tree)");
} else {
println!("used by:");
for chain in &users {
println!(" jan {chain}");
}
}
if installed.is_empty() {
println!("installed: (none found)");
} else {
println!("installed ({}):", installed.len());
for (name, version) in &installed {
if version.is_empty() {
println!(" {name}");
} else if env.manager == Manager::Gradle {
println!(" {name}-{version}.jar");
} else {
println!(" {name}{pin_sep}{version}");
}
}
}
Ok(0)
}
fn parse_age_duration(raw: &str) -> Result<Duration> {
let s = raw.trim();
if s.is_empty() {
bail!("empty `--older-than` value");
}
let (num_s, unit) = match s.chars().last() {
Some(c) if c.is_ascii_alphabetic() => {
let (n, u) = s.split_at(s.len() - 1);
(n, u.to_ascii_lowercase())
}
_ => (s, "d".to_string()),
};
let n: u64 = num_s
.parse()
.with_context(|| format!("invalid `--older-than` number in `{raw}`"))?;
let secs = match unit.as_str() {
"s" => n,
"m" => n.saturating_mul(60),
"h" => n.saturating_mul(3600),
"d" => n.saturating_mul(86_400),
"w" => n.saturating_mul(86_400 * 7),
other => bail!("unknown `--older-than` unit `{other}` (use s/m/h/d/w)"),
};
Ok(Duration::from_secs(secs))
}
fn parse_byte_size(raw: &str) -> Result<u64> {
let s = raw.trim();
if s.is_empty() {
bail!("empty `--min-size` value");
}
let lower = s.to_ascii_lowercase();
let (num_s, mult) = if let Some(rest) = lower.strip_suffix("kib") {
(rest, 1024u64)
} else if let Some(rest) = lower.strip_suffix("mib") {
(rest, 1024 * 1024)
} else if let Some(rest) = lower.strip_suffix("gib") {
(rest, 1024 * 1024 * 1024)
} else if let Some(rest) = lower.strip_suffix("tib") {
(rest, 1024u64.pow(4))
} else if let Some(rest) = lower.strip_suffix('k') {
(rest, 1024)
} else if let Some(rest) = lower.strip_suffix('m') {
(rest, 1024 * 1024)
} else if let Some(rest) = lower.strip_suffix('g') {
(rest, 1024 * 1024 * 1024)
} else if let Some(rest) = lower.strip_suffix('t') {
(rest, 1024u64.pow(4))
} else if let Some(rest) = lower.strip_suffix('b') {
(rest, 1)
} else {
(lower.as_str(), 1)
};
let n: u64 = num_s
.trim()
.parse()
.with_context(|| format!("invalid `--min-size` number in `{raw}`"))?;
Ok(n.saturating_mul(mult))
}
fn age_at_least(last_used: Option<SystemTime>, min_age: Duration) -> bool {
if min_age.is_zero() {
return true;
}
let Some(ts) = last_used else {
return true;
};
match SystemTime::now().duration_since(ts) {
Ok(elapsed) => elapsed >= min_age,
Err(_) => false, }
}
#[allow(clippy::too_many_arguments)]
fn packages_prune(
spec: &RootSpec,
use_root: &Path,
only: Option<Manager>,
json: bool,
orphans_only: bool,
older_than: Duration,
min_size: u64,
do_delete: bool,
) -> Result<i32> {
let envs: Vec<EnvInfo> = discover_envs()?
.into_iter()
.filter(|e| only.is_none_or(|m| e.manager == m))
.collect();
let decls = package_declarations(spec, use_root);
let users = users_by_env(&decls);
let mut skipped_referenced = 0usize;
let mut skipped_recent = 0usize;
let mut skipped_small = 0usize;
let mut victims: Vec<&EnvInfo> = Vec::new();
for e in &envs {
let is_orphan = !users.contains_key(&(e.manager, e.hash.clone()));
if orphans_only && !is_orphan {
skipped_referenced += 1;
continue;
}
if e.size_bytes < min_size {
skipped_small += 1;
continue;
}
if !age_at_least(e.last_used, older_than) {
skipped_recent += 1;
continue;
}
victims.push(e);
}
victims.sort_by(|a, b| {
match (a.last_used, b.last_used) {
(Some(x), Some(y)) => x.cmp(&y).then(b.size_bytes.cmp(&a.size_bytes)),
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
(None, None) => b.size_bytes.cmp(&a.size_bytes),
}
});
let total_bytes: u64 = victims.iter().map(|e| e.size_bytes).sum();
let scanned_bytes: u64 = envs.iter().map(|e| e.size_bytes).sum();
let older_label = format_older_than(older_than);
if !json {
print_cache_banner()?;
println!(
"scanned {} env{} ({})",
envs.len(),
if envs.len() == 1 { "" } else { "s" },
human_size(scanned_bytes)
);
}
if json {
let rows: Vec<_> = victims
.iter()
.map(|e| {
json!({
"manager": e.manager.id(),
"hash": e.hash,
"root": e.root.to_string_lossy(),
"size_bytes": e.size_bytes,
"used_age": age_label(e.last_used),
"orphan": !users.contains_key(&(e.manager, e.hash.clone())),
"deleted": do_delete,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json!({
"cache": cache_root()?.to_string_lossy(),
"jan_cache_dir": std::env::var("JAN_CACHE_DIR").ok(),
"dry_run": !do_delete,
"orphans_only": orphans_only,
"older_than_secs": older_than.as_secs(),
"min_size_bytes": min_size,
"scanned": envs.len(),
"scanned_bytes": scanned_bytes,
"skipped_referenced": skipped_referenced,
"skipped_recent": skipped_recent,
"skipped_small": skipped_small,
"count": victims.len(),
"bytes": total_bytes,
"envs": rows,
}))?
);
} else if victims.is_empty() {
println!(
"nothing to prune (orphans_only={orphans_only}, older_than={older_label}, min_size={})",
human_size(min_size)
);
if envs.is_empty() {
println!(" no package envs found under this cache root");
if std::env::var("JAN_CACHE_DIR").is_ok() {
println!(" tip: JAN_CACHE_DIR is set; unset it to scan ~/.cache/jan");
}
} else {
if skipped_referenced > 0 {
println!(
" skipped {skipped_referenced} still referenced by the preferred tree (pass `--all` to include)"
);
}
if skipped_recent > 0 {
println!(
" skipped {skipped_recent} used more recently than {older_label} (pass `--older-than 0` to include)"
);
}
if skipped_small > 0 {
println!(
" skipped {skipped_small} smaller than {} (lower `--min-size`)",
human_size(min_size)
);
}
}
} else {
let mode = if do_delete {
"removing"
} else {
"would remove"
};
println!(
"{mode} {} env{}, reclaiming {}:",
victims.len(),
if victims.len() == 1 { "" } else { "s" },
human_size(total_bytes)
);
for e in &victims {
let orphan = !users.contains_key(&(e.manager, e.hash.clone()));
println!(
" {} {} {} {} {}{}",
e.manager.id(),
e.hash,
human_size(e.size_bytes),
age_label(e.last_used),
e.root.display(),
if orphan { "" } else { " (referenced)" }
);
}
if !do_delete {
println!("dry-run only; re-run with `--yes` to delete");
}
}
if do_delete {
for e in &victims {
fs::remove_dir_all(&e.root)
.with_context(|| format!("remove package env {}", e.root.display()))?;
}
}
Ok(0)
}
#[allow(clippy::too_many_arguments)]
fn packages_path(
spec: &RootSpec,
use_root: &Path,
only: Option<Manager>,
selector: &str,
want_bin: bool,
want_python: bool,
want_modules: bool,
want_lib: bool,
want_classpath: bool,
) -> Result<i32> {
let envs: Vec<EnvInfo> = discover_envs()?
.into_iter()
.filter(|e| only.is_none_or(|m| e.manager == m))
.collect();
let decls: Vec<Declaration> = package_declarations(spec, use_root)
.into_iter()
.filter(|d| only.is_none_or(|m| d.manager == m))
.collect();
let env = resolve_selector(&envs, &decls, selector)?;
if want_python && env.manager != Manager::Uv {
bail!(
"`--python` needs a uv env; `{selector}` is a {} env",
env.manager.id()
);
}
if want_modules && env.manager != Manager::Pnpm {
bail!(
"`--modules` needs a pnpm env; `{selector}` is a {} env",
env.manager.id()
);
}
if (want_lib || want_classpath) && env.manager != Manager::Gradle {
bail!(
"`--lib` / `--classpath` need a gradle env; `{selector}` is a {} env",
env.manager.id()
);
}
if want_classpath {
let cp = read_classpath_file(&env.root)
.ok_or_else(|| anyhow::anyhow!("no classpath file in {}", env.root.display()))?;
println!("{cp}");
return Ok(0);
}
let out = if want_python {
venv_python(&env.bin_dir())
} else if want_modules {
env.root.join("node_modules")
} else if want_lib || (want_bin && env.manager == Manager::Gradle) {
env.root.join("lib")
} else if want_bin {
env.bin_dir()
} else {
env.root.clone()
};
println!("{}", out.display());
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{GradlePackages, PnpmPackages, UvPackages};
#[test]
fn list_hash_stable_under_reorder() {
let a = UvPackages::list(vec!["pyyaml==6.0.2".into(), "requests==2.32.3".into()]);
let b = UvPackages::list(vec!["requests==2.32.3".into(), "pyyaml==6.0.2".into()]);
let root = Path::new(".");
let ca = uv_canonical(&a, root).unwrap();
let cb = uv_canonical(&b, root).unwrap();
assert_eq!(ca, cb);
assert_eq!(decl_hash(&ca), decl_hash(&cb));
}
#[test]
fn exact_pins_accepted() {
for entry in [
"pandas==2.0.1",
"pandas == 2.0.1",
"pandas[perf]==2.0.1",
"pandas==2.0.1 # comment",
"pandas==2.0.1; python_version < '3.12'",
"torch===2.4.0+cpu",
"wheelhouse @ https://example.com/w-1.0-py3-none-any.whl#sha256=abc123",
] {
assert!(
check_pinned_requirement(entry).is_ok(),
"expected `{entry}` to be accepted"
);
}
}
#[test]
fn unpinned_requirements_rejected() {
for entry in [
"pandas",
"pandas>=2",
"pandas~=2.0",
"pandas!=1.9",
"pandas>=2,<3",
"pandas==2.0.*",
"pandas==",
"pandas==2.0.1,!=2.0.2",
"wheelhouse @ https://example.com/w-1.0-py3-none-any.whl",
"==2.0.1",
] {
assert!(
check_pinned_requirement(entry).is_err(),
"expected `{entry}` to be rejected"
);
}
}
#[test]
fn exact_npm_pins_accepted() {
for entry in [
"typescript@5.6.3",
"@types/node@22.7.4",
"zx@8.1.9-beta.1",
"esbuild@0.24.0+build.1",
] {
assert!(
check_pinned_npm_spec(entry).is_ok(),
"expected `{entry}` to be accepted"
);
}
}
#[test]
fn unpinned_npm_specs_rejected() {
for entry in [
"typescript",
"typescript@latest",
"typescript@^5.6.3",
"typescript@~5.6",
"typescript@>=5",
"typescript@5.6",
"typescript@5.6.x",
"typescript@5 || 6",
"typescript@",
"@types/node",
"ts@npm:typescript@5.6.3",
"pkg@github:user/repo#abc",
] {
assert!(
check_pinned_npm_spec(entry).is_err(),
"expected `{entry}` to be rejected"
);
}
}
#[test]
fn pnpm_list_hash_stable_under_reorder() {
let a = PnpmPackages::list(vec!["zx@8.1.9".into(), "typescript@5.6.3".into()]);
let b = PnpmPackages::list(vec!["typescript@5.6.3".into(), "zx@8.1.9".into()]);
let ca = pnpm_canonical(&a, Path::new(".")).unwrap();
let cb = pnpm_canonical(&b, Path::new(".")).unwrap();
assert_eq!(ca, cb);
let uv = uv_canonical(
&UvPackages::list(vec!["typescript==5.6.3".into()]),
Path::new("."),
)
.unwrap();
assert_ne!(decl_hash(&ca), decl_hash(&uv));
}
#[test]
fn unpinned_list_fails_canonicalization() {
let uv = UvPackages::list(vec!["pandas".into()]);
let err = uv_canonical(&uv, Path::new(".")).unwrap_err();
assert!(err.to_string().contains("must pin a version"));
}
#[test]
fn exact_maven_pins_accepted() {
for entry in [
"org.jetbrains.kotlinx:kotlinx-cli-jvm:0.3.6",
"com.google.guava:guava:33.3.1-jre",
"org.apache.commons:commons-lang3:3.17.0",
] {
assert!(
check_pinned_maven_coord(entry).is_ok(),
"expected `{entry}` to be accepted"
);
}
}
#[test]
fn unpinned_maven_coords_rejected() {
for entry in [
"guava",
"com.google.guava:guava",
"com.google.guava:guava:31.+",
"com.google.guava:guava:[31,32)",
"com.google.guava:guava:latest.release",
"com.google.guava:guava:1.0,2.0",
"g:a:v:classifier",
] {
assert!(
check_pinned_maven_coord(entry).is_err(),
"expected `{entry}` to be rejected"
);
}
}
#[test]
fn gradle_list_hash_stable_under_reorder() {
let a = GradlePackages::list(vec![
"org.jetbrains.kotlinx:kotlinx-cli-jvm:0.3.6".into(),
"com.google.guava:guava:33.3.1-jre".into(),
]);
let b = GradlePackages::list(vec![
"com.google.guava:guava:33.3.1-jre".into(),
"org.jetbrains.kotlinx:kotlinx-cli-jvm:0.3.6".into(),
]);
let ca = gradle_canonical(&a, Path::new(".")).unwrap();
let cb = gradle_canonical(&b, Path::new(".")).unwrap();
assert_eq!(ca, cb);
}
#[test]
fn list_and_requirements_differ() {
let list = UvPackages::list(vec!["pyyaml==6.0.2".into()]);
let c = uv_canonical(&list, Path::new(".")).unwrap();
assert!(c.starts_with("list:"));
assert_ne!(decl_hash(&c), decl_hash("requirements:/tmp/x#abc"));
}
#[test]
fn kotlin_type_name_from_path() {
assert_eq!(
kotlin_type_name(Path::new("scripts/files/json-summary.kt")),
"JsonSummary"
);
assert_eq!(kotlin_type_name(Path::new("HelloWorld.kt")), "HelloWorld");
assert_eq!(kotlin_type_name(Path::new("main.kt")), "Main");
}
#[test]
fn kotlin_package_prefix_reads_first_package() {
assert_eq!(
kotlin_package_prefix("// comment\npackage com.example.demo\n\nfun main() {}\n"),
Some("com.example.demo".into())
);
assert_eq!(kotlin_package_prefix("fun main() {}\n"), None);
}
#[test]
fn parse_age_duration_forms() {
assert_eq!(
parse_age_duration("30d").unwrap(),
Duration::from_secs(30 * 86_400)
);
assert_eq!(
parse_age_duration("12h").unwrap(),
Duration::from_secs(12 * 3600)
);
assert_eq!(
parse_age_duration("45m").unwrap(),
Duration::from_secs(45 * 60)
);
assert_eq!(
parse_age_duration("90").unwrap(),
Duration::from_secs(90 * 86_400)
);
assert_eq!(parse_age_duration("0").unwrap(), Duration::ZERO);
assert!(parse_age_duration("10x").is_err());
}
#[test]
fn parse_byte_size_forms() {
assert_eq!(parse_byte_size("0").unwrap(), 0);
assert_eq!(parse_byte_size("1024").unwrap(), 1024);
assert_eq!(parse_byte_size("100M").unwrap(), 100 * 1024 * 1024);
assert_eq!(parse_byte_size("1GiB").unwrap(), 1024 * 1024 * 1024);
assert_eq!(parse_byte_size("512KiB").unwrap(), 512 * 1024);
assert!(parse_byte_size("").is_err());
}
#[test]
fn kotlin_value_is_path_vs_inline() {
assert!(crate::ExecSpec::kotlin_value_is_path("scripts/a.kt"));
assert!(crate::ExecSpec::kotlin_value_is_path("x.kts"));
assert!(!crate::ExecSpec::kotlin_value_is_path("fun main() {}\n"));
assert!(!crate::ExecSpec::kotlin_value_is_path(
"import x\nfun main() {}"
));
}
#[test]
fn parse_min_version_constraint_forms() {
let (label, v) = parse_min_version_constraint("21").unwrap();
assert_eq!(label, ">=21");
assert_eq!(v, MinVersion::new(21, 0, 0));
let (_, v) = parse_min_version_constraint(">=3.11").unwrap();
assert_eq!(v, MinVersion::new(3, 11, 0));
let (_, v) = parse_min_version_constraint("18.0.0").unwrap();
assert_eq!(v, MinVersion::new(18, 0, 0));
assert!(parse_min_version_constraint("^21").is_err());
}
#[test]
fn extract_java_version_from_output() {
let v = extract_version_from_tool_output(
"openjdk version \"21.0.2\" 2024-01-16\nOpenJDK Runtime Environment",
)
.unwrap();
assert_eq!(v, MinVersion::new(21, 0, 2));
let v = extract_version_from_tool_output("java version \"1.8.0_392\"").unwrap();
assert_eq!(v, MinVersion::new(8, 0, 0));
}
#[test]
fn gradle_java_req_changes_hash() {
let a = GradlePackages::list(vec!["com.google.guava:guava:33.3.1-jre".into()]);
let mut b = a.clone();
b.java = Some("21".into());
let ca = gradle_canonical(&a, Path::new(".")).unwrap();
let cb = gradle_canonical(&b, Path::new(".")).unwrap();
assert!(cb.starts_with("java:>=21\n"));
assert_ne!(decl_hash(&ca), decl_hash(&cb));
}
#[test]
fn inject_jvm_classpath_inserts_cp() {
let envs = EnsuredEnvs {
gradle: Some(GradleEnv {
lib_dir: PathBuf::from("/cache/lib"),
classpath: "/cache/lib/a.jar:/cache/lib/b.jar".into(),
}),
..Default::default()
};
let mut argv = vec!["kotlinc".into(), "-script".into(), "x.kts".into()];
inject_jvm_classpath(&mut argv, &envs);
assert_eq!(
argv,
vec![
"kotlinc".to_string(),
"-cp".to_string(),
"/cache/lib/a.jar:/cache/lib/b.jar".to_string(),
"-script".to_string(),
"x.kts".to_string(),
]
);
inject_jvm_classpath(&mut argv, &envs);
assert_eq!(argv.iter().filter(|a| *a == "-cp").count(), 1);
}
}