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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
63#[serde(rename_all = "lowercase")]
64pub enum TeeMode {
65    Never,
66    #[default]
67    Failures,
68    HighCompression,
69    Always,
70}
71
72/// Legacy: Controls agent output verbosity level injected into MCP instructions.
73/// Superseded by `CompressionLevel`. Kept for backward compatibility with old config.toml files.
74/// New setups use `compression_level` instead. See `CompressionLevel::effective()`.
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
76#[serde(rename_all = "lowercase")]
77pub enum TerseAgent {
78    #[default]
79    Off,
80    Lite,
81    Full,
82    Ultra,
83}
84
85impl TerseAgent {
86    /// Reads the terse-agent level from the `LEAN_CTX_TERSE_AGENT` env var.
87    pub fn from_env() -> Self {
88        match std::env::var("LEAN_CTX_TERSE_AGENT")
89            .unwrap_or_default()
90            .to_lowercase()
91            .as_str()
92        {
93            "lite" => Self::Lite,
94            "full" => Self::Full,
95            "ultra" => Self::Ultra,
96            _ => Self::Off,
97        }
98    }
99}
100
101/// Legacy: Controls how dense/compact MCP tool output is formatted.
102/// Superseded by `CompressionLevel`. Kept for backward compatibility with old config.toml files.
103/// New setups use `compression_level` instead. See `CompressionLevel::effective()`.
104#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
105#[serde(rename_all = "lowercase")]
106pub enum OutputDensity {
107    #[default]
108    Normal,
109    Terse,
110    Ultra,
111}
112
113impl OutputDensity {
114    /// Reads the output density from the `LEAN_CTX_OUTPUT_DENSITY` env var.
115    pub fn from_env() -> Self {
116        match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
117            .unwrap_or_default()
118            .to_lowercase()
119            .as_str()
120        {
121            "terse" => Self::Terse,
122            "ultra" => Self::Ultra,
123            _ => Self::Normal,
124        }
125    }
126}
127
128/// Unified compression level that replaces the 4 separate legacy concepts:
129/// `terse_agent`, `output_density`, `terse_mode`, and `crp_mode`.
130///
131/// Controls how much detail tool responses include.
132#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
133#[serde(rename_all = "snake_case")]
134pub enum ResponseVerbosity {
135    #[default]
136    Full,
137    HeadersOnly,
138}
139
140impl ResponseVerbosity {
141    pub fn effective() -> Self {
142        if let Ok(v) = std::env::var("LEAN_CTX_RESPONSE_VERBOSITY") {
143            match v.trim().to_lowercase().as_str() {
144                "headers_only" | "headers" | "minimal" => return Self::HeadersOnly,
145                "full" | "" => return Self::Full,
146                _ => {}
147            }
148        }
149        Config::load().response_verbosity
150    }
151
152    pub fn is_headers_only(&self) -> bool {
153        matches!(self, Self::HeadersOnly)
154    }
155}
156
157/// Each level maps to specific component settings via `to_components()`.
158#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
159#[serde(rename_all = "lowercase")]
160pub enum CompressionLevel {
161    Off,
162    /// Default: plain-English "concise" guidance (bullets, no filler). Readable
163    /// by humans inspecting their rules files, and still token-saving. The
164    /// denser, symbolic styles (`Standard`/`Max`, which enable CRP and the
165    /// `→ ∵ ∴` vocabulary) are opt-in "power modes" — set `compression_level`
166    /// in config. This only shapes the model's prose; tool-output compression
167    /// is governed separately and is unaffected.
168    #[default]
169    Lite,
170    Standard,
171    Max,
172}
173
174impl CompressionLevel {
175    /// Decomposes the unified level into legacy component settings.
176    /// Returns (TerseAgent, OutputDensity, crp_mode_str, terse_mode_bool).
177    pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
178        match self {
179            Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
180            Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
181            Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
182            Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
183        }
184    }
185
186    /// Infers a `CompressionLevel` from legacy config keys for backward compatibility.
187    /// Priority: terse_agent > output_density (picks the highest implied level).
188    pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
189        match (terse_agent, output_density) {
190            (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
191            (TerseAgent::Full, _) => Self::Standard,
192            (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
193            _ => Self::Off,
194        }
195    }
196
197    /// Reads the compression level from the `LEAN_CTX_COMPRESSION` env var.
198    pub fn from_env() -> Option<Self> {
199        std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
200            match v.trim().to_lowercase().as_str() {
201                "off" => Some(Self::Off),
202                "lite" => Some(Self::Lite),
203                "standard" => Some(Self::Standard),
204                "max" => Some(Self::Max),
205                _ => None,
206            }
207        })
208    }
209
210    /// Returns the effective compression level with resolution order:
211    /// 0. Session-level degrade override (set by correction-loop feedback)
212    /// 1. `LEAN_CTX_COMPRESSION` env var
213    /// 2. `compression_level` in config
214    /// 3. Legacy `ultra_compact` flag (maps to `Max`)
215    /// 4. Legacy env vars (`LEAN_CTX_TERSE_AGENT`, `LEAN_CTX_OUTPUT_DENSITY`)
216    /// 5. Legacy config fields (`terse_agent`, `output_density`)
217    pub fn effective(config: &Config) -> Self {
218        if let Some(degraded) = Self::session_degrade_level() {
219            return degraded;
220        }
221        if let Some(env_level) = Self::from_env() {
222            return env_level;
223        }
224        if config.compression_level != Self::Off {
225            return config.compression_level;
226        }
227        if config.ultra_compact {
228            return Self::Max;
229        }
230        let ta_env = TerseAgent::from_env();
231        let od_env = OutputDensity::from_env();
232        let ta = if ta_env == TerseAgent::Off {
233            config.terse_agent.clone()
234        } else {
235            ta_env
236        };
237        let od = if od_env == OutputDensity::Normal {
238            config.output_density.clone()
239        } else {
240            od_env
241        };
242        Self::from_legacy(&ta, &od)
243    }
244
245    /// Session-level degrade: correction loop detected, temporarily reduce compression.
246    /// 0 = no override, 1 = Off, 2 = Lite
247    pub fn session_degrade_level() -> Option<Self> {
248        match SESSION_DEGRADE_LEVEL.load(std::sync::atomic::Ordering::Relaxed) {
249            1 => Some(Self::Off),
250            2 => Some(Self::Lite),
251            _ => None,
252        }
253    }
254
255    /// Sets a session-level compression degrade (called by correction loop detection).
256    pub fn set_session_degrade(level: &Self) {
257        let val = match level {
258            Self::Off => 1u8,
259            Self::Lite => 2u8,
260            _ => 0u8,
261        };
262        SESSION_DEGRADE_LEVEL.store(val, std::sync::atomic::Ordering::Relaxed);
263    }
264
265    /// Clears the session-level degrade (recovery after correction rate drops).
266    pub fn clear_session_degrade() {
267        SESSION_DEGRADE_LEVEL.store(0, std::sync::atomic::Ordering::Relaxed);
268    }
269
270    pub fn from_str_label(s: &str) -> Option<Self> {
271        match s.trim().to_lowercase().as_str() {
272            "off" => Some(Self::Off),
273            "lite" => Some(Self::Lite),
274            "standard" | "std" => Some(Self::Standard),
275            "max" => Some(Self::Max),
276            _ => None,
277        }
278    }
279
280    pub fn is_active(&self) -> bool {
281        !matches!(self, Self::Off)
282    }
283
284    pub fn label(&self) -> &'static str {
285        match self {
286            Self::Off => "off",
287            Self::Lite => "lite",
288            Self::Standard => "standard",
289            Self::Max => "max",
290        }
291    }
292
293    pub fn description(&self) -> &'static str {
294        match self {
295            Self::Off => "No compression — full verbose output",
296            Self::Lite => "Light compression — concise output, basic terse filtering",
297            Self::Standard => {
298                "Standard compression — dense output, compact protocol, pattern-aware"
299            }
300            Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
301        }
302    }
303}
304
305/// Where agent rule files are installed: global home dir, project-local, or both.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum RulesScope {
308    Both,
309    Global,
310    Project,
311}
312
313/// How agent rules are injected for AGENTS.md/CLAUDE.md/CODEBUDDY.md/GEMINI.md consumers.
314///
315/// - `Shared` (default): write a marker-delimited block into the user's shared
316///   instruction file (`CLAUDE.md`, `CODEBUDDY.md`, `AGENTS.md`, `GEMINI.md`) — zero-config
317///   discoverability, but touches a file the user also authors.
318/// - `Dedicated`: never write into those shared files. Instead use each agent's
319///   config-driven, fully-removable auto-load path (Claude/Codex `SessionStart`
320///   hook `additionalContext`, OpenCode `instructions[]`, Gemini
321///   `context.fileName`) plus a lean-ctx-owned rules file. See issue #343.
322/// - `Off`: never write any rules file. For hosts that already supply their own
323///   tool-steering workflow (e.g. an embedded extension) or for phase-isolated /
324///   non-caching harnesses where the injected prefix is pure re-billed overhead
325///   with no cached-re-read dividend to amortize it. See GitHub #361.
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub enum RulesInjection {
328    Shared,
329    Dedicated,
330    Off,
331}
332
333/// Whether lean-ctx mirrors the host IDE's tool-permission rules onto its own
334/// MCP tools ("permission inheritance").
335///
336/// - `Off` (default): lean-ctx tools are governed only by lean-ctx's own layers
337///   (role policy, shell allowlist). lean-ctx's `ctx_shell` therefore runs
338///   independently of the IDE's `bash`/`rm *` permission rules.
339/// - `On`: before dispatching, lean-ctx reads the active IDE's permission config
340///   (v1: OpenCode `opencode.json[c]`) and applies the equivalent decision to
341///   the matching lean-ctx tool — `deny` blocks, `ask` is held back (MCP cannot
342///   prompt for these tools), `allow` proceeds. Read-only; lean-ctx never writes
343///   the IDE's `permission` block.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum PermissionInheritance {
346    Off,
347    On,
348}