sdd-layer 0.19.1

Spec-Driven Development CLI and agent harness
//! Utilidades cross-platform (Windows, Linux, macOS).
//!
//! Centraliza a lógica que depende do sistema operacional para que os
//! comandos do `sdd` detectem o SO e ajam corretamente em cada um:
//! exibição de caminhos, escolha de editor e notificações ao usuário.

use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

/// Caminho pronto para exibir ao usuário.
///
/// No Windows, `Path::canonicalize` devolve o prefixo de caminho estendido
/// (`\\?\C:\...` ou `\\?\UNC\servidor\share`). Esse prefixo é tecnicamente
/// válido, mas polui a saída (`copy \\?\D:\...`). Aqui removemos o prefixo
/// para exibição. Em Unix o caminho é devolvido sem alteração.
pub fn display_path(path: &Path) -> String {
    let raw = path.to_string_lossy();
    if let Some(rest) = raw.strip_prefix(r"\\?\UNC\") {
        return format!(r"\\{rest}");
    }
    if let Some(rest) = raw.strip_prefix(r"\\?\") {
        return rest.to_string();
    }
    raw.into_owned()
}

/// Canonicaliza um caminho e já remove o prefixo verbatim do Windows
/// (`\\?\`), devolvendo um caminho funcional e adequado para exibição.
/// Em Unix equivale a `Path::canonicalize`.
pub fn canonicalize(path: &Path) -> std::io::Result<PathBuf> {
    path.canonicalize().map(|c| PathBuf::from(display_path(&c)))
}

/// Procura um executável no `PATH`, tentando as extensões do Windows.
///
/// No Windows, executáveis têm extensão (`git.exe`, `cmd.exe`); um lookup
/// que só testa o nome cru (`git`) nunca encontra nada. Aqui tentamos o nome
/// exato e, no Windows, cada extensão de `PATHEXT` (`.EXE`, `.CMD`, ...).
/// Em Unix, testa apenas o nome exato. Retorna o caminho do primeiro match.
pub fn find_executable(command: &str) -> Option<PathBuf> {
    let paths = std::env::var_os("PATH")?;
    for dir in std::env::split_paths(&paths) {
        let direct = dir.join(command);
        if direct.is_file() {
            return Some(direct);
        }
        if cfg!(windows) {
            for ext in windows_path_extensions() {
                let candidate = dir.join(format!("{command}{ext}"));
                if candidate.is_file() {
                    return Some(candidate);
                }
            }
        }
    }
    None
}

/// `true` se o executável existe no `PATH` (ver [`find_executable`]).
pub fn executable_exists(command: &str) -> bool {
    find_executable(command).is_some()
}

/// Extensões executáveis do Windows, a partir de `PATHEXT` (com fallback).
fn windows_path_extensions() -> Vec<String> {
    std::env::var("PATHEXT")
        .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_string())
        .split(';')
        .filter(|ext| !ext.is_empty())
        .map(|ext| ext.to_string())
        .collect()
}

/// Editor a usar para abrir artefatos.
///
/// Precedência: `$VISUAL` → `$EDITOR` → padrão por SO
/// (Windows = `notepad`, demais = `vi`).
pub fn default_editor() -> String {
    for var in ["VISUAL", "EDITOR"] {
        if let Ok(value) = std::env::var(var) {
            let trimmed = value.trim();
            if !trimmed.is_empty() {
                return trimmed.to_string();
            }
        }
    }
    if cfg!(windows) {
        "notepad".to_string()
    } else {
        "vi".to_string()
    }
}

