indusagi-core 0.1.0

Cross-cutting primitives every indusagi crate depends on: cancellation, env registry, brand, locator, canonical-JSON, version, ids, errors, re-iterable channel.
Documentation
//! The single environment-variable registry for the framework.
//!
//! Every env var the framework reads is declared and resolved through this
//! module — subsystems must not probe `std::env` directly (the TS codebase had
//! two competing resolution tables; the Rust port has exactly one, satisfying
//! risk D7 / R2). The only sanctioned exception is spawning child processes that
//! inherit the full environment.
//!
//! ## M0 phase-2 scope
//!
//! This milestone ships the *structure* plus the load-bearing reads:
//! - the brand grammar ([`env_name`], re-exported from [`crate::brand`]),
//! - [`read_env`] (branded) and [`read_raw`] (the escape hatch for unbranded
//!   names like `AWS_REGION`), with empty-string-is-unset trimming,
//! - the [`INDUSAGI_HOME`](indusagi_home) resolver.
//!
//! The full provider machinery — `PROVIDER_ENV_MAP` (15 rows), the 7 bespoke
//! resolvers (Vertex triple-gate, Bedrock chain, Copilot/Anthropic/Kimi
//! fallbacks), `SDK_CREDENTIALS_MARKER`, and `is_likely_valid_api_key` — is
//! sketched here as a skeleton (the table and the marker constant are committed
//! now since they are parity-locked) and filled in fully in a later milestone.

use std::path::PathBuf;

use crate::brand::{BRAND, env_name};

/// The branded env-var prefix without the trailing underscore.
/// (The trailing-underscore form lives on [`crate::brand::Brand::env_prefix`].)
pub const ENV_PREFIX: &str = "INDUSAGI";

/// Sentinel returned in place of a literal key when a provider authenticates
/// through an SDK credential chain (AWS profiles, Google ADC) rather than an
/// env var. Parity-locked: callers treat it as "credentials present".
pub const SDK_CREDENTIALS_MARKER: &str = "<sdk-managed-credentials>";

/// Read a branded env var by suffix (e.g. `read_env("home")` reads
/// `INDUSAGI_HOME`). Empty/whitespace-only values are treated as *absent*,
/// matching the TS `readEnv` trim-and-undefined-on-empty rule.
pub fn read_env(suffix: &str) -> Option<String> {
    read_raw(&env_name(suffix))
}

/// Read a raw (unbranded) env var by its full name — the escape hatch for
/// variables the framework does not own, such as `AWS_REGION`. Empty/whitespace
/// values are treated as absent, identical to [`read_env`].
pub fn read_raw(name: &str) -> Option<String> {
    match std::env::var(name) {
        Ok(raw) => {
            let trimmed = raw.trim();
            if trimmed.is_empty() {
                None
            } else {
                Some(trimmed.to_string())
            }
        }
        Err(_) => None,
    }
}

/// Resolve the framework state-directory root in precedence order:
///   1. `INDUSAGI_HOME` (trimmed, empty = unset),
///   2. the OS home directory (`$HOME` / platform equivalent),
///   3. a last-resort fallback of `.` so the function is total.
///
/// The TS `Locator.resolveHome` precedence is `override → INDUSAGI_HOME →
/// os.homedir()`; the override arg is a [`crate::locate::Locator`] concern, so
/// this free function covers steps 2–3 of that chain (and the [`Locator`] layers
/// the override on top).
///
/// [`Locator`]: crate::locate::Locator
pub fn indusagi_home() -> PathBuf {
    if let Some(home) = read_env("HOME") {
        return PathBuf::from(home);
    }
    home_dir().unwrap_or_else(|| PathBuf::from("."))
}

/// Best-effort OS home directory without pulling in the `dirs` crate at the core
/// layer: consult `HOME` (Unix) then `USERPROFILE` (Windows). A later milestone
/// may swap in `dirs::home_dir()` for full platform coverage; this keeps the
/// core crate dependency-light while honoring the common case.
pub(crate) fn home_dir() -> Option<PathBuf> {
    read_raw("HOME")
        .or_else(|| read_raw("USERPROFILE"))
        .map(PathBuf::from)
}

// ---------------------------------------------------------------------------
// Provider table skeleton (parity-locked constants committed now; bespoke
// resolvers + validity policy land in a later milestone).
// ---------------------------------------------------------------------------

