stackenv 0.1.4

Native, zero-container multi-project dev environment manager. No Docker, no VMs.
use anyhow::{Result, bail};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Deserialize)]
pub struct Manifest {
    #[serde(default)]
    pub project: Project,
    #[serde(rename = "clone", default)]
    pub clones: Vec<CloneEntry>,
    #[serde(default)]
    pub language: BTreeMap<String, Language>,
    #[serde(default)]
    pub service: BTreeMap<String, Service>,
    pub run: Option<Run>,
    #[serde(default)]
    pub tool: BTreeMap<String, Tool>,
}

#[derive(Debug, Deserialize, Default)]
pub struct Project {
    #[serde(default)]
    pub name: String,
    pub domain: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct CloneEntry {
    pub repo: String,
    #[serde(default = "default_clone_path")]
    pub path: String,
    #[serde(rename = "ref")]
    pub git_ref: Option<String>,
}

fn default_clone_path() -> String {
    ".".to_string()
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum Language {
    Simple(String),
    Detailed {
        version: Option<String>,
        manager: Option<String>,
        plugin: Option<String>,
        binary: Option<String>,
        path: Option<String>,
    },
}

impl Language {
    pub fn version(&self) -> Option<&str> {
        match self {
            Language::Simple(v) => Some(v),
            Language::Detailed { version, .. } => version.as_deref(),
        }
    }

    pub fn manager(&self) -> Option<&str> {
        match self {
            Language::Simple(_) => None,
            Language::Detailed { manager, .. } => manager.as_deref(),
        }
    }

    pub fn plugin(&self) -> Option<&str> {
        match self {
            Language::Simple(_) => None,
            Language::Detailed { plugin, .. } => plugin.as_deref(),
        }
    }

    pub fn binary(&self) -> Option<&str> {
        match self {
            Language::Simple(_) => None,
            Language::Detailed { binary, .. } => binary.as_deref(),
        }
    }

    pub fn path(&self) -> Option<&str> {
        match self {
            Language::Simple(_) => None,
            Language::Detailed { path, .. } => path.as_deref(),
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct Service {
    pub version: String,
    pub schema: Option<String>,
    pub port: Option<u16>,
    pub command: Option<String>,
    pub path: Option<String>,
    #[serde(default)]
    pub external: bool,
}

impl Service {
    pub fn resolve_schema(&self, project_name: &str) -> String {
        self.schema.clone().unwrap_or_else(|| project_name.to_string())
    }

    pub fn resolve_port(&self, engine: &str) -> Option<u16> {
        self.port.or_else(|| conventional_port(engine))
    }

    pub fn resolve_command(&self, engine: &str) -> Option<String> {
        self.command.clone().or_else(|| default_command(engine).map(str::to_string))
    }

    pub fn validate(&self, engine: &str) -> Result<()> {
        if self.external && (self.path.is_some() || self.command.is_some()) {
            bail!("[service.{engine}] can't set path/command when external = true — stack only connects to it, never starts it");
        }
        Ok(())
    }
}

fn conventional_port(engine: &str) -> Option<u16> {
    match engine {
        "mysql" => Some(3306),
        "postgres" | "postgresql" => Some(5432),
        "mongo" | "mongodb" => Some(27017),
        _ => None,
    }
}

fn default_command(engine: &str) -> Option<&'static str> {
    match engine {
        "mysql" => Some("{path} --datadir={data_dir} --port={port}"),
        "postgres" | "postgresql" => Some(r#"{path} -D {data_dir} -o "-p {port}""#),
        "mongo" | "mongodb" => Some("{path} --dbpath {data_dir} --port {port}"),
        _ => None,
    }
}

#[derive(Debug, Deserialize)]
pub struct Run {
    pub command: Option<String>,
    pub port: Option<u16>,
    #[serde(default = "default_cwd")]
    pub cwd: String,
    #[serde(default)]
    pub external: bool,
}

fn default_cwd() -> String {
    ".".to_string()
}

impl Run {
    pub fn validate(&self) -> Result<()> {
        if self.external {
            if self.command.is_some() {
                bail!("[run].command is ignored when external = true — remove one or the other");
            }
            if self.port.is_none() {
                bail!("[run].port is required when external = true — stack has nothing to allocate for a process it isn't starting");
            }
        } else if self.command.is_none() {
            bail!("[run].command is required unless external = true");
        }
        Ok(())
    }
}

#[derive(Debug, Deserialize)]
pub struct Tool {
    pub path: Option<String>,
    pub version: Option<String>,
}

impl Manifest {
    pub fn load(path: &Path) -> Result<Self> {
        let text = std::fs::read_to_string(path)?;
        let mut manifest: Manifest = toml::from_str(&text)?;

        if manifest.project.name.is_empty() {
            manifest.project.name = path
                .parent()
                .and_then(|p| p.file_name())
                .and_then(|n| n.to_str())
                .unwrap_or("project")
                .to_string();
        }

        if manifest.project.domain.is_none() {
            manifest.project.domain = Some(format!("{}.localhost", manifest.project.name));
        }

        Ok(manifest)
    }

    pub fn find_and_load(start: &Path) -> Result<(PathBuf, Self)> {
        let mut dir = std::path::absolute(start)?;
        loop {
            let candidate = dir.join("stack.toml");
            if candidate.is_file() {
                let manifest = Self::load(&candidate)?;
                return Ok((candidate, manifest));
            }
            if !dir.pop() {
                bail!("no stack.toml found in this directory or any parent");
            }
        }
    }
}

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

    fn write_and_load(dir: &std::path::Path, contents: &str) -> Manifest {
        let path = dir.join("stack.toml");
        std::fs::write(&path, contents).unwrap();
        Manifest::load(&path).unwrap()
    }

    #[test]
    fn project_name_defaults_to_folder_name() {
        let tmp = std::env::temp_dir().join(format!("stack-test-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();
        let named_dir = tmp.join("my-cool-app");
        std::fs::create_dir_all(&named_dir).unwrap();

        let manifest = write_and_load(&named_dir, "[language]\nphp = \"8.3.1\"\n");
        assert_eq!(manifest.project.name, "my-cool-app");

        std::fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn domain_defaults_to_name_dot_localhost() {
        let tmp = std::env::temp_dir().join(format!("stack-test-domain-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();

        let manifest = write_and_load(&tmp, "[project]\nname = \"my-app\"\n");
        assert_eq!(manifest.project.domain, Some("my-app.localhost".to_string()));

        std::fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn explicit_domain_is_not_overridden() {
        let tmp = std::env::temp_dir().join(format!("stack-test-domain2-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();

        let manifest = write_and_load(&tmp, "[project]\nname = \"my-app\"\ndomain = \"custom.example\"\n");
        assert_eq!(manifest.project.domain, Some("custom.example".to_string()));

        std::fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn clone_path_defaults_to_dot() {
        let entry: CloneEntry = toml::from_str("repo = \"git@github.com:acme/x.git\"\n").unwrap();
        assert_eq!(entry.path, ".");
    }

    #[test]
    fn service_schema_defaults_to_project_name() {
        let svc: Service = toml::from_str("version = \"8.0.35\"\n").unwrap();
        assert_eq!(svc.resolve_schema("my_project"), "my_project");

        let svc_explicit: Service = toml::from_str("version = \"8.0.35\"\nschema = \"custom\"\n").unwrap();
        assert_eq!(svc_explicit.resolve_schema("my_project"), "custom");
    }

    #[test]
    fn service_port_defaults_to_conventional_port() {
        let svc: Service = toml::from_str("version = \"8.0.35\"\n").unwrap();
        assert_eq!(svc.resolve_port("mysql"), Some(3306));
        assert_eq!(svc.resolve_port("postgres"), Some(5432));
        assert_eq!(svc.resolve_port("mongo"), Some(27017));
        assert_eq!(svc.resolve_port("redis"), None);

        let svc_explicit: Service = toml::from_str("version = \"1\"\nport = 9999\n").unwrap();
        assert_eq!(svc_explicit.resolve_port("mysql"), Some(9999));
    }

    #[test]
    fn service_command_defaults_for_known_engines_only() {
        let svc: Service = toml::from_str("version = \"8.0.35\"\n").unwrap();
        assert!(svc.resolve_command("mysql").is_some());
        assert!(svc.resolve_command("redis").is_none());

        let svc_explicit: Service = toml::from_str("version = \"1\"\ncommand = \"custom --flag\"\n").unwrap();
        assert_eq!(svc_explicit.resolve_command("mysql"), Some("custom --flag".to_string()));
    }

    #[test]
    fn run_requires_command_unless_external() {
        let run: Run = toml::from_str("port = 8000\n").unwrap();
        assert!(run.validate().is_err());

        let run: Run = toml::from_str("command = \"php -S 127.0.0.1:{port}\"\n").unwrap();
        assert!(run.validate().is_ok());
    }

    #[test]
    fn run_external_rejects_command() {
        let run: Run = toml::from_str("external = true\nport = 8000\ncommand = \"uvicorn --reload\"\n").unwrap();
        assert!(run.validate().is_err());
    }

    #[test]
    fn run_external_requires_port() {
        let run: Run = toml::from_str("external = true\n").unwrap();
        assert!(run.validate().is_err());

        let run: Run = toml::from_str("external = true\nport = 8000\n").unwrap();
        assert!(run.validate().is_ok());
    }

    #[test]
    fn language_simple_string_form_parses() {
        let map: BTreeMap<String, Language> = toml::from_str("php = \"8.3.1\"\n").unwrap();
        let lang = map.get("php").unwrap();
        assert_eq!(lang.version(), Some("8.3.1"));
        assert_eq!(lang.manager(), None);
        assert_eq!(lang.path(), None);
    }

    #[test]
    fn language_detailed_table_form_parses() {
        let map: BTreeMap<String, Language> =
            toml::from_str("[rust]\nversion = \"1.75.0\"\nmanager = \"vfox\"\nplugin = \"rust\"\n").unwrap();
        let lang = map.get("rust").unwrap();
        assert_eq!(lang.version(), Some("1.75.0"));
        assert_eq!(lang.manager(), Some("vfox"));
        assert_eq!(lang.plugin(), Some("rust"));
    }

    #[test]
    fn language_byo_path_form_parses() {
        let map: BTreeMap<String, Language> = toml::from_str("[legacyphp]\npath = \"C:/tools/php-5.6/php.exe\"\n").unwrap();
        let lang = map.get("legacyphp").unwrap();
        assert_eq!(lang.path(), Some("C:/tools/php-5.6/php.exe"));
        assert_eq!(lang.version(), None);
    }

    #[test]
    fn tool_parses_byo_path_form() {
        let tool: Tool = toml::from_str("path = \"C:/tools/terraform.exe\"\n").unwrap();
        assert_eq!(tool.path.as_deref(), Some("C:/tools/terraform.exe"));
        assert_eq!(tool.version, None);
    }

    #[test]
    fn tool_parses_version_only_form() {
        let tool: Tool = toml::from_str("version = \"2.7.1\"\n").unwrap();
        assert_eq!(tool.version.as_deref(), Some("2.7.1"));
        assert_eq!(tool.path, None);
    }

    #[test]
    fn manifest_language_map_mixes_simple_and_detailed_entries() {
        let tmp = std::env::temp_dir().join(format!("stack-test-lang-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();

        let manifest = write_and_load(
            &tmp,
            "[language]\nphp = \"8.3.1\"\n\n[language.rust]\nversion = \"1.75.0\"\nmanager = \"vfox\"\n",
        );
        assert_eq!(manifest.language.get("php").unwrap().version(), Some("8.3.1"));
        assert_eq!(manifest.language.get("rust").unwrap().manager(), Some("vfox"));

        std::fs::remove_dir_all(&tmp).ok();
    }
}