stackenv 0.1.10

Native, zero-container multi-project dev environment manager. No Docker, no VMs.
use anyhow::{Context, Result, anyhow, bail};
use crate::core::manifest::Manifest;
use crate::core::projects::ProjectsFile;
use crate::core::registry::Registry;
use std::path::Path;

pub fn register(kind: &str, name: &str, version: &str, path: Option<&str>, external: bool, port: Option<u16>) -> Result<()> {
    if !matches!(kind, "service" | "tool" | "language") {
        bail!("unknown kind '{kind}' for stack register — supported: service, tool, language");
    }

    if external {
        if kind != "service" {
            bail!("--external only applies to `service` — `tool`/`language` registrations are always a BYO path");
        }
        if path.is_some() {
            bail!("can't set both a path and --external — pick one");
        }
        let port = port.ok_or_else(|| anyhow!("--external requires --port"))?;
        let mut registry = Registry::load();
        registry.register_external(name, version, port);
        registry.save().context("failed to persist registry")?;
        println!("registered service '{name}' @ {version} -> external, port {port}");
        return Ok(());
    }

    let path = path.ok_or_else(|| anyhow!("stack register {kind} {name} {version} <path> (or --external --port <port>, service only)"))?;
    if !Path::new(path).exists() {
        bail!("{path} does not exist");
    }
    let mut registry = Registry::load();
    registry.register_path(kind, name, version, path);
    registry.save().context("failed to persist registry")?;
    println!("registered {kind} '{name}' @ {version} -> {path}");
    Ok(())
}

pub fn unregister(kind: &str, name: &str, version: &str) -> Result<()> {
    if !matches!(kind, "service" | "tool" | "language") {
        bail!("unknown kind '{kind}' for stack unregister — supported: service, tool, language");
    }

    let mut registry = Registry::load();
    let removed = registry
        .remove(kind, name, version)
        .ok_or_else(|| anyhow!("no registered {kind} '{name}' @ {version} — check `stack list` for the exact kind/name/version"))?;
    registry.save().context("failed to persist registry")?;
    println!("unregistered {kind} '{name}' @ {version} -> {}", registry_entry_destination(&removed));
    Ok(())
}

fn installed_vfox_languages() -> Vec<(String, String)> {
    let mut found = Vec::new();
    let Some(cache_dir) = dirs::home_dir().map(|h| h.join(".vfox").join("cache")) else { return found };
    let Ok(plugins) = std::fs::read_dir(&cache_dir) else { return found };
    for plugin_entry in plugins.flatten() {
        let Ok(plugin_name) = plugin_entry.file_name().into_string() else { continue };
        let Ok(versions) = std::fs::read_dir(plugin_entry.path()) else { continue };
        for version_entry in versions.flatten() {
            if let Some(version) = version_entry.file_name().to_str().and_then(|v| v.strip_prefix("v-")) {
                found.push((plugin_name.clone(), version.to_string()));
            }
        }
    }
    found
}

fn installed_uv_pythons() -> std::collections::BTreeSet<String> {
    let mut versions = std::collections::BTreeSet::new();
    if let Ok(output) = std::process::Command::new("uv").args(["python", "list", "--only-installed"]).output()
        && output.status.success()
    {
        let text = String::from_utf8_lossy(&output.stdout);
        for line in text.lines() {
            if let Some(version) = line.split_whitespace().next() {
                versions.insert(version.to_string());
            }
        }
    }
    versions
}

pub fn list() {
    println!("languages (vfox):");
    let vfox_languages = installed_vfox_languages();
    if vfox_languages.is_empty() {
        println!("  (none)");
    }
    for (plugin, version) in &vfox_languages {
        println!("  {plugin}@{version}");
    }

    println!("languages (uv):");
    let uv_pythons = installed_uv_pythons();
    if uv_pythons.is_empty() {
        println!("  (none)");
    }
    for version in &uv_pythons {
        println!("  python@{version}");
    }

    println!("registered (BYO services/tools/languages):");
    let registry = Registry::load();
    if registry.entries.is_empty() {
        println!("  (none)");
    }
    for entry in registry.entries.values() {
        println!("  {}.{}@{} -> {}", entry.kind, entry.name, entry.version, registry_entry_destination(entry));
    }
}