/// Notificação "best-effort" ao usuário, adaptada ao SO.
///
/// Nunca falha de forma propagada: se a ferramenta nativa não existir,
/// a notificação é simplesmente ignorada. Retorna `true` se conseguiu
/// disparar o comando nativo.
pub fn notify(title: &str, message: &str) -> bool {
    let title = sanitize(title);
    let message = sanitize(message);

    if cfg!(target_os = "macos") {
        let script = format!("display notification \"{message}\" with title \"{title}\"");
        return spawn("osascript", &["-e", &script]);
    }

    if cfg!(target_os = "windows") {
        // Balloon tip via Windows Forms — não exige módulos extras do PowerShell.
        let script = format!(
            "Add-Type -AssemblyName System.Windows.Forms; \
             Add-Type -AssemblyName System.Drawing; \
             $n = New-Object System.Windows.Forms.NotifyIcon; \
             $n.Icon = [System.Drawing.SystemIcons]::Information; \
             $n.Visible = $true; \
             $n.ShowBalloonTip(10000, '{title}', '{message}', \
             [System.Windows.Forms.ToolTipIcon]::Info); \
             Start-Sleep -Seconds 1; $n.Dispose()"
        );
        return spawn(
            "powershell",
            &["-NoProfile", "-WindowStyle", "Hidden", "-Command", &script],
        );
    }

    // Linux / *BSD: notify-send (libnotify).
    spawn("notify-send", &[title.as_str(), message.as_str()])
}

/// Remove aspas e quebras de linha que poderiam quebrar o comando nativo.
fn sanitize(input: &str) -> String {
    input
        .chars()
        .map(|c| match c {
            '"' | '\'' | '`' => ' ',
            '\n' | '\r' => ' ',
            other => other,
        })
        .collect()
}

fn spawn(program: &str, args: &[&str]) -> bool {
    Command::new(program)
        .args(args)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|status| status.success())
        .unwrap_or(false)
}

/// Lock process-wide para testes que mutam variáveis de ambiente globais
/// (`EDITOR`/`VISUAL`). `cargo test` roda testes em paralelo no mesmo processo;
/// sem serialização, esses testes corromperiam o env uns dos outros (ex.: um
/// remove `EDITOR` enquanto o outro acabou de defini-lo). Compartilhado entre
/// módulos (ex.: `tui::editor`). Tolerante a poison: um teste que entra em panic
/// segurando o lock não deve cascatear falhas para os demais.
#[cfg(test)]
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Adquire o `ENV_LOCK` ignorando poison (o estado protegido é só `()`).
#[cfg(test)]
pub(crate) fn lock_env() -> std::sync::MutexGuard<'static, ()> {
    ENV_LOCK
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner())
}

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

    #[test]
    fn display_path_strips_verbatim_prefix() {
        assert_eq!(
            display_path(&PathBuf::from(r"\\?\D:\proj\cijira")),
            r"D:\proj\cijira"
        );
    }

    #[test]
    fn display_path_strips_verbatim_unc_prefix() {
        assert_eq!(
            display_path(&PathBuf::from(r"\\?\UNC\server\share\x")),
            r"\\server\share\x"
        );
    }

    #[test]
    fn display_path_passthrough_plain() {
        assert_eq!(
            display_path(&PathBuf::from("/home/user/proj")),
            "/home/user/proj"
        );
        assert_eq!(display_path(&PathBuf::from(r"D:\proj")), r"D:\proj");
    }

    #[test]
    fn default_editor_prefers_env() {
        let _env = lock_env();
        let saved_visual = std::env::var("VISUAL").ok();
        let saved_editor = std::env::var("EDITOR").ok();
        std::env::remove_var("VISUAL");
        std::env::set_var("EDITOR", "nano");
        assert_eq!(default_editor(), "nano");
        std::env::set_var("VISUAL", "code --wait");
        assert_eq!(default_editor(), "code --wait");

        std::env::remove_var("VISUAL");
        std::env::remove_var("EDITOR");
        let fallback = default_editor();
        if cfg!(windows) {
            assert_eq!(fallback, "notepad");
        } else {
            assert_eq!(fallback, "vi");
        }

        match saved_visual {
            Some(v) => std::env::set_var("VISUAL", v),
            None => std::env::remove_var("VISUAL"),
        }
        match saved_editor {
            Some(v) => std::env::set_var("EDITOR", v),
            None => std::env::remove_var("EDITOR"),
        }
    }
}