o- 0.5.0

Multi-Engine JavaScript Runtime
Documentation
use clap::Parser;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::pm::{PmError, install_from};

#[derive(Parser, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Args {
    pub package: String,
    #[arg(last = true)]
    pub args: Vec<String>,
}

pub fn parse_package(package: &str) -> Result<(String, String), PmError> {
    let package = package.trim();
    if package.is_empty() {
        return Err(PmError::InvalidPackageSpec {
            spec: package.to_string(),
        });
    }

    if package.starts_with('@') {
        let slash = package.find('/').ok_or_else(|| PmError::InvalidPackageSpec {
            spec: package.to_string(),
        })?;
        let tail = &package[slash + 1..];

        if let Some(at) = tail.rfind('@') {
            let split_index = slash + 1 + at;
            let name = &package[..split_index];
            let version = &package[split_index + 1..];
            if version.is_empty() {
                return Err(PmError::InvalidPackageSpec {
                    spec: package.to_string(),
                });
            }
            return Ok((name.to_string(), version.to_string()));
        }

        return Ok((package.to_string(), "latest".to_string()));
    }

    if let Some((name, version)) = package.rsplit_once('@') {
        if name.is_empty() || version.is_empty() {
            return Err(PmError::InvalidPackageSpec {
                spec: package.to_string(),
            });
        }
        return Ok((name.to_string(), version.to_string()));
    }

    Ok((package.to_string(), "latest".to_string()))
}

pub fn process(package: &str, version: &str, args: &[String]) -> Result<String, PmError> {
    let temp = tempfile::tempdir().map_err(|source| PmError::CreateTempDir { source })?;
    let path = temp.path().to_owned();

    let manifest = format!(
        r#"{{
  "name": "____o-x-generated____",
  "version": "1.0.0",
  "private": true,
  "dependencies": {{
    "{}": "{}"
  }}
}}"#,
        package, version
    );

    let manifest_path = path.join("package.json");
    fs::write(&manifest_path, manifest).map_err(|source| PmError::WriteGeneratedManifest {
        path: manifest_path.clone(),
        source,
    })?;

    let path_str = path
        .to_str()
        .ok_or_else(|| PmError::InvalidTempPath { path: path.clone() })?;

    install_from(path_str)?;

    let package_dir = install_dir(&path.join("node_modules"), package);
    let package_json_path = package_dir.join("package.json");
    let command_name = resolve_bin_command(package, &package_json_path)?;
    let command_path = resolve_shim_path(&path.join("node_modules"), &command_name);
    if !command_path.is_file() {
        return Err(PmError::MissingPackageBinary {
            package: package.to_string(),
            command: command_name,
            path: command_path,
        });
    }

    let output = Command::new(&command_path)
        .args(args)
        .current_dir(&path)
        .output()
        .map_err(|source| PmError::SpawnPackageBinary {
            package: package.to_string(),
            command: command_path.clone(),
            source,
        })?;

    if !output.status.success() {
        return Err(PmError::PackageBinaryFailed {
            package: package.to_string(),
            command: command_path,
            status: output
                .status
                .code()
                .map(|code| code.to_string())
                .unwrap_or_else(|| output.status.to_string()),
            stderr: stderr_string(&output.stderr),
        });
    }

    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    Ok(stdout)
}

fn resolve_bin_command(package: &str, package_json_path: &Path) -> Result<String, PmError> {
    let source =
        fs::read_to_string(package_json_path).map_err(|source| PmError::ReadInstalledManifest {
            path: package_json_path.to_path_buf(),
            source,
        })?;
    let value: Value = serde_json::from_str(&source).map_err(|source| PmError::ParseManifest {
        path: package_json_path.to_path_buf(),
        source,
    })?;

    let default_name = default_bin_name(package);
    let Some(bin_value) = value.get("bin") else {
        return Ok(default_name);
    };

    match bin_value {
        Value::String(_) => Ok(default_name),
        Value::Object(entries) => {
            if entries.contains_key(&default_name) {
                return Ok(default_name);
            }

            if entries.len() == 1 {
                if let Some((name, _)) = entries.iter().next() {
                    return Ok(name.clone());
                }
            }

            Err(PmError::AmbiguousBinEntry {
                package: package.to_string(),
                path: package_json_path.to_path_buf(),
                available: entries.keys().cloned().collect(),
            })
        }
        Value::Null => Ok(default_name),
        _ => Err(PmError::InvalidBinField {
            path: package_json_path.to_path_buf(),
        }),
    }
}

fn default_bin_name(package: &str) -> String {
    package
        .rsplit_once('/')
        .map(|(_, name)| name.to_string())
        .unwrap_or_else(|| package.to_string())
}

fn install_dir(node_modules_dir: &Path, package_name: &str) -> PathBuf {
    if let Some((scope, name)) = package_name.split_once('/') {
        node_modules_dir.join(scope).join(name)
    } else {
        node_modules_dir.join(package_name)
    }
}

#[cfg(unix)]
fn resolve_shim_path(node_modules_dir: &Path, command_name: &str) -> PathBuf {
    node_modules_dir.join(".bin").join(command_name)
}

#[cfg(windows)]
fn resolve_shim_path(node_modules_dir: &Path, command_name: &str) -> PathBuf {
    node_modules_dir
        .join(".bin")
        .join(format!("{command_name}.cmd"))
}

fn stderr_string(stderr: &[u8]) -> Option<String> {
    if stderr.is_empty() {
        None
    } else {
        Some(String::from_utf8_lossy(stderr).trim().to_string())
    }
}