use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
use anyhow::{bail, Context, Result};
use serde_json::json;
use crate::remote::{self, cache_root};
use crate::spec_load;
use crate::{
CommandNode, GradlePackages, PackagesSpec, PnpmPackages, RootSpec, RunContext, 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 {
#[allow(dead_code)]
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> {
match uv {
UvPackages::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)?;
}
Ok(format!("list:\n{}", sorted.join("\n")))
}
UvPackages::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)?;
Ok(format!(
"project:{}#{digest}\nlock:{lock_digest}",
dir.to_string_lossy()
))
}
UvPackages::Requirements(rel) => {
let file = spec_load::resolve_under_use_root(use_root, rel)?;
check_requirements_pins(&file)?;
let digest = remote::sha256_file(&file)?;
Ok(format!(
"requirements:{}#{}",
file.to_string_lossy(),
digest
))
}
}
}
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> {
match pnpm {
PnpmPackages::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)?;
}
Ok(format!("list:\n{}", sorted.join("\n")))
}
PnpmPackages::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)?;
Ok(format!(
"project:{}#{digest}\nlock:{lock_digest}",
dir.to_string_lossy()
))
}
}
}
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> {
match gradle {
GradlePackages::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)?;
}
Ok(format!("list:\n{}", sorted.join("\n")))
}
GradlePackages::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)?;
Ok(format!(
"project:{}#{digest}\nlock:{lock_digest}",
dir.to_string_lossy()
))
}
}
}
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 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() {
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 {
run_uv(&["venv", "--clear", root.to_str().unwrap()], None)?;
}
let python = venv_python(&bin);
let python_s = python.to_string_lossy();
match uv {
UvPackages::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,
)?;
}
UvPackages::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,
)?;
}
UvPackages::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()))?;
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() {
return Ok(PnpmEnv {
bin_dir: bin,
modules_dir: modules,
});
}
}
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 {
PnpmPackages::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)?;
}
PnpmPackages::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()))?;
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) {
return Ok(GradleEnv {
lib_dir: lib,
classpath,
});
}
}
}
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 {
GradlePackages::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",
&[
"--no-daemon",
"--console=plain",
"--project-dir",
root.to_str().unwrap(),
"janCopyDeps",
],
None,
)?;
}
GradlePackages::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",
&[
"--no-daemon",
"--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()))?;
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 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>,
}
impl EnvInfo {
pub fn kind(&self) -> &'static str {
match self.canonical.as_deref() {
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();
};
if let Some(rest) = canonical.strip_prefix("list:") {
let pkgs: Vec<&str> = rest
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
return pkgs.join(", ");
}
let body = canonical.lines().next().unwrap_or(canonical);
let without_kind = body.split_once(':').map(|(_, r)| r).unwrap_or(body);
without_kind
.rsplit_once('#')
.map(|(p, _)| p.to_string())
.unwrap_or_else(|| without_kind.to_string())
}
}
#[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 {
match uv {
UvPackages::List(pkgs) => pkgs.join(", "),
UvPackages::Project(p) => format!("project {p}"),
UvPackages::Requirements(r) => format!("requirements {r}"),
}
}
fn pnpm_summary(pnpm: &PnpmPackages) -> String {
match pnpm {
PnpmPackages::List(pkgs) => pkgs.join(", "),
PnpmPackages::Project(p) => format!("project {p}"),
}
}
fn gradle_summary(gradle: &GradlePackages) -> String {
match gradle {
GradlePackages::List(pkgs) => pkgs.join(", "),
GradlePackages::Project(p) => format!("project {p}"),
}
}
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 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();
for manager in [Manager::Uv, Manager::Pnpm, Manager::Gradle] {
let root = manager.envs_root()?;
for entry in fs::read_dir(&root)
.with_context(|| format!("read {}", root.display()))?
.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(),
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)
}
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 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]
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.
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
--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 mut sub: Option<String> = None;
let mut selector: Option<String> = None;
let mut json = false;
let mut orphans_only = false;
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;
for arg in args {
let s = arg.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_packages_help();
return Ok(0);
}
"--json" => json = true,
"--orphans" => orphans_only = 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),
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}`"),
}
}
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,
)
}
other => {
print_packages_help();
bail!("unknown packages subcommand `{other}` (list | show | path)")
}
}
}
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,
"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(),
manager.envs_root()?.to_string_lossy().into_owned(),
);
}
}
println!(
"{}",
serde_json::to_string_pretty(&json!({
"roots": roots,
"envs": envs_json,
"not_built": missing,
}))?
);
return Ok(0);
}
for manager in [Manager::Uv, Manager::Pnpm, Manager::Gradle] {
if only.is_none_or(|m| m == manager) {
println!("{} envs: {}", manager.id(), manager.envs_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", "BUILT"
);
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.modified),
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,
"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!("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)
}
#[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"));
}
}