indusagi_core/env.rs
1//! The single environment-variable registry for the framework.
2//!
3//! Every env var the framework reads is declared and resolved through this
4//! module — subsystems must not probe `std::env` directly (the TS codebase had
5//! two competing resolution tables; the Rust port has exactly one, satisfying
6//! risk D7 / R2). The only sanctioned exception is spawning child processes that
7//! inherit the full environment.
8//!
9//! ## M0 phase-2 scope
10//!
11//! This milestone ships the *structure* plus the load-bearing reads:
12//! - the brand grammar ([`env_name`], re-exported from [`crate::brand`]),
13//! - [`read_env`] (branded) and [`read_raw`] (the escape hatch for unbranded
14//! names like `AWS_REGION`), with empty-string-is-unset trimming,
15//! - the [`INDUSAGI_HOME`](indusagi_home) resolver.
16//!
17//! The full provider machinery — `PROVIDER_ENV_MAP` (15 rows), the 7 bespoke
18//! resolvers (Vertex triple-gate, Bedrock chain, Copilot/Anthropic/Kimi
19//! fallbacks), `SDK_CREDENTIALS_MARKER`, and `is_likely_valid_api_key` — is
20//! sketched here as a skeleton (the table and the marker constant are committed
21//! now since they are parity-locked) and filled in fully in a later milestone.
22
23use std::path::PathBuf;
24
25use crate::brand::{BRAND, env_name};
26
27/// The branded env-var prefix without the trailing underscore.
28/// (The trailing-underscore form lives on [`crate::brand::Brand::env_prefix`].)
29pub const ENV_PREFIX: &str = "INDUSAGI";
30
31/// Sentinel returned in place of a literal key when a provider authenticates
32/// through an SDK credential chain (AWS profiles, Google ADC) rather than an
33/// env var. Parity-locked: callers treat it as "credentials present".
34pub const SDK_CREDENTIALS_MARKER: &str = "<sdk-managed-credentials>";
35
36/// Read a branded env var by suffix (e.g. `read_env("home")` reads
37/// `INDUSAGI_HOME`). Empty/whitespace-only values are treated as *absent*,
38/// matching the TS `readEnv` trim-and-undefined-on-empty rule.
39pub fn read_env(suffix: &str) -> Option<String> {
40 read_raw(&env_name(suffix))
41}
42
43/// Read a raw (unbranded) env var by its full name — the escape hatch for
44/// variables the framework does not own, such as `AWS_REGION`. Empty/whitespace
45/// values are treated as absent, identical to [`read_env`].
46pub fn read_raw(name: &str) -> Option<String> {
47 match std::env::var(name) {
48 Ok(raw) => {
49 let trimmed = raw.trim();
50 if trimmed.is_empty() {
51 None
52 } else {
53 Some(trimmed.to_string())
54 }
55 }
56 Err(_) => None,
57 }
58}
59
60/// Resolve the framework state-directory root in precedence order:
61/// 1. `INDUSAGI_HOME` (trimmed, empty = unset),
62/// 2. the OS home directory (`$HOME` / platform equivalent),
63/// 3. a last-resort fallback of `.` so the function is total.
64///
65/// The TS `Locator.resolveHome` precedence is `override → INDUSAGI_HOME →
66/// os.homedir()`; the override arg is a [`crate::locate::Locator`] concern, so
67/// this free function covers steps 2–3 of that chain (and the [`Locator`] layers
68/// the override on top).
69///
70/// [`Locator`]: crate::locate::Locator
71pub fn indusagi_home() -> PathBuf {
72 if let Some(home) = read_env("HOME") {
73 return PathBuf::from(home);
74 }
75 home_dir().unwrap_or_else(|| PathBuf::from("."))
76}
77
78/// Best-effort OS home directory without pulling in the `dirs` crate at the core
79/// layer: consult `HOME` (Unix) then `USERPROFILE` (Windows). A later milestone
80/// may swap in `dirs::home_dir()` for full platform coverage; this keeps the
81/// core crate dependency-light while honoring the common case.
82pub(crate) fn home_dir() -> Option<PathBuf> {
83 read_raw("HOME")
84 .or_else(|| read_raw("USERPROFILE"))
85 .map(PathBuf::from)
86}
87
88// ---------------------------------------------------------------------------
89// Provider table skeleton (parity-locked constants committed now; bespoke
90// resolvers + validity policy land in a later milestone).
91// ---------------------------------------------------------------------------
92
93/// The single-env-var provider rows (`provider -> ENVVAR`), in TS source
94/// order. Bespoke providers (anthropic, github-copilot, google-vertex,
95/// amazon-bedrock, kimi, kimi-coding) are resolved separately and are *not* in
96/// this table. Committed now because the rows are parity-locked.
97pub const PROVIDER_ENV_MAP: &[(&str, &str)] = &[
98 ("openai", "OPENAI_API_KEY"),
99 ("azure-openai-responses", "AZURE_OPENAI_API_KEY"),
100 ("google", "GEMINI_API_KEY"),
101 ("groq", "GROQ_API_KEY"),
102 ("cerebras", "CEREBRAS_API_KEY"),
103 ("xai", "XAI_API_KEY"),
104 ("openrouter", "OPENROUTER_API_KEY"),
105 ("vercel-ai-gateway", "AI_GATEWAY_API_KEY"),
106 ("zai", "ZAI_API_KEY"),
107 ("mistral", "MISTRAL_API_KEY"),
108 ("minimax", "MINIMAX_API_KEY"),
109 ("minimax-cn", "MINIMAX_CN_API_KEY"),
110 ("opencode", "OPENCODE_API_KEY"),
111 ("sarvam", "SARVAM_API_KEY"),
112 ("krutrim", "KRUTRIM_API_KEY"),
113 ("nvidia", "NVIDIA_API_KEY"),
114];
115
116/// Look up the env var name a single-key provider maps to. Bespoke providers
117/// return `None` here (their resolution is custom, landing in a later milestone).
118pub fn provider_env_var(provider: &str) -> Option<&'static str> {
119 PROVIDER_ENV_MAP
120 .iter()
121 .find(|(name, _)| *name == provider)
122 .map(|(_, var)| *var)
123}
124
125/// The framework's canonical brand, re-exported so call sites resolve env names
126/// without importing `brand` directly.
127pub fn brand_env_prefix() -> &'static str {
128 BRAND.env_prefix
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 // These tests mutate the process environment, which is global; run them in
136 // one test fn each with distinct var names so they don't race each other.
137
138 #[test]
139 fn env_name_is_re_exported_grammar() {
140 assert_eq!(env_name("home"), "INDUSAGI_HOME");
141 assert_eq!(ENV_PREFIX, "INDUSAGI");
142 assert_eq!(brand_env_prefix(), "INDUSAGI_");
143 }
144
145 #[test]
146 fn read_raw_trims_and_treats_empty_as_absent() {
147 let name = "INDUSAGI_TEST_READ_RAW_X1";
148 // SAFETY: single-threaded within this test; unique var name.
149 unsafe {
150 std::env::set_var(name, " spaced value ");
151 }
152 assert_eq!(read_raw(name), Some("spaced value".to_string()));
153
154 unsafe {
155 std::env::set_var(name, " ");
156 }
157 assert_eq!(read_raw(name), None, "whitespace-only is absent");
158
159 unsafe {
160 std::env::remove_var(name);
161 }
162 assert_eq!(read_raw(name), None, "unset is absent");
163 }
164
165 #[test]
166 fn indusagi_home_honors_the_override_env() {
167 let name = env_name("HOME");
168 unsafe {
169 std::env::set_var(&name, "/tmp/sandbox-home");
170 }
171 assert_eq!(indusagi_home(), PathBuf::from("/tmp/sandbox-home"));
172
173 // Empty INDUSAGI_HOME is treated as unset => falls back to OS home / ".".
174 unsafe {
175 std::env::set_var(&name, " ");
176 }
177 let fallback = indusagi_home();
178 assert_ne!(fallback, PathBuf::from(" "));
179
180 unsafe {
181 std::env::remove_var(&name);
182 }
183 }
184
185 #[test]
186 fn provider_table_has_the_15_locked_rows() {
187 assert_eq!(PROVIDER_ENV_MAP.len(), 16); // 16 single-env-var rows, verbatim from TS source order
188 assert_eq!(provider_env_var("openai"), Some("OPENAI_API_KEY"));
189 assert_eq!(provider_env_var("google"), Some("GEMINI_API_KEY"));
190 assert_eq!(
191 provider_env_var("azure-openai-responses"),
192 Some("AZURE_OPENAI_API_KEY")
193 );
194 // Bespoke providers are not in the single-key table.
195 assert_eq!(provider_env_var("anthropic"), None);
196 assert_eq!(provider_env_var("amazon-bedrock"), None);
197 }
198
199 #[test]
200 fn sdk_marker_is_the_locked_sentinel() {
201 assert_eq!(SDK_CREDENTIALS_MARKER, "<sdk-managed-credentials>");
202 }
203}