slash-lib 0.1.0

Executor types and high-level API for the slash-command language
Documentation
use std::cell::OnceCell;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Path, PathBuf};

/// Lazy environment loader that resolves `$KEY` / `${KEY}` references.
///
/// Files are loaded on first access, not at construction. Resolution order:
/// 1. `.slenv` values (highest priority)
/// 2. `.env` values
/// 3. Encrypted `.env` via `dotenvx get` (if dotenvx is on PATH)
/// 4. Process environment (fallback)
///
/// When a resolved value looks like a secret, a warning is emitted to stderr.
pub struct SlenvLoader {
    dir: Option<PathBuf>,
    vars: OnceCell<HashMap<String, String>>,
    has_dotenvx: OnceCell<bool>,
}

impl SlenvLoader {
    /// Create a loader that will lazily load from `dir/.env` and `dir/.slenv`.
    pub fn new(dir: &Path) -> Self {
        Self {
            dir: Some(dir.to_path_buf()),
            vars: OnceCell::new(),
            has_dotenvx: OnceCell::new(),
        }
    }

    /// Create an empty loader (no file backing). For testing.
    pub fn empty() -> Self {
        let loader = Self {
            dir: None,
            vars: OnceCell::new(),
            has_dotenvx: OnceCell::new(),
        };
        loader.vars.set(HashMap::new()).ok();
        loader
    }

    /// Create a loader from an in-memory string. For testing.
    pub fn from_content(content: &str) -> Self {
        let vars = dotenvy::from_read_iter(Cursor::new(content.to_owned()))
            .filter_map(|r| r.ok())
            .collect();
        let loader = Self {
            dir: None,
            vars: OnceCell::new(),
            has_dotenvx: OnceCell::new(),
        };
        loader.vars.set(vars).ok();
        loader
    }

    /// Insert a variable (mutable access, for setup before first resolve).
    pub fn insert_mut(&mut self, key: impl Into<String>, value: impl Into<String>) {
        // Ensure OnceCell is initialized, then mutate via &mut self.
        let _ = self.vars.get_or_init(HashMap::new);
        if let Some(vars) = self.vars.get_mut() {
            vars.insert(key.into(), value.into());
        }
    }

    /// Resolve `$KEY` and `${KEY}` references in a string.
    pub fn resolve(&self, input: &str) -> String {
        let mut result = String::with_capacity(input.len());
        let bytes = input.as_bytes();
        let len = bytes.len();
        let mut i = 0;

        while i < len {
            if bytes[i] == b'$' && i + 1 < len {
                if bytes[i + 1] == b'{' {
                    if let Some(close) = input[i + 2..].find('}') {
                        let key = &input[i + 2..i + 2 + close];
                        if let Some(val) = self.lookup(key) {
                            warn_if_secret(key, &val);
                            result.push_str(&val);
                        } else {
                            result.push_str(&input[i..i + 3 + close]);
                        }
                        i += 3 + close;
                        continue;
                    }
                } else if bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_' {
                    let start = i + 1;
                    let mut end = start;
                    while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
                        end += 1;
                    }
                    let key = &input[start..end];
                    if let Some(val) = self.lookup(key) {
                        warn_if_secret(key, &val);
                        result.push_str(&val);
                    } else {
                        result.push_str(&input[i..end]);
                    }
                    i = end;
                    continue;
                }
            }
            result.push(bytes[i] as char);
            i += 1;
        }

        result
    }

    fn lookup(&self, key: &str) -> Option<String> {
        // 1. Check loaded vars (.slenv + .env)
        if let Some(val) = self.vars().get(key) {
            return Some(val.clone());
        }

        // 2. Try dotenvx for encrypted values
        if let Some(val) = self.dotenvx_get(key) {
            return Some(val);
        }

        // 3. Fall back to process environment
        std::env::var(key).ok()
    }

    /// Lazily load vars from .env and .slenv.
    fn vars(&self) -> &HashMap<String, String> {
        self.vars.get_or_init(|| {
            let Some(dir) = &self.dir else {
                return HashMap::new();
            };

            let mut vars = HashMap::new();

            // Load .env first (lower priority).
            let env_path = dir.join(".env");
            if let Ok(iter) = dotenvy::from_path_iter(&env_path) {
                for item in iter.flatten() {
                    vars.insert(item.0, item.1);
                }
            }

            // Load .slenv second (overrides .env).
            let slenv_path = dir.join(".slenv");
            if let Ok(iter) = dotenvy::from_path_iter(&slenv_path) {
                for item in iter.flatten() {
                    vars.insert(item.0, item.1);
                }
            }

            vars
        })
    }

    /// Check if dotenvx is available (cached).
    fn has_dotenvx(&self) -> bool {
        *self.has_dotenvx.get_or_init(|| {
            std::process::Command::new("dotenvx")
                .arg("--version")
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .status()
                .is_ok_and(|s| s.success())
        })
    }

    /// Try to resolve a key via `dotenvx get KEY`.
    fn dotenvx_get(&self, key: &str) -> Option<String> {
        if !self.has_dotenvx() {
            return None;
        }

        let dir = self.dir.as_ref()?;

        let output = std::process::Command::new("dotenvx")
            .arg("get")
            .arg(key)
            .current_dir(dir)
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::null())
            .output()
            .ok()?;

        if output.status.success() {
            let val = String::from_utf8(output.stdout).ok()?;
            let trimmed = val.trim().to_string();
            if trimmed.is_empty() {
                None
            } else {
                Some(trimmed)
            }
        } else {
            None
        }
    }
}

