stackenv 0.1.3

Native, zero-container multi-project dev environment manager. No Docker, no VMs.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Registry {
    #[serde(default)]
    pub entries: BTreeMap<String, RegistryEntry>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct RegistryEntry {
    pub kind: String,
    pub name: String,
    pub version: String,
    pub path: Option<String>,
    #[serde(default)]
    pub external: bool,
    pub port: Option<u16>,
}

fn key(kind: &str, name: &str, version: &str) -> String {
    format!("{kind}:{name}:{version}")
}

impl Registry {
    pub fn registry_path() -> PathBuf {
        let base = dirs::home_dir().expect("could not resolve home directory");
        base.join(".stack").join("registry.json")
    }

    pub fn load() -> Self {
        let path = Self::registry_path();
        match std::fs::read_to_string(&path) {
            Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
            Err(_) => Self::default(),
        }
    }

    pub fn save(&self) -> std::io::Result<()> {
        let path = Self::registry_path();
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let text = serde_json::to_string_pretty(self).expect("registry serializes");
        std::fs::write(path, text)
    }

    pub fn register_path(&mut self, kind: &str, name: &str, version: &str, path: &str) {
        self.entries.insert(
            key(kind, name, version),
            RegistryEntry { kind: kind.to_string(), name: name.to_string(), version: version.to_string(), path: Some(path.to_string()), external: false, port: None },
        );
    }

    pub fn register_external(&mut self, name: &str, version: &str, port: u16) {
        self.entries.insert(
            key("service", name, version),
            RegistryEntry { kind: "service".to_string(), name: name.to_string(), version: version.to_string(), path: None, external: true, port: Some(port) },
        );
    }

    pub fn lookup(&self, kind: &str, name: &str, version: &str) -> Option<&RegistryEntry> {
        self.entries.get(&key(kind, name, version))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn register_path_round_trips() {
        let mut registry = Registry::default();
        registry.register_path("tool", "terraform", "1.7.0", "C:/tools/terraform.exe");
        let entry = registry.lookup("tool", "terraform", "1.7.0").unwrap();
        assert_eq!(entry.path.as_deref(), Some("C:/tools/terraform.exe"));
        assert!(!entry.external);
        assert_eq!(entry.port, None);
    }

    #[test]
    fn register_external_round_trips() {
        let mut registry = Registry::default();
        registry.register_external("mysql", "8.0.35", 3306);
        let entry = registry.lookup("service", "mysql", "8.0.35").unwrap();
        assert_eq!(entry.path, None);
        assert!(entry.external);
        assert_eq!(entry.port, Some(3306));
    }

    #[test]
    fn lookup_returns_none_for_unregistered_entry() {
        let registry = Registry::default();
        assert!(registry.lookup("service", "nope", "1.0").is_none());
    }

    #[test]
    fn old_registry_json_without_external_or_port_still_deserializes() {
        let json = r#"{"entries":{"service:mysql:8.0.35":{"kind":"service","name":"mysql","version":"8.0.35","path":"C:/mysql/mysqld.exe"}}}"#;
        let registry: Registry = serde_json::from_str(json).unwrap();
        let entry = registry.lookup("service", "mysql", "8.0.35").unwrap();
        assert_eq!(entry.path.as_deref(), Some("C:/mysql/mysqld.exe"));
        assert!(!entry.external);
        assert_eq!(entry.port, None);
    }
}