hopper-cli 0.1.0

Command-line tooling for Hopper account inspection, schema export, and migration planning
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Output};
use toml::Value;

pub fn current_dir() -> Result<PathBuf, String> {
    env::current_dir().map_err(|err| format!("Failed to determine current directory: {err}"))
}

pub fn find_project_root(start: &Path) -> Result<PathBuf, String> {
    for dir in start.ancestors() {
        if dir.join("Cargo.toml").exists() {
            return Ok(dir.to_path_buf());
        }
    }
    Err(format!(
        "No Cargo.toml found while searching upward from {}",
        start.display()
    ))
}

pub fn find_workspace_root(start: &Path) -> Result<PathBuf, String> {
    let mut workspace = None;
    for dir in start.ancestors() {
        let cargo_toml = dir.join("Cargo.toml");
        if !cargo_toml.exists() {
            continue;
        }

        let content = fs::read_to_string(&cargo_toml).unwrap_or_default();
        if content.contains("[workspace]") {
            workspace = Some(dir.to_path_buf());
        }
    }

    workspace
        .or_else(|| find_project_root(start).ok())
        .ok_or_else(|| {
            format!(
                "No Cargo workspace root found while searching upward from {}",
                start.display()
            )
        })
}

pub fn run_status(program: &str, args: &[String], cwd: &Path) -> Result<ExitStatus, String> {
    Command::new(program)
        .args(args)
        .current_dir(cwd)
        .status()
        .map_err(|err| format!("Failed to run {}: {err}", display_command(program, args)))
}

pub fn run_output(program: &str, args: &[String], cwd: &Path) -> Result<Output, String> {
    Command::new(program)
        .args(args)
        .current_dir(cwd)
        .output()
        .map_err(|err| format!("Failed to run {}: {err}", display_command(program, args)))
}

pub fn display_command(program: &str, args: &[String]) -> String {
    let mut rendered = String::from(program);
    for arg in args {
        rendered.push(' ');
        if arg.contains(' ') {
            rendered.push('"');
            rendered.push_str(arg);
            rendered.push('"');
        } else {
            rendered.push_str(arg);
        }
    }
    rendered
}

pub fn default_solana_keypair_path() -> Option<PathBuf> {
    if let Ok(explicit) = env::var("SOLANA_KEYPAIR") {
        if !explicit.trim().is_empty() {
            return Some(PathBuf::from(explicit));
        }
    }

    home_dir().map(|home| home.join(".config").join("solana").join("id.json"))
}

pub fn home_dir() -> Option<PathBuf> {
    env::var_os("USERPROFILE")
        .map(PathBuf::from)
        .or_else(|| env::var_os("HOME").map(PathBuf::from))
}

pub fn resolve_workspace_member_manifest(
    workspace_root: &Path,
    package: &str,
) -> Result<PathBuf, String> {
    let workspace_manifest_path = workspace_root.join("Cargo.toml");
    let workspace_manifest = fs::read_to_string(&workspace_manifest_path).map_err(|err| {
        format!(
            "Failed to read {}: {err}",
            workspace_manifest_path.display()
        )
    })?;
    let workspace_value: Value = workspace_manifest.parse().map_err(|err| {
        format!(
            "Failed to parse {}: {err}",
            workspace_manifest_path.display()
        )
    })?;

    let members = workspace_value
        .get("workspace")
        .and_then(Value::as_table)
        .and_then(|workspace| workspace.get("members"))
        .and_then(Value::as_array)
        .ok_or_else(|| {
            format!(
                "{} does not declare workspace members",
                workspace_manifest_path.display()
            )
        })?;

    for member in members {
        let Some(member_path) = member.as_str() else {
            continue;
        };
        let manifest_path = workspace_root.join(member_path).join("Cargo.toml");
        if !manifest_path.exists() {
            continue;
        }

        let manifest = fs::read_to_string(&manifest_path)
            .map_err(|err| format!("Failed to read {}: {err}", manifest_path.display()))?;
        let manifest_value: Value = manifest
            .parse()
            .map_err(|err| format!("Failed to parse {}: {err}", manifest_path.display()))?;

        let package_name = manifest_value
            .get("package")
            .and_then(Value::as_table)
            .and_then(|package_table| package_table.get("name"))
            .and_then(Value::as_str);

        if package_name == Some(package) {
            return Ok(manifest_path);
        }
    }

    Err(format!(
        "Could not find a workspace member named {} under {}",
        package,
        workspace_root.display()
    ))
}

pub fn infer_program_manifest_for_project(start: &Path) -> Result<PathBuf, String> {
    let project_root = find_project_root(start)?;
    infer_program_manifest_in_dir(&project_root)
}

pub fn infer_program_manifest_for_package(
    workspace_root: &Path,
    package: &str,
) -> Result<PathBuf, String> {
    let manifest_path = resolve_workspace_member_manifest(workspace_root, package)?;
    let project_root = manifest_path.parent().ok_or_else(|| {
        format!(
            "Resolved manifest {} has no parent directory",
            manifest_path.display()
        )
    })?;
    infer_program_manifest_in_dir(project_root)
}

fn infer_program_manifest_in_dir(project_root: &Path) -> Result<PathBuf, String> {
    let cargo_manifest = project_root.join("Cargo.toml");
    let package_name = if cargo_manifest.exists() {
        read_package_name(&cargo_manifest).ok()
    } else {
        None
    };

    let mut candidates = vec![project_root.join("hopper.manifest.json")];
    if let Some(name) = package_name {
        candidates.push(project_root.join(format!("{name}.manifest.json")));
    }
    candidates.push(project_root.join("manifest.json"));

    for candidate in candidates {
        if candidate.exists() {
            return Ok(candidate);
        }
    }

    Err(format!(
        "Could not find a generated Hopper program manifest under {}. Looked for hopper.manifest.json, <package>.manifest.json, and manifest.json.",
        project_root.display()
    ))
}

fn read_package_name(manifest_path: &Path) -> Result<String, String> {
    let manifest = fs::read_to_string(manifest_path)
        .map_err(|err| format!("Failed to read {}: {err}", manifest_path.display()))?;
    let manifest_value: Value = manifest
        .parse()
        .map_err(|err| format!("Failed to parse {}: {err}", manifest_path.display()))?;

    manifest_value
        .get("package")
        .and_then(Value::as_table)
        .and_then(|package_table| package_table.get("name"))
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
        .ok_or_else(|| format!("{} does not declare package.name", manifest_path.display()))
}

pub fn write_text_file(path: &Path, contents: &str, force: bool) -> Result<(), String> {
    if path.exists() && !force {
        return Err(format!(
            "Refusing to overwrite existing file {} without --force",
            path.display()
        ));
    }

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|err| format!("Failed to create directory {}: {err}", parent.display()))?;
    }

    fs::write(path, contents).map_err(|err| format!("Failed to write {}: {err}", path.display()))
}