// ============================================================================
// SECRET DETECTION
// ============================================================================

/// Known prefixes that indicate a secret/token.
const SECRET_PREFIXES: &[&str] = &[
    "sk-",
    "sk_",
    "pk-",
    "pk_", // Stripe, OpenAI
    "ghp_",
    "gho_",
    "ghs_",
    "ghu_", // GitHub
    "AKIA", // AWS
    "xoxb-",
    "xoxp-",
    "xapp-", // Slack
    "eyJ",   // JWT
    "shpat_",
    "shpss_",            // Shopify
    "whsec_",            // Webhook secrets
    "sq0",               // Square
    "ANTHROPIC_API_KEY", // Just kidding — prefix match, not key name
];

/// Emit a warning to stderr if a value looks like it might be a secret.
fn warn_if_secret(key: &str, value: &str) {
    if looks_like_secret(value) {
        eprintln!(
            "warning: ${} looks like a secret — consider encrypting with `dotenvx encrypt`",
            key
        );
    }
}

fn looks_like_secret(value: &str) -> bool {
    // Check known prefixes.
    for prefix in SECRET_PREFIXES {
        if value.starts_with(prefix) {
            return true;
        }
    }

    // High entropy heuristic: long alphanumeric strings (32+ chars, mixed case/digits).
    if value.len() >= 32 && value.is_ascii() {
        let alpha = value.chars().filter(|c| c.is_ascii_alphabetic()).count();
        let digit = value.chars().filter(|c| c.is_ascii_digit()).count();
        let has_mixed_case = value.chars().any(|c| c.is_ascii_uppercase())
            && value.chars().any(|c| c.is_ascii_lowercase());

        // Looks like a key if it's mostly alphanumeric with mixed case and digits.
        if alpha + digit > value.len() * 3 / 4 && has_mixed_case && digit > 0 {
            return true;
        }
    }

    false
}

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

    #[test]
    fn resolve_from_vars() {
        let mut loader = SlenvLoader::empty();
        loader.insert_mut("NAME", "world");
        assert_eq!(loader.resolve("hello $NAME"), "hello world");
        assert_eq!(loader.resolve("hello ${NAME}!"), "hello world!");
    }

    #[test]
    fn resolve_falls_back_to_process_env() {
        let loader = SlenvLoader::empty();
        let result = loader.resolve("$HOME");
        assert!(!result.starts_with('$'));
    }

    #[test]
    fn unresolved_key_left_as_is() {
        let loader = SlenvLoader::empty();
        assert_eq!(
            loader.resolve("$NONEXISTENT_SLASH_VAR_XYZ"),
            "$NONEXISTENT_SLASH_VAR_XYZ"
        );
    }

    #[test]
    fn no_dollar_sign_unchanged() {
        let loader = SlenvLoader::empty();
        assert_eq!(loader.resolve("hello world"), "hello world");
    }

    #[test]
    fn from_content_loads_vars() {
        let loader = SlenvLoader::from_content("FOO=bar\nBAZ=quoted");
        assert_eq!(loader.vars().get("FOO").unwrap(), "bar");
        assert_eq!(loader.vars().get("BAZ").unwrap(), "quoted");
    }

    #[test]
    fn detects_known_secret_prefixes() {
        assert!(looks_like_secret("sk-1234567890abcdef"));
        assert!(looks_like_secret("ghp_abcdefghijk123"));
        assert!(looks_like_secret("AKIAIOSFODNN7EXAMPLE"));
        assert!(looks_like_secret("xoxb-123-456-abc"));
    }

    #[test]
    fn detects_high_entropy_strings() {
        assert!(looks_like_secret("aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV"));
    }

    #[test]
    fn ignores_normal_values() {
        assert!(!looks_like_secret("hello world"));
        assert!(!looks_like_secret("localhost"));
        assert!(!looks_like_secret("3000"));
        assert!(!looks_like_secret("/usr/local/bin"));
    }
}