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::caddy;
use crate::core::commands::shared::resolve_tool;
use crate::core::constants::STACK_ACCENT_RGB;
use crate::core::manifest::Manifest;
use crate::core::toolchain;
use crate::platform;
use std::path::{Path, PathBuf};

fn check_on_path(name: &str) -> Result<String> {
    match std::process::Command::new(name).arg("--version").output() {
        Ok(output) => {
            let stdout_line = String::from_utf8_lossy(&output.stdout).lines().next().unwrap_or("").trim().to_string();
            if stdout_line.is_empty() {
                let stderr_line = String::from_utf8_lossy(&output.stderr)
                    .lines()
                    .next()
                    .unwrap_or("(no version output)")
                    .trim()
                    .to_string();
                Ok(stderr_line)
            } else {
                Ok(stdout_line)
            }
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!("not found on PATH"),
        Err(e) => Err(anyhow!("failed to run: {e}")),
    }
}

const KNOWN_SERVICE_PATTERNS: &[(&str, &str, u16)] = &[("MySQL*", "mysql", 3306), ("MongoDB*", "mongo", 27017), ("postgresql*", "postgres", 5432)];

fn match_known_service(name: &str) -> Option<(&'static str, u16)> {
    let lower = name.to_lowercase();
    KNOWN_SERVICE_PATTERNS
        .iter()
        .find(|(pattern, _, _)| lower.starts_with(&pattern.trim_end_matches('*').to_lowercase()))
        .map(|(_, engine, port)| (*engine, *port))
}

#[cfg(windows)]
fn scan_windows_services() {
    let quoted_patterns = KNOWN_SERVICE_PATTERNS.iter().map(|(pattern, _, _)| format!("'{pattern}'")).collect::<Vec<_>>().join(",");
    let script =
        format!("Get-Service -Name {quoted_patterns} -ErrorAction SilentlyContinue | Where-Object {{ $_.Status -eq 'Running' }} | Select-Object -ExpandProperty Name");
    let Ok(output) = std::process::Command::new("powershell").args(["-NoProfile", "-Command", &script]).output() else {
        return;
    };
    let names: Vec<&str> = std::str::from_utf8(&output.stdout).unwrap_or("").lines().map(str::trim).filter(|l| !l.is_empty()).collect();
    if names.is_empty() {
        return;
    }

    println!("found running Windows Services matching known database engines:");
    for name in names {
        if let Some((engine, port)) = match_known_service(name) {
            println!("  '{name}' — if this is the {engine} instance you want stack to use, add `external = true` to [service.{engine}] in stack.toml (conventionally port {port}, adjust if yours differs)");
        }
    }
}

pub fn doctor(fix: bool) -> Result<()> {
    let mut all_ok = true;

    for tool in ["vfox", "uv", "caddy"] {
        let check = if tool == "caddy" {
            caddy::resolve_caddy_binary().and_then(|bin| check_on_path(&bin.to_string_lossy()))
        } else {
            check_on_path(tool)
        };
        match check {
            Ok(version) => {
                println!("  {tool}: OK ({version})");
                if let Some(pinned) = crate::core::pinned::pinned_version(tool)
                    && !version.contains(pinned)
                {
                    println!("    warning: stack was tested against {tool} {pinned}; installed version may behave differently");
                }
            }
            Err(e) => {
                println!("  {tool}: MISSING ({e:#})");
                all_ok = false;
                if fix {
                    let pinned =
                        crate::core::pinned::pinned_version(tool).ok_or_else(|| anyhow!("no pinned version known for '{tool}'"))?;
                    println!("  installing {tool}@{pinned}...");
                    match platform::install_pinned(tool, pinned) {
                        Ok(()) => println!("  {tool}: installed"),
                        Err(e) => println!("  {tool}: install failed: {e:#}"),
                    }
                }
            }
        }
    }

    #[cfg(windows)]
    scan_windows_services();

    if let Ok((path, manifest)) = Manifest::find_and_load(&PathBuf::from(".")) {
        if !manifest.tool.is_empty() {
            println!("tools declared in {}:", path.display());
            for (name, tool) in &manifest.tool {
                match resolve_tool(name, tool, fix) {
                    Ok(bin) => println!("  tool.{name}: OK ({})", bin.display()),
                    Err(e) => {
                        println!("  tool.{name}: MISSING ({e:#})");
                        all_ok = false;
                    }
                }
            }
        }
        let byo_languages: Vec<_> = manifest.language.iter().filter(|(_, entry)| entry.path().is_some()).collect();
        if !byo_languages.is_empty() {
            println!("languages with a BYO path declared in {}:", path.display());
            for (name, entry) in byo_languages {
                let byo_path = entry.path().expect("filtered to entries with a path");
                if Path::new(byo_path).is_file() {
                    println!("  language.{name}: OK ({byo_path})");
                } else {
                    println!("  language.{name}: MISSING ({byo_path})");
                    all_ok = false;
                }
            }
        }
    }

    if all_ok { Ok(()) } else { Err(anyhow!("one or more checks failed")) }
}

pub fn setup(shell: &str) {
    match crate::core::shell::ensure_hook_installed(shell) {
        Ok(true) => println!("added the stack hook for {shell}"),
        Ok(false) => println!("stack hook already present for {shell} — nothing to do"),
        Err(e) => eprintln!("warning: could not wire up the shell hook: {e:#}"),
    }

    println!("checking vfox/uv/caddy...");
    if let Err(e) = doctor(true) {
        eprintln!("warning: {e:#}");
    }

    #[cfg(windows)]
    println!("tip: for curl/Postman/non-browser clients to also resolve *.localhost, consider Acrylic DNS Proxy (optional, one-time, needs admin rights)");
    #[cfg(any(target_os = "macos", target_os = "linux"))]
    println!("tip: for curl/Postman/non-browser clients to also resolve *.localhost, consider Dnsmasq (optional, one-time, needs admin rights)");
}

pub fn hook(shell: &str) -> Result<()> {
    let script = crate::core::shell::hook_script(shell)?;
    println!("{script}");
    Ok(())
}

pub fn activate(shell: &str) {
    let Ok((_, manifest)) = Manifest::find_and_load(&PathBuf::from(".")) else {
        return;
    };

    print_active_indicator(shell);

    let mut dirs: Vec<PathBuf> = Vec::new();
    let mut php_binary: Option<PathBuf> = None;
    for (name, entry) in &manifest.language {
        if let Some(bin) = toolchain::lookup(name, entry) {
            if name == "php" {
                php_binary = Some(bin.clone());
            }
            if let Some(p) = bin.parent() {
                dirs.push(p.to_path_buf());
            }
        }
    }

    let composer_shadow = php_binary
        .as_ref()
        .and_then(|php| manifest.tool.get("composer").and_then(|tool| resolve_tool("composer", tool, false).ok().map(|phar| (php.clone(), phar))));
    match &composer_shadow {
        Some((php, phar)) => apply_composer_shadow(shell, php, phar),
        None if shell == "cmd" => clear_cmd_composer_shadow(),
        None => {}
    }

    if dirs.is_empty() {
        return;
    }

    let joined = std::env::join_paths(dirs).map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
    match shell {
        "pwsh" | "powershell" => println!("$env:PATH = \"{joined};\" + $env:PATH"),
        "cmd" => {
            // current_path is baked in as a literal value here, not printed as
            // %PATH% for cmd to expand later: %PATH% inside a FOR /F loop
            // variable's runtime value does not get expanded the way it would
            // in a normally-parsed command line.
            let current_path = std::env::var("PATH").unwrap_or_default();
            println!("SET PATH={joined};{current_path}");
        }
        _ => {}
    }
}

fn print_active_indicator(shell: &str) {
    let (r, g, b) = STACK_ACCENT_RGB;
    match shell {
        "pwsh" | "powershell" => println!("$global:__StackActive = $true"),
        "cmd" => {
            let prefix = format!("\x1b[38;2;{r};{g};{b}m[stack]\x1b[0m ");
            let current = std::env::var("PROMPT").unwrap_or_else(|_| "$P$G".to_string());
            let base = current.strip_prefix(prefix.as_str()).unwrap_or(current.as_str());
            println!("SET PROMPT={prefix}{base}");
        }
        _ => {}
    }
}

fn apply_composer_shadow(shell: &str, php: &Path, phar: &Path) {
    match shell {
        "pwsh" | "powershell" => {
            // global: is required: this is Invoke-Expression'd from inside the
            // wrapper's `prompt` function, and a plain `function composer {...}`
            // there is scoped local to `prompt` and vanishes when it returns.
            let escape_pwsh_single_quoted = |p: &Path| p.display().to_string().replace('\'', "''");
            println!("function global:composer {{ & '{}' '{}' @args }}", escape_pwsh_single_quoted(php), escape_pwsh_single_quoted(phar));
        }
        "cmd" => {
            if let Err(e) = write_cmd_composer_bat(php, phar) {
                eprintln!("warning: failed to write composer.bat: {e:#}");
            }
        }
        _ => {}
    }
}

fn cmd_composer_bat_path() -> Result<PathBuf> {
    Ok(crate::core::shell::stack_exe_dir()?.join("composer.bat"))
}

fn write_cmd_composer_bat(php: &Path, phar: &Path) -> Result<()> {
    let path = cmd_composer_bat_path()?;
    let content = format!("@echo off\r\n\"{}\" \"{}\" %*\r\n", php.display(), phar.display());
    std::fs::write(&path, content).with_context(|| format!("failed to write {}", path.display()))
}

fn clear_cmd_composer_shadow() {
    if let Ok(path) = cmd_composer_bat_path()
        && path.is_file()
    {
        let _ = std::fs::remove_file(&path);
    }
}

// Order matters: backtick must be escaped before `$`, or the fresh backtick
// that escaping `$` introduces would itself get escaped a second time.
fn escape_pwsh_double_quoted(value: &str) -> String {
    value.replace('`', "``").replace('$', "`$").replace('"', "`\"")
}

pub fn load_env(path: Option<String>) -> Result<()> {
    let path = path.unwrap_or_else(|| ".env".to_string());
    let iter = dotenvy::from_path_iter(&path).with_context(|| format!("failed to open {path}"))?;

    let mut names = Vec::new();
    for item in iter {
        let (key, value) = item.with_context(|| format!("failed to parse {path}"))?;
        println!("$env:{key} = \"{}\"", escape_pwsh_double_quoted(&value));
        names.push(key);
    }

    if names.is_empty() {
        eprintln!("loaded: (no variables found in {path})");
    } else {
        eprintln!("loaded: {}", names.join(", "));
    }
    Ok(())
}

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

    #[test]
    fn match_known_service_matches_expected_engines_case_insensitively() {
        assert_eq!(match_known_service("MySQL80"), Some(("mysql", 3306)));
        assert_eq!(match_known_service("mysql"), Some(("mysql", 3306)));
        assert_eq!(match_known_service("MongoDBR2"), Some(("mongo", 27017)));
        assert_eq!(match_known_service("postgresql-x64-16"), Some(("postgres", 5432)));
    }

    #[test]
    fn match_known_service_returns_none_for_unrelated_names() {
        assert_eq!(match_known_service("Spooler"), None);
        assert_eq!(match_known_service("WindowsUpdate"), None);
    }

    #[test]
    fn escape_pwsh_double_quoted_handles_backtick_dollar_and_quote_together() {
        let raw = "p@ss$word`with`backticks\"and\"quotes";
        let escaped = escape_pwsh_double_quoted(raw);
        assert_eq!(escaped, "p@ss`$word``with``backticks`\"and`\"quotes");
    }

    #[test]
    fn escape_pwsh_double_quoted_is_noop_for_plain_text() {
        assert_eq!(escape_pwsh_double_quoted("plain-value-123"), "plain-value-123");
    }

    #[test]
    fn load_env_dotenv_parsing_skips_blanks_and_comments_and_strips_quotes() {
        let dir = std::env::temp_dir().join(format!("stack-load-env-test-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let env_path = dir.join(".env");
        std::fs::write(&env_path, "# a full-line comment\n\nPLAIN=value1\nQUOTED=\"value with spaces\"\n").unwrap();

        let iter = dotenvy::from_path_iter(&env_path).unwrap();
        let pairs: Vec<(String, String)> = iter.map(|item| item.unwrap()).collect();

        assert_eq!(pairs, vec![
            ("PLAIN".to_string(), "value1".to_string()),
            ("QUOTED".to_string(), "value with spaces".to_string()),
        ]);

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

    #[test]
    fn load_env_missing_file_is_a_hard_error() {
        let result = load_env(Some("this-file-does-not-exist.env".to_string()));
        assert!(result.is_err());
    }
}