Skip to main content

lean_ctx/core/config/
enums.rs

1//! Configuration enums and their behavior.
2//!
3//! Extracted from `config::mod` to keep the top-level config module focused on
4//! the `Config` struct and loading logic. These types are re-exported from the
5//! `config` module root, so external paths like `config::CompressionLevel`
6//! continue to work unchanged.
7
8use serde::{Deserialize, Serialize};
9use std::sync::atomic::AtomicU8;
10
11use super::Config;
12
13static SESSION_DEGRADE_LEVEL: AtomicU8 = AtomicU8::new(0);
14
15/// Unified reasoning-effort level for the cache-safe, cross-provider effort
16/// control (#834). "Off" is represented by `Option::None`, not a variant — the
17/// feature is strictly opt-in.
18///
19/// This type only carries the operator's *intent*; the wire translation into
20/// each provider's native parameter (OpenAI `reasoning(_).effort`, Anthropic
21/// `output_config.effort`) lives in [`crate::proxy::effort`]. The value is a
22/// constant once configured, so it is identical on every request of every
23/// conversation — the provider prompt-cache prefix stays byte-stable (#448/#498)
24/// and only the model's reasoning depth changes. Per-turn effort switching is
25/// deliberately *not* supported: it would invalidate the prompt cache.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum Effort {
29    Minimal,
30    Low,
31    Medium,
32    High,
33}
34
35impl Effort {
36    /// Parse a config/env token. `off`, empty, or anything unrecognized yields
37    /// `None` (feature disabled) so a typo can never silently enable it.
38    #[must_use]
39    pub fn parse(s: &str) -> Option<Self> {
40        match s.trim().to_ascii_lowercase().as_str() {
41            "minimal" => Some(Self::Minimal),
42            "low" => Some(Self::Low),
43            "medium" => Some(Self::Medium),
44            "high" => Some(Self::High),
45            _ => None,
46        }
47    }
48
49    /// Stable lowercase label (config display, logs, `/status`).
50    #[must_use]
51    pub fn label(self) -> &'static str {
52        match self {
53            Self::Minimal => "minimal",
54            Self::Low => "low",
55            Self::Medium => "medium",
56            Self::High => "high",
57        }
58    }
59}
60
61/// Controls when shell output is tee'd to disk for later retrieval.
62///
63/// Default is `HighCompression` (not `Failures`): a heavily compressed but
64/// *successful* command is exactly the case where an agent later needs the raw
65/// bytes, and teeing them guarantees the MCP-free recovery path (a real file the
66/// agent can read with any tool) always exists. The archive GC (TTL + size cap)
67/// covers the extra files.
68#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
69#[serde(rename_all = "lowercase")]
70pub enum TeeMode {
71    Never,
72    Failures,
73    #[default]
74    HighCompression,
75    Always,
76}
77
78/// Controls the reactive recovery footer surfaced on compressed tool output
79/// (`ctx_read`, archive/firewall/spill handles, `ctx_shell` tee).
80///
81/// The proactive `RECOVER` rule teaches the vocabulary once in the system
82/// prompt; this knob governs the per-output reminder that names the concrete
83/// file path / handle at point-of-need:
84/// * `Minimal` (default) — a single, non-MCP-first line on the *first* compressed
85///   view of a file/handle per session.
86/// * `Full` — the richer ladder (`mode=full` · `raw=true` · `ctx_retrieve` ·
87///   `ctx_expand`); used by the `exploration`/`review` profiles.
88/// * `Off` — suppresses the footer entirely (the proactive rule still ships, so
89///   reversibility is never undiscoverable).
90#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
91#[serde(rename_all = "lowercase")]
92pub enum RecoveryHints {
93    Off,
94    #[default]
95    Minimal,
96    Full,
97}
98
99impl RecoveryHints {
100    /// Parse a config/env token. Returns `None` for unrecognized input so a typo
101    /// falls back to the default rather than silently disabling the feature.
102    #[must_use]
103    pub fn parse(s: &str) -> Option<Self> {
104        match s.trim().to_ascii_lowercase().as_str() {
105            "off" | "none" | "false" => Some(Self::Off),
106            "minimal" | "min" | "on" | "true" => Some(Self::Minimal),
107            "full" => Some(Self::Full),
108            _ => None,
109        }
110    }
111
112    /// Reads the recovery-hint tier from `LEAN_CTX_RECOVERY_HINTS` (ops/test
113    /// override). Unset or unrecognized yields `None` (use the configured value).
114    #[must_use]
115    pub fn from_env() -> Option<Self> {
116        Self::parse(&std::env::var("LEAN_CTX_RECOVERY_HINTS").ok()?)
117    }
118
119    /// Stable lowercase label (config display, schema, `/status`).
120    #[must_use]
121    pub fn label(self) -> &'static str {
122        match self {
123            Self::Off => "off",
124            Self::Minimal => "minimal",
125            Self::Full => "full",
126        }
127    }
128}
129
130/// Legacy: Controls agent output verbosity level injected into MCP instructions.
131/// Superseded by `CompressionLevel`. Kept for backward compatibility with old config.toml files.
132/// New setups use `compression_level` instead. See `CompressionLevel::effective()`.
133#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
134#[serde(rename_all = "lowercase")]
135pub enum TerseAgent {
136    #[default]
137    Off,
138    Lite,
139    Full,
140    Ultra,
141}
142
143impl TerseAgent {
144    /// Reads the terse-agent level from the `LEAN_CTX_TERSE_AGENT` env var.
145    pub fn from_env() -> Self {
146        match std::env::var("LEAN_CTX_TERSE_AGENT")
147            .unwrap_or_default()
148            .to_lowercase()
149            .as_str()
150        {
151            "lite" => Self::Lite,
152            "full" => Self::Full,
153            "ultra" => Self::Ultra,
154            _ => Self::Off,
155        }
156    }
157}
158
159/// Legacy: Controls how dense/compact MCP tool output is formatted.
160/// Superseded by `CompressionLevel`. Kept for backward compatibility with old config.toml files.
161/// New setups use `compression_level` instead. See `CompressionLevel::effective()`.
162#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
163#[serde(rename_all = "lowercase")]
164pub enum OutputDensity {
165    #[default]
166    Normal,
167    Terse,
168    Ultra,
169}
170
171impl OutputDensity {
172    /// Reads the output density from the `LEAN_CTX_OUTPUT_DENSITY` env var.
173    pub fn from_env() -> Self {
174        match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
175            .unwrap_or_default()
176            .to_lowercase()
177            .as_str()
178        {
179            "terse" => Self::Terse,
180            "ultra" => Self::Ultra,
181            _ => Self::Normal,
182        }
183    }
184}
185
186/// Unified compression level that replaces the 4 separate legacy concepts:
187/// `terse_agent`, `output_density`, `terse_mode`, and `crp_mode`.
188///
189/// Controls how much detail tool responses include.
190#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
191#[serde(rename_all = "snake_case")]
192pub enum ResponseVerbosity {
193    #[default]
194    Full,
195    HeadersOnly,
196}
197
198impl ResponseVerbosity {
199    pub fn effective() -> Self {
200        if let Ok(v) = std::env::var("LEAN_CTX_RESPONSE_VERBOSITY") {
201            match v.trim().to_lowercase().as_str() {
202                "headers_only" | "headers" | "minimal" => return Self::HeadersOnly,
203                "full" | "" => return Self::Full,
204                _ => {}
205            }
206        }
207        Config::load().response_verbosity
208    }
209
210    pub fn is_headers_only(&self) -> bool {
211        matches!(self, Self::HeadersOnly)
212    }
213}
214
215/// Each level maps to specific component settings via `to_components()`.
216#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
217#[serde(rename_all = "lowercase")]
218pub enum CompressionLevel {
219    Off,
220    /// Default: plain-English "concise" guidance (bullets, no filler). Readable
221    /// by humans inspecting their rules files, and still token-saving. The
222    /// denser, symbolic styles (`Standard`/`Max`, which enable CRP and the
223    /// `→ ∵ ∴` vocabulary) are opt-in "power modes" — set `compression_level`
224    /// in config. This only shapes the model's prose; tool-output compression
225    /// is governed separately and is unaffected.
226    #[default]
227    Lite,
228    Standard,
229    Max,
230    /// Ultra-dense bullet-point-only mode — zero prose, diff-style facts,
231    /// strictest token budget (<=50 tokens per non-code response). Maps to
232    /// CrpMode::Tdd with tighter output constraints (#795).
233    Raw,
234}
235
236/// Science-driven context intelligence features that can be individually toggled.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
238#[serde(rename_all = "lowercase")]
239pub enum CognitiveMode {
240    /// All science features disabled.
241    Off,
242    /// Only basic features (IB, chunking).
243    Basic,
244    /// Full science suite (IB, chunking, FSRS, OT allocation, graph expansion, verbosity learning).
245    #[default]
246    Full,
247}
248
249impl std::fmt::Display for CognitiveMode {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.write_str(match self {
252            Self::Off => "off",
253            Self::Basic => "basic",
254            Self::Full => "full",
255        })
256    }
257}
258
259/// Outcome of [`CompressionLevel::degrade_action`]: what to do with the session
260/// degrade given the current re-fetch pressure. Split from the dispatch so the
261/// threshold logic is a pure, testable function.
262#[derive(Debug, Clone, Copy, PartialEq)]
263pub enum SessionDegrade {
264    /// Set the session degrade to this level.
265    Set(CompressionLevel),
266    /// Clear any session degrade (pressure fully relaxed).
267    Clear,
268    /// Leave the current degrade unchanged (intermediate pressure band).
269    Leave,
270}
271
272impl CompressionLevel {
273    /// Decomposes the unified level into legacy component settings.
274    /// Returns (TerseAgent, OutputDensity, crp_mode_str, terse_mode_bool).
275    pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
276        match self {
277            Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
278            Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
279            Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
280            Self::Max | Self::Raw => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
281        }
282    }
283
284    /// Infers a `CompressionLevel` from legacy config keys for backward compatibility.
285    /// Priority: terse_agent > output_density (picks the highest implied level).
286    pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
287        match (terse_agent, output_density) {
288            (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
289            (TerseAgent::Full, _) => Self::Standard,
290            (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
291            _ => Self::Off,
292        }
293    }
294
295    /// Reads the compression level from the `LEAN_CTX_COMPRESSION` env var.
296    pub fn from_env() -> Option<Self> {
297        std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
298            match v.trim().to_lowercase().as_str() {
299                "off" => Some(Self::Off),
300                "lite" => Some(Self::Lite),
301                "standard" => Some(Self::Standard),
302                "max" => Some(Self::Max),
303                "raw" => Some(Self::Raw),
304                _ => None,
305            }
306        })
307    }
308
309    /// Returns the effective compression level with resolution order:
310    /// 0. Session-level degrade override (set by correction-loop feedback)
311    /// 1. `LEAN_CTX_COMPRESSION` env var
312    /// 2. `compression_level` in config
313    /// 3. Legacy `ultra_compact` flag (maps to `Max`)
314    /// 4. Legacy env vars (`LEAN_CTX_TERSE_AGENT`, `LEAN_CTX_OUTPUT_DENSITY`)
315    /// 5. Legacy config fields (`terse_agent`, `output_density`)
316    pub fn effective(config: &Config) -> Self {
317        if let Some(degraded) = Self::session_degrade_level() {
318            return degraded;
319        }
320        if let Some(env_level) = Self::from_env() {
321            return env_level;
322        }
323        if config.compression_level != Self::Off {
324            return config.compression_level;
325        }
326        if config.ultra_compact {
327            return Self::Max;
328        }
329        let ta_env = TerseAgent::from_env();
330        let od_env = OutputDensity::from_env();
331        let ta = if ta_env == TerseAgent::Off {
332            config.terse_agent.clone()
333        } else {
334            ta_env
335        };
336        let od = if od_env == OutputDensity::Normal {
337            config.output_density.clone()
338        } else {
339            od_env
340        };
341        Self::from_legacy(&ta, &od)
342    }
343
344    /// Session-level degrade: correction loop detected, temporarily reduce compression.
345    /// 0 = no override, 1 = Off, 2 = Lite
346    pub fn session_degrade_level() -> Option<Self> {
347        match SESSION_DEGRADE_LEVEL.load(std::sync::atomic::Ordering::Relaxed) {
348            1 => Some(Self::Off),
349            2 => Some(Self::Lite),
350            _ => None,
351        }
352    }
353
354    /// Sets a session-level compression degrade (called by correction loop detection).
355    pub fn set_session_degrade(level: &Self) {
356        let val = match level {
357            Self::Off => 1u8,
358            Self::Lite => 2u8,
359            _ => 0u8,
360        };
361        SESSION_DEGRADE_LEVEL.store(val, std::sync::atomic::Ordering::Relaxed);
362    }
363
364    /// Clears the session-level degrade (recovery after correction rate drops).
365    pub fn clear_session_degrade() {
366        SESSION_DEGRADE_LEVEL.store(0, std::sync::atomic::Ordering::Relaxed);
367    }
368
369    /// Maps re-fetch *pressure* to a session-degrade decision. Pressure is the
370    /// stronger of the correction-loop count (re-reads/re-runs) and the CCR
371    /// retrieve count (`ctx_expand`/`ctx_retrieve`) — two views of the same "too
372    /// aggressive" signal (#941): 5+ degrades to `Off`, 3+ to `Lite`, 0 clears,
373    /// and the 1–2 band leaves the current degrade untouched.
374    ///
375    /// Pure and total so the thresholds are unit-testable without the dispatch
376    /// path — the regression guard for the brittle source-grep test this replaced
377    /// (#957).
378    pub fn degrade_action(correction_count: u32, retrieve_count: u32) -> SessionDegrade {
379        let pressure = correction_count.max(retrieve_count);
380        if pressure >= 5 {
381            SessionDegrade::Set(Self::Off)
382        } else if pressure >= 3 {
383            SessionDegrade::Set(Self::Lite)
384        } else if pressure == 0 {
385            SessionDegrade::Clear
386        } else {
387            SessionDegrade::Leave
388        }
389    }
390
391    /// Applies a [`SessionDegrade`] decision to the process-global session state.
392    pub fn apply_degrade_action(action: SessionDegrade) {
393        match action {
394            SessionDegrade::Set(level) => Self::set_session_degrade(&level),
395            SessionDegrade::Clear => Self::clear_session_degrade(),
396            SessionDegrade::Leave => {}
397        }
398    }
399
400    pub fn from_str_label(s: &str) -> Option<Self> {
401        match s.trim().to_lowercase().as_str() {
402            "off" => Some(Self::Off),
403            "lite" => Some(Self::Lite),
404            "standard" | "std" => Some(Self::Standard),
405            "max" => Some(Self::Max),
406            _ => None,
407        }
408    }
409
410    pub fn is_active(&self) -> bool {
411        !matches!(self, Self::Off)
412    }
413
414    /// Numeric aggression scale for level comparisons (higher = more compression).
415    pub fn aggression_index(self) -> u8 {
416        match self {
417            Self::Off => 0,
418            Self::Lite => 1,
419            Self::Standard => 2,
420            Self::Max => 3,
421            Self::Raw => 4,
422        }
423    }
424
425    /// Returns `true` when `self` compresses more aggressively than `other`.
426    pub fn is_more_aggressive_than(self, other: &Self) -> bool {
427        self.aggression_index() > other.aggression_index()
428    }
429    pub fn label(&self) -> &'static str {
430        match self {
431            Self::Off => "off",
432            Self::Lite => "lite",
433            Self::Standard => "standard",
434            Self::Max => "max",
435            Self::Raw => "raw",
436        }
437    }
438
439    pub fn description(&self) -> &'static str {
440        match self {
441            Self::Off => "No compression — full verbose output",
442            Self::Lite => "Light compression — concise output, basic terse filtering",
443            Self::Standard => {
444                "Standard compression — dense output, compact protocol, pattern-aware"
445            }
446            Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
447            Self::Raw => "Raw-dense compression — bullet points only, zero prose, diff-style facts",
448        }
449    }
450}
451
452/// Where agent rule files are installed: global home dir, project-local, or both.
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
454pub enum RulesScope {
455    Both,
456    Global,
457    Project,
458}
459
460/// How agent rules are injected for AGENTS.md/CLAUDE.md/CODEBUDDY.md/GEMINI.md consumers.
461///
462/// - `Shared` (default): write a marker-delimited block into the user's shared
463///   instruction file (`CLAUDE.md`, `CODEBUDDY.md`, `AGENTS.md`, `GEMINI.md`) — zero-config
464///   discoverability, but touches a file the user also authors.
465/// - `Dedicated`: never write into those shared files. Instead use each agent's
466///   config-driven, fully-removable auto-load path (Claude/Codex `SessionStart`
467///   hook `additionalContext`, OpenCode `instructions[]`, Gemini
468///   `context.fileName`) plus a lean-ctx-owned rules file. See issue #343.
469/// - `Off`: never write any rules file. For hosts that already supply their own
470///   tool-steering workflow (e.g. an embedded extension) or for phase-isolated /
471///   non-caching harnesses where the injected prefix is pure re-billed overhead
472///   with no cached-re-read dividend to amortize it. See GitHub #361.
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474pub enum RulesInjection {
475    Shared,
476    Dedicated,
477    Off,
478}
479
480/// Whether lean-ctx mirrors the host IDE's tool-permission rules onto its own
481/// MCP tools ("permission inheritance").
482///
483/// - `Off` (default): lean-ctx tools are governed only by lean-ctx's own layers
484///   (role policy, shell allowlist). lean-ctx's `ctx_shell` therefore runs
485///   independently of the IDE's `bash`/`rm *` permission rules.
486/// - `On`: before dispatching, lean-ctx reads the active IDE's permission config
487///   (v1: OpenCode `opencode.json[c]`) and applies the equivalent decision to
488///   the matching lean-ctx tool — `deny` blocks, `ask` is held back (MCP cannot
489///   prompt for these tools), `allow` proceeds. Read-only; lean-ctx never writes
490///   the IDE's `permission` block.
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub enum PermissionInheritance {
493    Off,
494    On,
495}
496
497#[cfg(test)]
498mod tests {
499    use super::{CompressionLevel, SessionDegrade};
500
501    #[test]
502    fn degrade_action_thresholds_are_pressure_based() {
503        use CompressionLevel::{Lite, Off};
504        use SessionDegrade::{Clear, Leave, Set};
505        // 5+ pressure → Off, driven by EITHER the correction-loop or the CCR
506        // retrieve count (the stronger of the two), per #941.
507        assert_eq!(CompressionLevel::degrade_action(5, 0), Set(Off));
508        assert_eq!(CompressionLevel::degrade_action(0, 5), Set(Off));
509        assert_eq!(CompressionLevel::degrade_action(9, 1), Set(Off));
510        // 3–4 pressure → Lite.
511        assert_eq!(CompressionLevel::degrade_action(3, 0), Set(Lite));
512        assert_eq!(CompressionLevel::degrade_action(0, 4), Set(Lite));
513        assert_eq!(CompressionLevel::degrade_action(4, 4), Set(Lite));
514        // 0 pressure → clear; the 1–2 band holds the current degrade.
515        assert_eq!(CompressionLevel::degrade_action(0, 0), Clear);
516        assert_eq!(CompressionLevel::degrade_action(2, 1), Leave);
517        assert_eq!(CompressionLevel::degrade_action(1, 2), Leave);
518    }
519}