fn registry_entry_destination(entry: &crate::core::registry::RegistryEntry) -> String {
    match &entry.path {
        Some(path) => path.clone(),
        None => format!("external, port {}", entry.port.map_or_else(|| "?".to_string(), |p| p.to_string())),
    }
}

pub fn prune(yes: bool, purge_data: bool) -> Result<()> {
    if purge_data && !yes {
        bail!("--purge-data requires --yes");
    }
    let mut projects = ProjectsFile::load();
    let mut referenced_languages: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
    let mut referenced_services: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
    let mut dirty = false;

    projects.projects.retain(|path, record| {
        let dir = Path::new(path);
        if !dir.is_dir() {
            println!("  (project no longer exists, dropping from tracking: {path})");
            dirty = true;
            return false;
        }
        match Manifest::load(&dir.join("stack.toml")) {
            Ok(manifest) => {
                for (name, entry) in &manifest.language {
                    if let Some(v) = entry.version() {
                        referenced_languages.insert((name.clone(), v.to_string()));
                    }
                }
                for (name, svc) in &manifest.service {
                    referenced_services.insert((name.clone(), svc.version.clone()));
                }
            }
            Err(_) => {
                for (n, v) in &record.languages {
                    referenced_languages.insert((n.clone(), v.clone()));
                }
                for (n, v) in &record.services {
                    referenced_services.insert((n.clone(), v.clone()));
                }
            }
        }
        true
    });
    if dirty {
        let _ = projects.save();
    }

    let mut orphan_vfox: Vec<(String, String)> = Vec::new();
    for (plugin, version) in installed_vfox_languages() {
        if !referenced_languages.contains(&(plugin.clone(), version.clone())) {
            orphan_vfox.push((plugin, version));
        }
    }
    let mut orphan_uv: Vec<String> = Vec::new();
    for version in installed_uv_pythons() {
        if !referenced_languages.iter().any(|(n, v)| n == "python" && *v == version) {
            orphan_uv.push(version);
        }
    }
    let registry = Registry::load();
    let orphan_services: Vec<&crate::core::registry::RegistryEntry> = registry
        .entries
        .values()
        .filter(|e| e.kind == "service" && !referenced_services.contains(&(e.name.clone(), e.version.clone())))
        .collect();

    println!("orphaned languages (vfox):");
    if orphan_vfox.is_empty() {
        println!("  (none)");
    }
    for (plugin, version) in &orphan_vfox {
        println!("  {plugin}@{version}");
    }
    println!("orphaned languages (uv):");
    if orphan_uv.is_empty() {
        println!("  (none)");
    }
    for version in &orphan_uv {
        println!("  python@{version}");
    }
    println!("orphaned registered services:");
    if orphan_services.is_empty() {
        println!("  (none)");
    }
    for entry in &orphan_services {
        println!("  {}@{} -> {}", entry.name, entry.version, registry_entry_destination(entry));
    }

    if !yes {
        println!("(dry run — pass --yes to uninstall orphaned language SDKs and drop orphaned registry entries; add --purge-data to also delete orphaned services' data directories)");
        return Ok(());
    }

    for (plugin, version) in &orphan_vfox {
        println!("  uninstalling vfox {plugin}@{version}...");
        if let Err(e) = std::process::Command::new("vfox").args(["uninstall", &format!("{plugin}@{version}")]).status() {
            eprintln!("    warning: failed to run vfox uninstall: {e}");
        }
    }
    for version in &orphan_uv {
        println!("  uninstalling uv python {version}...");
        if let Err(e) = std::process::Command::new("uv").args(["python", "uninstall", version]).status() {
            eprintln!("    warning: failed to run uv python uninstall: {e}");
        }
    }

    let orphan_service_keys: Vec<(String, String)> = orphan_services.iter().map(|e| (e.name.clone(), e.version.clone())).collect();
    let mut registry = Registry::load();
    registry.entries.retain(|_, e| e.kind != "service" || !orphan_service_keys.contains(&(e.name.clone(), e.version.clone())));
    registry.save().context("failed to persist registry")?;

    if purge_data {
        for (name, version) in &orphan_service_keys {
            if let Some(data_dir) = dirs::home_dir().map(|h| h.join(".stack").join("data").join(name).join(version)) {
                println!("  purging data directory {}", data_dir.display());
                if let Err(e) = std::fs::remove_dir_all(&data_dir) {
                    eprintln!("    warning: failed to remove {}: {e}", data_dir.display());
                }
            }
        }
    }

    Ok(())
}