jbx 0.6.2

jbx: one-stop Java toolbox for scripts, tools, and agents
Documentation
use anyhow::{anyhow, Context, Result};
use std::io::Read;
use std::path::PathBuf;
use std::process::Command as ProcessCommand;

#[derive(Debug, Clone)]
pub struct MavenToolOptions {
    pub coordinate: String,
    pub repos: Vec<String>,
    pub cache_dir: Option<PathBuf>,
    pub main_class: Option<String>,
    pub args: Vec<String>,
    pub progress: crate::ProgressOptions,
}

pub fn maven_repositories(repo_args: &[String]) -> Vec<crate::resolver::Repository> {
    let mut repos = vec![crate::resolver::Repository::central()];
    for repo in repo_args {
        if repo == "central" || repo == "mavenCentral" {
            continue;
        }
        if let Some((id, url)) = repo.split_once('=') {
            repos.push(crate::resolver::Repository {
                id: id.to_string(),
                url: url.to_string(),
            });
        } else {
            repos.push(crate::resolver::Repository {
                id: repo.clone(),
                url: repo.clone(),
            });
        }
    }
    repos
}

fn primary_jar_name(coordinate: &str) -> Result<String> {
    let dep = crate::resolver::parse_coordinate(coordinate)?;
    Ok(match dep.classifier {
        Some(classifier) => format!("{}-{}-{classifier}.jar", dep.module.name, dep.version),
        None => format!("{}-{}.jar", dep.module.name, dep.version),
    })
}

fn resolve_maven_tool_coordinate(
    coordinate: &str,
    repos: &[crate::resolver::Repository],
) -> Result<String> {
    let parts: Vec<&str> = coordinate.split(':').collect();
    if parts.len() != 2 {
        crate::resolver::parse_coordinate(coordinate)?;
        return Ok(coordinate.to_string());
    }
    let module = crate::resolver::Module {
        org: parts[0].to_string(),
        name: parts[1].to_string(),
    };
    let version = crate::resolver::resolve_latest_version(&module, repos)?;
    Ok(format!("{}:{}:{version}", module.org, module.name))
}

pub fn run(options: MavenToolOptions) -> Result<i32> {
    let cache_dir = match options.cache_dir {
        Some(path) => path,
        None => crate::default_cache_dir()?.join("deps"),
    };
    let repos = maven_repositories(&options.repos);
    let requested_coordinate = options.coordinate;
    let progress = options.progress;
    progress.phase(format!("resolving dependencies for {requested_coordinate}"));
    let coordinate = resolve_maven_tool_coordinate(&requested_coordinate, &repos)?;
    let coordinates = vec![coordinate.clone()];
    let classpath = crate::resolver::resolve_classpath(&coordinates, &repos, &cache_dir)?;
    if classpath.is_empty() {
        return Err(anyhow!("no JARs resolved for {coordinate}"));
    }

    let mut java = ProcessCommand::new("java");
    if let Some(main_class) = options.main_class {
        java.arg("-cp")
            .arg(std::env::join_paths(&classpath)?)
            .arg(main_class);
    } else {
        let jar_name = primary_jar_name(&coordinate)?;
        let primary_jar = classpath
            .iter()
            .find(|path| {
                path.file_name()
                    .is_some_and(|name| name == jar_name.as_str())
            })
            .ok_or_else(|| anyhow!("resolved classpath did not contain primary JAR {jar_name}"))?;
        if let Some(main_class) = read_main_class_from_jar(primary_jar)? {
            java.arg("-cp")
                .arg(std::env::join_paths(&classpath)?)
                .arg(main_class);
        } else {
            java.arg("-jar").arg(primary_jar);
        }
    }
    java.args(options.args);
    progress.phase(format!("running {coordinate}"));
    let status = java.status().context("failed to launch java")?;
    #[cfg(unix)]
    {
        use std::os::unix::process::ExitStatusExt;
        if let Some(signal) = status.signal() {
            return Ok(128 + signal);
        }
    }
    Ok(status.code().unwrap_or(1))
}

fn read_main_class_from_jar(jar: &std::path::Path) -> Result<Option<String>> {
    let file = std::fs::File::open(jar)
        .with_context(|| format!("failed to open primary JAR {}", jar.display()))?;
    let mut archive = zip::ZipArchive::new(file)
        .with_context(|| format!("failed to read primary JAR {}", jar.display()))?;
    let Ok(mut manifest) = archive.by_name("META-INF/MANIFEST.MF") else {
        return Ok(None);
    };
    let mut content = String::new();
    manifest
        .read_to_string(&mut content)
        .with_context(|| format!("failed to read manifest from {}", jar.display()))?;
    Ok(manifest_attribute(&content, "Main-Class"))
}

fn manifest_attribute(manifest: &str, key: &str) -> Option<String> {
    let mut unfolded: Vec<String> = Vec::new();
    for line in manifest.lines() {
        if let Some(continuation) = line.strip_prefix(' ') {
            if let Some(last) = unfolded.last_mut() {
                last.push_str(continuation);
            }
        } else {
            unfolded.push(line.to_string());
        }
    }
    let prefix = format!("{key}:");
    unfolded.into_iter().find_map(|line| {
        line.strip_prefix(&prefix)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(str::to_string)
    })
}