/// The single-env-var provider rows (`provider -> ENVVAR`), in TS source
/// order. Bespoke providers (anthropic, github-copilot, google-vertex,
/// amazon-bedrock, kimi, kimi-coding) are resolved separately and are *not* in
/// this table. Committed now because the rows are parity-locked.
pub const PROVIDER_ENV_MAP: &[(&str, &str)] = &[
    ("openai", "OPENAI_API_KEY"),
    ("azure-openai-responses", "AZURE_OPENAI_API_KEY"),
    ("google", "GEMINI_API_KEY"),
    ("groq", "GROQ_API_KEY"),
    ("cerebras", "CEREBRAS_API_KEY"),
    ("xai", "XAI_API_KEY"),
    ("openrouter", "OPENROUTER_API_KEY"),
    ("vercel-ai-gateway", "AI_GATEWAY_API_KEY"),
    ("zai", "ZAI_API_KEY"),
    ("mistral", "MISTRAL_API_KEY"),
    ("minimax", "MINIMAX_API_KEY"),
    ("minimax-cn", "MINIMAX_CN_API_KEY"),
    ("opencode", "OPENCODE_API_KEY"),
    ("sarvam", "SARVAM_API_KEY"),
    ("krutrim", "KRUTRIM_API_KEY"),
    ("nvidia", "NVIDIA_API_KEY"),
];

/// Look up the env var name a single-key provider maps to. Bespoke providers
/// return `None` here (their resolution is custom, landing in a later milestone).
pub fn provider_env_var(provider: &str) -> Option<&'static str> {
    PROVIDER_ENV_MAP
        .iter()
        .find(|(name, _)| *name == provider)
        .map(|(_, var)| *var)
}

/// The framework's canonical brand, re-exported so call sites resolve env names
/// without importing `brand` directly.
pub fn brand_env_prefix() -> &'static str {
    BRAND.env_prefix
}

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

    // These tests mutate the process environment, which is global; run them in
    // one test fn each with distinct var names so they don't race each other.

    #[test]
    fn env_name_is_re_exported_grammar() {
        assert_eq!(env_name("home"), "INDUSAGI_HOME");
        assert_eq!(ENV_PREFIX, "INDUSAGI");
        assert_eq!(brand_env_prefix(), "INDUSAGI_");
    }

    #[test]
    fn read_raw_trims_and_treats_empty_as_absent() {
        let name = "INDUSAGI_TEST_READ_RAW_X1";
        // SAFETY: single-threaded within this test; unique var name.
        unsafe {
            std::env::set_var(name, "  spaced value  ");
        }
        assert_eq!(read_raw(name), Some("spaced value".to_string()));

        unsafe {
            std::env::set_var(name, "   ");
        }
        assert_eq!(read_raw(name), None, "whitespace-only is absent");

        unsafe {
            std::env::remove_var(name);
        }
        assert_eq!(read_raw(name), None, "unset is absent");
    }

    #[test]
    fn indusagi_home_honors_the_override_env() {
        let name = env_name("HOME");
        unsafe {
            std::env::set_var(&name, "/tmp/sandbox-home");
        }
        assert_eq!(indusagi_home(), PathBuf::from("/tmp/sandbox-home"));

        // Empty INDUSAGI_HOME is treated as unset => falls back to OS home / ".".
        unsafe {
            std::env::set_var(&name, "  ");
        }
        let fallback = indusagi_home();
        assert_ne!(fallback, PathBuf::from("  "));

        unsafe {
            std::env::remove_var(&name);
        }
    }

    #[test]
    fn provider_table_has_the_15_locked_rows() {
        assert_eq!(PROVIDER_ENV_MAP.len(), 16); // 16 single-env-var rows, verbatim from TS source order
        assert_eq!(provider_env_var("openai"), Some("OPENAI_API_KEY"));
        assert_eq!(provider_env_var("google"), Some("GEMINI_API_KEY"));
        assert_eq!(
            provider_env_var("azure-openai-responses"),
            Some("AZURE_OPENAI_API_KEY")
        );
        // Bespoke providers are not in the single-key table.
        assert_eq!(provider_env_var("anthropic"), None);
        assert_eq!(provider_env_var("amazon-bedrock"), None);
    }

    #[test]
    fn sdk_marker_is_the_locked_sentinel() {
        assert_eq!(SDK_CREDENTIALS_MARKER, "<sdk-managed-credentials>");
    }
}