rivox 1.0.0

Universal polyglot build coordination layer for Python, Rust, and Node monorepos
Documentation
use super::EcosystemAdapter;
use crate::graph::GraphNode;
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

pub struct GradleAdapter;

impl EcosystemAdapter for GradleAdapter {
    fn name(&self) -> &'static str {
        "gradle"
    }

    fn detect_tool(&self) -> Result<String> {
        let gradlew = if cfg!(windows) {
            "gradlew.bat"
        } else {
            "./gradlew"
        };
        let output = Command::new(gradlew)
            .arg("--version")
            .output()
            .or_else(|_| Command::new("gradle").arg("--version").output())
            .context("Failed to execute `gradle --version`. Ensure Gradle or gradlew wrapper is installed.")?;

        if !output.status.success() {
            bail!(
                "`gradle --version` failed with exit status: {}",
                output.status
            );
        }

        let version_str = String::from_utf8_lossy(&output.stdout)
            .lines()
            .find(|line| line.starts_with("Gradle "))
            .unwrap_or("Gradle 8.x")
            .to_string();

        Ok(version_str)
    }

    fn resolve(&self, workspace_path: &Path, frozen: bool) -> Result<PathBuf> {
        let lockfile_path = workspace_path.join("gradle.lockfile");

        if frozen && lockfile_path.exists() {
            return Ok(lockfile_path);
        }

        let gradlew = if cfg!(windows) {
            "gradlew.bat"
        } else {
            "./gradlew"
        };
        let mut cmd = if workspace_path.join(gradlew).exists() {
            Command::new(workspace_path.join(gradlew))
        } else {
            Command::new("gradle")
        };

        cmd.arg("dependencies")
            .arg("--write-locks")
            .current_dir(workspace_path);

        let output = cmd
            .output()
            .context("Failed to run `gradle dependencies --write-locks` in workspace")?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            tracing::warn!("Gradle write-locks warning: {}", stderr);
        }

        if lockfile_path.exists() {
            return Ok(lockfile_path);
        }

        // Check for alternative Gradle files (build.gradle or build.gradle.kts)
        let build_gradle = workspace_path.join("build.gradle");
        let build_gradle_kts = workspace_path.join("build.gradle.kts");

        if build_gradle.exists() {
            Ok(build_gradle)
        } else if build_gradle_kts.exists() {
            Ok(build_gradle_kts)
        } else {
            bail!(
                "Neither `gradle.lockfile` nor `build.gradle` found at {}",
                workspace_path.display()
            );
        }
    }

    fn parse_lockfile(&self, lockfile_path: &Path) -> Result<Vec<GraphNode>> {
        let content = fs::read_to_string(lockfile_path).with_context(|| {
            format!("Failed to read Gradle file at {}", lockfile_path.display())
        })?;

        let mut nodes = Vec::new();

        // If parsing gradle.lockfile format: <group>:<name>:<version>=<configuration>
        for line in content.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with("#") || line.starts_with("//") {
                continue;
            }

            // e.g. org.springframework.boot:spring-boot-starter-web:3.2.0=compileClasspath
            if let Some((dep_spec, _config)) = line.split_once('=') {
                let parts: Vec<&str> = dep_spec.split(':').collect();
                if parts.len() >= 3 {
                    let group = parts[0];
                    let artifact = parts[1];
                    let version = parts[2];
                    let pkg_name = format!("{}:{}", group, artifact);

                    let content_hash = format!(
                        "sha256:{}",
                        hex::encode(Sha256::digest(
                            format!("gradle:{}:{}:{}", group, artifact, version).as_bytes()
                        ))
                    );

                    nodes.push(GraphNode {
                        ecosystem: "gradle".to_string(),
                        package_name: pkg_name,
                        resolved_version: version.to_string(),
                        content_hash,
                        platform_markers: None,
                        lock_ref: lockfile_path.to_string_lossy().to_string(),
                    });
                }
            } else if line.contains("implementation") || line.contains("api") || line.contains("id")
            {
                // Parse build.gradle / build.gradle.kts dependency declarations as fallback
                let cleaned = line
                    .replace(['"', '\''], "")
                    .replace("implementation", "")
                    .replace("api", "")
                    .trim()
                    .to_string();
                let parts: Vec<&str> = cleaned.split(':').collect();
                if parts.len() >= 3 {
                    let group = parts[0].trim();
                    let artifact = parts[1].trim();
                    let version = parts[2].trim();
                    let pkg_name = format!("{}:{}", group, artifact);

                    let content_hash = format!(
                        "sha256:{}",
                        hex::encode(Sha256::digest(
                            format!("gradle:{}:{}:{}", group, artifact, version).as_bytes()
                        ))
                    );

                    nodes.push(GraphNode {
                        ecosystem: "gradle".to_string(),
                        package_name: pkg_name,
                        resolved_version: version.to_string(),
                        content_hash,
                        platform_markers: None,
                        lock_ref: lockfile_path.to_string_lossy().to_string(),
                    });
                }
            }
        }

        nodes.sort_by(|a, b| {
            a.package_name
                .cmp(&b.package_name)
                .then(a.resolved_version.cmp(&b.resolved_version))
        });
        nodes.dedup_by(|a, b| {
            a.package_name == b.package_name && a.resolved_version == b.resolved_version
        });

        Ok(nodes)
    }
}