Skip to main content

difflore_core/infra/
env.rs

1// Centralized accessors for environment-derived runtime configuration.
2//
3// All env reads in non-test code should funnel through this module so that:
4//   * The list of recognised env vars is discoverable in one place.
5//   * Tests can shadow values by setting the var before first access (each
6//     accessor caches per-process via `OnceLock`).
7//   * Bool/integer parsing happens in one place.
8
9use std::ffi::OsString;
10use std::sync::OnceLock;
11
12// --- env var name constants ---
13
14pub const OPENAI_API_KEY: &str = "OPENAI_API_KEY";
15pub const ANTHROPIC_API_KEY: &str = "ANTHROPIC_API_KEY";
16
17pub const DIFFLORE_FIX_DEBUG: &str = "DIFFLORE_FIX_DEBUG";
18pub const DIFFLORE_FIX_DUMP_DIR: &str = "DIFFLORE_FIX_DUMP_DIR";
19pub const DIFFLORE_FIX_PREVIEW_REVIEW_TIMEOUT_SECS: &str =
20    "DIFFLORE_FIX_PREVIEW_REVIEW_TIMEOUT_SECS";
21pub const DIFFLORE_TRACE_HOOK: &str = "DIFFLORE_TRACE_HOOK";
22pub const DIFFLORE_HOOK_CACHE_TTL_MS: &str = "DIFFLORE_HOOK_CACHE_TTL_MS";
23/// Controls the per-process `hook_post_edit` short-circuit cache.
24///
25/// `auto` (default): apply the empirical heuristic — once a file extension
26/// has produced ≥10 hook serves in the trailing window with ≥90% empties,
27/// skip the index round-trip and return an empty rule list immediately.
28/// `off`: always run the full retrieval path (debugging / regression check).
29/// `force`: always short-circuit on the FIRST call for every extension —
30/// diagnostic-only; the agent gets no rules so cite this from a flag, not
31/// production config.
32///
33/// The cache is in-process and does NOT persist across CLI invocations:
34/// every fresh daemon launch re-learns. Short-circuited calls deliberately
35/// do not write to `mcp_rule_serves` or `cloud_outbox`, so a recovering
36/// corpus quality can let the extension recover the next launch.
37pub const DIFFLORE_HOOK_SHORT_CIRCUIT: &str = "DIFFLORE_HOOK_SHORT_CIRCUIT";
38pub const DIFFLORE_HOOK_CLIENT: &str = "DIFFLORE_HOOK_CLIENT";
39pub const DIFFLORE_HOOK_FORWARD: &str = "DIFFLORE_HOOK_FORWARD";
40pub const DIFFLORE_DEBUG_HOOKS: &str = "DIFFLORE_DEBUG_HOOKS";
41pub const DIFFLORE_HOOK_SHIM_TRACE: &str = "DIFFLORE_HOOK_SHIM_TRACE";
42/// Opt-in: allow the post-edit / pre-read hook to fall back to cross-repo
43/// "starter" rules when the current repo has no scoped memory. Default OFF —
44/// the hook is repo-level only: it surfaces THIS repo's own memory or stays
45/// silent, never auto-injecting transferable rules from other repos on every
46/// edit. (The explicit `difflore recall` command keeps its own starter path.)
47pub const DIFFLORE_HOOK_CROSS_REPO_STARTER: &str = "DIFFLORE_HOOK_CROSS_REPO_STARTER";
48pub const DIFFLORE_MASTER_KEY: &str = "DIFFLORE_MASTER_KEY";
49pub const DIFFLORE_HOME: &str = "DIFFLORE_HOME";
50pub const DIFFLORE_MCP_HOME: &str = "DIFFLORE_MCP_HOME";
51pub const DIFFLORE_NO_WELCOME: &str = "DIFFLORE_NO_WELCOME";
52pub const DIFFLORE_CLOUD_TOKEN: &str = "DIFFLORE_CLOUD_TOKEN";
53/// API key for `difflore embeddings setup` (BYOK), read from env/stdin so it
54/// stays out of shell history. The runtime resolver uses the encrypted key
55/// stored by that command — not a raw env var at embed time.
56pub const DIFFLORE_EMBEDDING_KEY: &str = "DIFFLORE_EMBEDDING_KEY";
57pub const DIFFLORE_TOKEN: &str = "DIFFLORE_TOKEN";
58pub const DIFFLORE_DEBUG_CLOUD: &str = "DIFFLORE_DEBUG_CLOUD";
59pub const DIFFLORE_DEBUG_TELEMETRY: &str = "DIFFLORE_DEBUG_TELEMETRY";
60pub const DIFFLORE_DEBUG_PROVIDERS: &str = "DIFFLORE_DEBUG_PROVIDERS";
61pub const DIFFLORE_BFS_RETRIEVAL: &str = "DIFFLORE_BFS_RETRIEVAL";
62pub const DIFFLORE_INTENT_RERANK: &str = "DIFFLORE_INTENT_RERANK";
63pub const DIFFLORE_DISABLE_RULES: &str = "DIFFLORE_DISABLE_RULES";
64/// Probability (0.0–0.10) that an MCP recall serve with caller-requested
65/// `top_k == 5` is transparently bumped to `top_k = 8` so we accrue data on
66/// whether rules at ranks 6–8 ever get accepted.
67pub const DIFFLORE_DEEP_RECALL_SAMPLE_RATE: &str = "DIFFLORE_DEEP_RECALL_SAMPLE_RATE";
68pub const DIFFLORE_CLAUDE_HOME: &str = "DIFFLORE_CLAUDE_HOME";
69pub const DIFFLORE_CLOUD_URL: &str = "DIFFLORE_CLOUD_URL";
70pub const DIFF_LORE_CLOUD_URL: &str = "DIFF_LORE_CLOUD_URL";
71
72pub const NO_COLOR: &str = "NO_COLOR";
73pub const COLORTERM: &str = "COLORTERM";
74pub const TERM: &str = "TERM";
75pub const PATH: &str = "PATH";
76pub const COLORFGBG: &str = "COLORFGBG";
77pub const DIFFLORE_THEME: &str = "DIFFLORE_THEME";
78
79// --- low-level helpers ---
80
81#[must_use]
82pub fn var(name: &str) -> Option<String> {
83    std::env::var(name).ok()
84}
85
86#[must_use]
87pub fn var_os(name: &str) -> Option<OsString> {
88    std::env::var_os(name)
89}
90
91#[must_use]
92pub fn flag_set(name: &str) -> bool {
93    std::env::var_os(name).is_some()
94}
95
96#[must_use]
97pub fn non_empty(name: &str) -> Option<String> {
98    std::env::var(name).ok().filter(|v| !v.is_empty())
99}
100
101#[must_use]
102pub fn truthy(name: &str) -> bool {
103    matches!(std::env::var(name), Ok(v) if !v.is_empty() && v != "0" && v != "false")
104}
105
106// --- typed accessors (cached) ---
107
108#[must_use]
109pub fn fix_debug() -> bool {
110    static CACHED: OnceLock<bool> = OnceLock::new();
111    *CACHED.get_or_init(|| flag_set(DIFFLORE_FIX_DEBUG))
112}
113
114#[must_use]
115pub fn trace_hook() -> bool {
116    static CACHED: OnceLock<bool> = OnceLock::new();
117    *CACHED.get_or_init(|| flag_set(DIFFLORE_TRACE_HOOK))
118}
119
120/// Whether the hook may inject cross-repo "starter" rules when the current repo
121/// has no scoped memory. Default OFF: the hook is repo-level only — it surfaces
122/// THIS repo's own memory or stays silent, instead of injecting transferable
123/// rules from other repos on every edit. Set `DIFFLORE_HOOK_CROSS_REPO_STARTER`
124/// to a truthy value to opt back into cold-start starter hints in the hook.
125#[must_use]
126pub fn hook_cross_repo_starter_enabled() -> bool {
127    static CACHED: OnceLock<bool> = OnceLock::new();
128    *CACHED.get_or_init(|| truthy(DIFFLORE_HOOK_CROSS_REPO_STARTER))
129}
130
131#[must_use]
132pub fn debug_cloud() -> bool {
133    static CACHED: OnceLock<bool> = OnceLock::new();
134    *CACHED.get_or_init(|| flag_set(DIFFLORE_DEBUG_CLOUD))
135}
136
137#[must_use]
138pub fn debug_telemetry() -> bool {
139    static CACHED: OnceLock<bool> = OnceLock::new();
140    *CACHED.get_or_init(|| flag_set(DIFFLORE_DEBUG_TELEMETRY))
141}
142
143#[must_use]
144pub fn debug_providers() -> bool {
145    static CACHED: OnceLock<bool> = OnceLock::new();
146    *CACHED.get_or_init(|| flag_set(DIFFLORE_DEBUG_PROVIDERS))
147}
148
149#[must_use]
150pub fn hook_cache_ttl_ms() -> Option<u64> {
151    var(DIFFLORE_HOOK_CACHE_TTL_MS).and_then(|v| v.parse().ok())
152}
153
154/// Tri-state knob for the `hook_post_edit` short-circuit heuristic.
155///
156/// See [`DIFFLORE_HOOK_SHORT_CIRCUIT`] for the env semantics.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum HookShortCircuitMode {
159    /// Apply the empirical heuristic — short-circuit when the trailing
160    /// window's empty-rate clears the threshold.
161    Auto,
162    /// Never short-circuit (debugging).
163    Off,
164    /// Always short-circuit (diagnostic only).
165    Force,
166}
167
168impl HookShortCircuitMode {
169    #[must_use]
170    pub fn parse(raw: &str) -> Self {
171        match raw.trim().to_ascii_lowercase().as_str() {
172            "off" | "disable" | "disabled" | "0" | "false" => Self::Off,
173            "force" | "always" => Self::Force,
174            _ => Self::Auto,
175        }
176    }
177}
178
179/// Resolve [`DIFFLORE_HOOK_SHORT_CIRCUIT`] into a tri-state mode.
180///
181/// Read directly from the env on every call so tests can flip behaviour
182/// without racing a `OnceLock` cache. The check is cheap (one
183/// `std::env::var` lookup) compared to the rest of the hook hot path.
184#[must_use]
185pub fn hook_short_circuit_mode() -> HookShortCircuitMode {
186    match var(DIFFLORE_HOOK_SHORT_CIRCUIT) {
187        Some(v) if !v.is_empty() => HookShortCircuitMode::parse(&v),
188        _ => HookShortCircuitMode::Auto,
189    }
190}
191
192/// Default deep-recall sample rate when the env var is unset (2%).
193pub const DEFAULT_DEEP_RECALL_SAMPLE_RATE: f32 = 0.02;
194
195/// Maximum permitted deep-recall sample rate (10%). Anything higher would
196/// substantially shift the cost/token profile of the hot recall path; the
197/// whole point of the sampler is "cheap occasional probe", not a knob the
198/// caller can crank into a second production mode.
199pub const MAX_DEEP_RECALL_SAMPLE_RATE: f32 = 0.10;
200
201/// Parse a raw `DIFFLORE_DEEP_RECALL_SAMPLE_RATE` string into a validated
202/// `f32` in `[0.0, MAX_DEEP_RECALL_SAMPLE_RATE]`.
203///
204/// Returns a human-readable error message on:
205/// * non-numeric input (e.g. `"two"`),
206/// * non-finite values (`NaN`, `±inf`),
207/// * negative values,
208/// * values above `MAX_DEEP_RECALL_SAMPLE_RATE`.
209///
210/// Empty / whitespace input is rejected too — an explicit
211/// `DIFFLORE_DEEP_RECALL_SAMPLE_RATE=` is almost always a typo, and silently
212/// falling back to the default would mask it; the caller can simply unset the
213/// var to get the default.
214pub fn parse_deep_recall_sample_rate(raw: &str) -> Result<f32, String> {
215    let trimmed = raw.trim();
216    if trimmed.is_empty() {
217        return Err(format!(
218            "{DIFFLORE_DEEP_RECALL_SAMPLE_RATE} is empty; unset it to use the default \
219             ({DEFAULT_DEEP_RECALL_SAMPLE_RATE}) or pass a value in \
220             [0.0, {MAX_DEEP_RECALL_SAMPLE_RATE}]"
221        ));
222    }
223    let parsed: f32 = trimmed.parse().map_err(|_| {
224        format!(
225            "{DIFFLORE_DEEP_RECALL_SAMPLE_RATE}={raw:?} is not a valid f32; expected a \
226             number in [0.0, {MAX_DEEP_RECALL_SAMPLE_RATE}]"
227        )
228    })?;
229    if !parsed.is_finite() {
230        return Err(format!(
231            "{DIFFLORE_DEEP_RECALL_SAMPLE_RATE}={raw:?} must be finite; got {parsed}"
232        ));
233    }
234    if !(0.0..=MAX_DEEP_RECALL_SAMPLE_RATE).contains(&parsed) {
235        return Err(format!(
236            "{DIFFLORE_DEEP_RECALL_SAMPLE_RATE}={parsed} is out of range; expected \
237             [0.0, {MAX_DEEP_RECALL_SAMPLE_RATE}]"
238        ));
239    }
240    Ok(parsed)
241}
242
243/// Resolve [`DIFFLORE_DEEP_RECALL_SAMPLE_RATE`] into a validated probability.
244///
245/// Read on every call (no `OnceLock` cache) so tests can flip the rate
246/// per-test without racing. The read is one `std::env::var` lookup and a
247/// short parse — negligible against the rest of the recall hot path.
248///
249/// An invalid env value falls back to [`DEFAULT_DEEP_RECALL_SAMPLE_RATE`]
250/// after logging a single `eprintln!` to stderr. Recall must never fail
251/// because of a malformed observability knob; the caller-visible behaviour
252/// degrades to "no sampling beyond the default", which is the safe default.
253#[must_use]
254pub fn deep_recall_sample_rate() -> f32 {
255    match var(DIFFLORE_DEEP_RECALL_SAMPLE_RATE) {
256        Some(raw) => match parse_deep_recall_sample_rate(&raw) {
257            Ok(rate) => rate,
258            Err(msg) => {
259                eprintln!(
260                    "[difflore] invalid {DIFFLORE_DEEP_RECALL_SAMPLE_RATE}: {msg}; \
261                     falling back to default {DEFAULT_DEEP_RECALL_SAMPLE_RATE}"
262                );
263                DEFAULT_DEEP_RECALL_SAMPLE_RATE
264            }
265        },
266        None => DEFAULT_DEEP_RECALL_SAMPLE_RATE,
267    }
268}
269
270#[must_use]
271pub fn master_key_hex() -> Option<String> {
272    var(DIFFLORE_MASTER_KEY)
273}
274
275#[must_use]
276pub fn difflore_home() -> Option<OsString> {
277    var_os(DIFFLORE_HOME)
278}
279
280#[must_use]
281pub fn fix_dump_dir() -> Option<String> {
282    var(DIFFLORE_FIX_DUMP_DIR)
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn parse_deep_recall_sample_rate_accepts_in_range_values() {
291        for raw in ["0.0", "0.02", "0.05", "0.10", " 0.02 "] {
292            let parsed = parse_deep_recall_sample_rate(raw)
293                .unwrap_or_else(|e| panic!("expected {raw:?} to parse, got error: {e}"));
294            assert!(
295                (0.0..=MAX_DEEP_RECALL_SAMPLE_RATE).contains(&parsed),
296                "{raw:?} parsed to {parsed}, outside the permitted range"
297            );
298        }
299        // Exact-value sanity for the canonical defaults users will set.
300        assert!((parse_deep_recall_sample_rate("0.02").unwrap() - 0.02).abs() < 1e-6);
301        assert!((parse_deep_recall_sample_rate("0.0").unwrap()).abs() < 1e-6);
302    }
303
304    #[test]
305    fn hook_short_circuit_mode_parses_each_state() {
306        assert_eq!(
307            HookShortCircuitMode::parse("auto"),
308            HookShortCircuitMode::Auto
309        );
310        assert_eq!(HookShortCircuitMode::parse(""), HookShortCircuitMode::Auto);
311        assert_eq!(
312            HookShortCircuitMode::parse("AUTO"),
313            HookShortCircuitMode::Auto
314        );
315        assert_eq!(
316            HookShortCircuitMode::parse("off"),
317            HookShortCircuitMode::Off
318        );
319        assert_eq!(
320            HookShortCircuitMode::parse("OFF"),
321            HookShortCircuitMode::Off
322        );
323        assert_eq!(
324            HookShortCircuitMode::parse("disabled"),
325            HookShortCircuitMode::Off
326        );
327        assert_eq!(HookShortCircuitMode::parse("0"), HookShortCircuitMode::Off);
328        assert_eq!(
329            HookShortCircuitMode::parse("false"),
330            HookShortCircuitMode::Off
331        );
332        assert_eq!(
333            HookShortCircuitMode::parse("force"),
334            HookShortCircuitMode::Force
335        );
336        assert_eq!(
337            HookShortCircuitMode::parse("FORCE"),
338            HookShortCircuitMode::Force
339        );
340        assert_eq!(
341            HookShortCircuitMode::parse("always"),
342            HookShortCircuitMode::Force
343        );
344        // Unknown values fall back to Auto rather than silently disabling
345        // the feature.
346        assert_eq!(
347            HookShortCircuitMode::parse("yolo"),
348            HookShortCircuitMode::Auto
349        );
350    }
351
352    #[test]
353    fn parse_deep_recall_sample_rate_rejects_out_of_range_and_garbage() {
354        // Above the 10% cap.
355        assert!(parse_deep_recall_sample_rate("0.5").is_err());
356        assert!(parse_deep_recall_sample_rate("1.0").is_err());
357        // Negative.
358        assert!(parse_deep_recall_sample_rate("-0.01").is_err());
359        // Non-numeric.
360        assert!(parse_deep_recall_sample_rate("two").is_err());
361        // Empty / whitespace.
362        assert!(parse_deep_recall_sample_rate("").is_err());
363        assert!(parse_deep_recall_sample_rate("   ").is_err());
364        // Non-finite.
365        assert!(parse_deep_recall_sample_rate("NaN").is_err());
366        assert!(parse_deep_recall_sample_rate("inf").is_err());
367    }
368}