rok-cli 0.6.1

Developer CLI for rok-based Axum applications
//! `rok plugin:install/list/remove` — plugin system (M7.12).

use std::{
    collections::HashMap,
    fs,
    path::PathBuf,
};

fn plugins_dir() -> PathBuf {
    let base = dirs_next::config_dir()
        .unwrap_or_else(|| PathBuf::from(".config"));
    base.join("rok").join("plugins")
}

fn plugins_manifest() -> PathBuf {
    plugins_dir().join("plugins.json")
}

fn load_plugins() -> HashMap<String, PluginEntry> {
    let path = plugins_manifest();
    if !path.exists() { return HashMap::new(); }
    let content = fs::read_to_string(&path).unwrap_or_default();
    serde_json::from_str(&content).unwrap_or_default()
}

fn save_plugins(plugins: &HashMap<String, PluginEntry>) -> anyhow::Result<()> {
    let dir = plugins_dir();
    fs::create_dir_all(&dir)?;
    let json = serde_json::to_string_pretty(plugins)?;
    fs::write(plugins_manifest(), json)?;
    Ok(())
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct PluginEntry {
    source: String,
    version: Option<String>,
    description: Option<String>,
    installed_at: String,
}

pub fn install(name: &str, source: Option<&str>) -> anyhow::Result<()> {
    let src = source.unwrap_or(name);
    println!("Installing plugin: {name} from {src}");

    // For cargo-based plugins (e.g. rok-plugin-<name>)
    let crate_name = if src.starts_with("rok-plugin-") || src.starts_with("http") {
        src.to_string()
    } else {
        format!("rok-plugin-{src}")
    };

    let status = std::process::Command::new("cargo")
        .args(["install", &crate_name])
        .status()?;

    if !status.success() {
        anyhow::bail!("cargo install {crate_name} failed — ensure the crate exists on crates.io");
    }

    let mut plugins = load_plugins();
    plugins.insert(name.to_string(), PluginEntry {
        source: crate_name,
        version: None,
        description: None,
        installed_at: chrono::Utc::now().to_rfc3339(),
    });
    save_plugins(&plugins)?;

    println!("  ✓ Plugin '{name}' installed.");
    println!("    Invoke with: rok plugin:{name} [args]");
    Ok(())
}

pub fn list() -> anyhow::Result<()> {
    let plugins = load_plugins();
    if plugins.is_empty() {
        println!("No plugins installed.");
        println!("Install one with: rok plugin:install <name>");
    } else {
        println!("{:<30} {:<15} SOURCE", "NAME", "INSTALLED");
        println!("{}", "".repeat(70));
        for (name, entry) in &plugins {
            println!("{:<30} {:<15} {}", name, &entry.installed_at[..10], entry.source);
        }
    }
    Ok(())
}

pub fn remove(name: &str) -> anyhow::Result<()> {
    let mut plugins = load_plugins();
    if let Some(entry) = plugins.remove(name) {
        let status = std::process::Command::new("cargo")
            .args(["uninstall", &entry.source])
            .status()?;
        if !status.success() {
            println!("  ! cargo uninstall failed — removing from manifest only");
        }
        save_plugins(&plugins)?;
        println!("  ✓ Plugin '{name}' removed.");
    } else {
        println!("  Plugin '{name}' is not installed.");
    }
    Ok(())
}