hygiea 0.1.0

Dev tool proxy for Claude skills — wraps java, mvn, find and more
use anyhow::{Context, Result, bail};
use console::style;
use serde::Deserialize;
use std::path::{Path, PathBuf};

// Only the fields we care about — serde ignores everything else in pom.xml.
#[derive(Deserialize, Default)]
#[serde(default)]
struct Pom {
    properties: Properties,
    build: Build,
}

#[derive(Deserialize, Default)]
#[serde(default)]
struct Properties {
    #[serde(rename = "maven.compiler.release")]
    compiler_release: Option<String>,
    #[serde(rename = "maven.compiler.source")]
    compiler_source: Option<String>,
}

#[derive(Deserialize, Default)]
#[serde(default)]
struct Build {
    plugins: Plugins,
}

#[derive(Deserialize, Default)]
#[serde(default)]
struct Plugins {
    plugin: Vec<Plugin>,
}

#[derive(Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
struct Plugin {
    artifact_id: Option<String>,
    configuration: Option<CompilerConfiguration>,
}

#[derive(Deserialize, Default)]
#[serde(default)]
struct CompilerConfiguration {
    release: Option<String>,
    source: Option<String>,
}

/// Parse the Java version from a pom.xml.
///
/// Priority order (first match wins):
///   1. <properties><maven.compiler.release>
///   2. <properties><maven.compiler.source>
///   3. maven-compiler-plugin <configuration><release>
///   4. maven-compiler-plugin <configuration><source>
pub fn detect_java_version(pom: &Path) -> Result<u32> {
    let content =
        std::fs::read_to_string(pom).with_context(|| format!("cannot read {}", pom.display()))?;

    let parsed: Pom = quick_xml::de::from_str(&content)
        .with_context(|| format!("failed to parse {}", pom.display()))?;

    let plugin_cfg = parsed
        .build
        .plugins
        .plugin
        .iter()
        .find(|p| p.artifact_id.as_deref() == Some("maven-compiler-plugin"))
        .and_then(|p| p.configuration.as_ref());

    let ver = parsed.properties.compiler_release
        .or(parsed.properties.compiler_source)
        .or_else(|| plugin_cfg.and_then(|c| c.release.clone()))
        .or_else(|| plugin_cfg.and_then(|c| c.source.clone()))
        .ok_or_else(|| anyhow::anyhow!(
            "{}",
            style("Could not detect Java version from pom.xml").red().bold()
        ))?;

    parse_java_version(&ver)
}

/// Turn "1.8", "8", "11", "17", "21", "25" → the major version number.
pub fn parse_java_version(v: &str) -> Result<u32> {
    let v = v.trim();
    if let Some(rest) = v.strip_prefix("1.") {
        return Ok(rest.parse::<u32>().with_context(|| format!("bad java version: {v}"))?);
    }
    Ok(v.parse::<u32>().with_context(|| format!("bad java version: {v}"))?)
}

/// Locate the JDK home for the given major version.
/// Search order:
///   macOS  — /usr/libexec/java_home -v <major>
///   Linux  — /usr/lib/jvm/java-<major>-*  (apt)
///          — ~/.sdkman/candidates/java/<major>.*  (sdkman)
///          — /opt/jdk/jdk-<major>*  (manual tarball)
///   all    — $JAVA_HOME (only if it matches the requested major)
pub fn find_jdk_home(major: u32) -> Result<PathBuf> {
    if let Some(path) = try_find_jdk_home(major) {
        return Ok(path);
    }
    bail!(
        "{} JDK {} not found — install it or set JAVA_HOME",
        style("").red().bold(),
        style(major).yellow()
    );
}

fn try_find_jdk_home(major: u32) -> Option<PathBuf> {
    #[cfg(target_os = "macos")]
    if let Some(p) = find_jdk_macos(major) {
        return Some(p);
    }

    #[cfg(target_os = "linux")]
    if let Some(p) = find_jdk_linux(major) {
        return Some(p);
    }

    // Universal fallback: $JAVA_HOME if it matches the requested version
    find_jdk_env(major)
}

#[cfg(target_os = "macos")]
fn find_jdk_macos(major: u32) -> Option<PathBuf> {
    let out = duct::cmd!("/usr/libexec/java_home", "-v", major.to_string())
        .stderr_null()
        .read()
        .ok()?;
    let path = PathBuf::from(out.trim());
    path.exists().then_some(path)
}

#[cfg(target_os = "linux")]
fn find_jdk_linux(major: u32) -> Option<PathBuf> {
    // 1. apt: /usr/lib/jvm/java-{major}-*
    if let Some(p) = scan_dir("/usr/lib/jvm", &format!("java-{major}-")) {
        return Some(p);
    }
    // 2. sdkman: ~/.sdkman/candidates/java/{major}.*
    if let Some(home) = std::env::var("HOME").ok() {
        let sdkman = PathBuf::from(home).join(".sdkman/candidates/java");
        if let Some(p) = scan_dir(&sdkman, &format!("{major}.")) {
            return Some(p);
        }
    }
    // 3. manual tarball: /opt/jdk/jdk-{major}*
    if let Some(p) = scan_dir("/opt/jdk", &format!("jdk-{major}")) {
        return Some(p);
    }
    None
}

/// Scan `dir` for the first subdirectory whose name starts with `prefix`.
fn scan_dir(dir: impl AsRef<Path>, prefix: &str) -> Option<PathBuf> {
    let entries = std::fs::read_dir(dir).ok()?;
    entries
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.is_dir())
        .find(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .map(|n| n.starts_with(prefix))
                .unwrap_or(false)
        })
}

/// Check if $JAVA_HOME points to the requested major version.
fn find_jdk_env(major: u32) -> Option<PathBuf> {
    let home = std::env::var("JAVA_HOME").ok()?;
    let path = PathBuf::from(home);
    if !path.exists() {
        return None;
    }
    // Probe the version via `<JAVA_HOME>/bin/java -version`
    let java_bin = path.join("bin/java");
    let out = duct::cmd!(&java_bin, "-version")
        .stderr_capture()
        .run()
        .ok()?;
    let version_output = String::from_utf8_lossy(&out.stderr);
    // e.g. `openjdk version "21.0.3" ...` or `java version "1.8.0_392" ...`
    let version_str = version_output.lines().next()?;
    let quoted = version_str.find('"')?;
    let rest = &version_str[quoted + 1..];
    let end = rest.find('"')?;
    let ver = parse_java_version(&rest[..end]).ok()?;
    (ver == major).then_some(path)
}

/// Walk up from `start` looking for pom.xml, at most 3 levels up.
pub fn find_pom(start: &Path) -> Option<PathBuf> {
    let mut dir = start.to_path_buf();
    for _ in 0..3 {
        let candidate = dir.join("pom.xml");
        if candidate.exists() {
            return Some(candidate);
        }
        if !dir.pop() {
            break;
        }
    }
    None
}