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
174/// Outcome of [`CompressionLevel::degrade_action`]: what to do with the session
175/// degrade given the current re-fetch pressure. Split from the dispatch so the
176/// threshold logic is a pure, testable function.
177#[derive(Debug, Clone, Copy, PartialEq)]
178pub enum SessionDegrade {
179 /// Set the session degrade to this level.
180 Set(CompressionLevel),
181 /// Clear any session degrade (pressure fully relaxed).
182 Clear,
183 /// Leave the current degrade unchanged (intermediate pressure band).
184 Leave,
185}
186
187impl CompressionLevel {
188 /// Decomposes the unified level into legacy component settings.
189 /// Returns (TerseAgent, OutputDensity, crp_mode_str, terse_mode_bool).
190 pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
191 match self {
192 Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
193 Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
194 Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
195 Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
196 }
197 }
198
199 /// Infers a `CompressionLevel` from legacy config keys for backward compatibility.
200 /// Priority: terse_agent > output_density (picks the highest implied level).
201 pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
202 match (terse_agent, output_density) {
203 (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
204 (TerseAgent::Full, _) => Self::Standard,
205 (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
206 _ => Self::Off,
207 }
208 }
209
210 /// Reads the compression level from the `LEAN_CTX_COMPRESSION` env var.
211 pub fn from_env() -> Option<Self> {
212 std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
213 match v.trim().to_lowercase().as_str() {
214 "off" => Some(Self::Off),
215 "lite" => Some(Self::Lite),
216 "standard" => Some(Self::Standard),
217 "max" => Some(Self::Max),
218 _ => None,
219 }
220 })
221 }
222
223 /// Returns the effective compression level with resolution order:
224 /// 0. Session-level degrade override (set by correction-loop feedback)
225 /// 1. `LEAN_CTX_COMPRESSION` env var
226 /// 2. `compression_level` in config
227 /// 3. Legacy `ultra_compact` flag (maps to `Max`)
228 /// 4. Legacy env vars (`LEAN_CTX_TERSE_AGENT`, `LEAN_CTX_OUTPUT_DENSITY`)
229 /// 5. Legacy config fields (`terse_agent`, `output_density`)
230 pub fn effective(config: &Config) -> Self {
231 if let Some(degraded) = Self::session_degrade_level() {
232 return degraded;
233 }
234 if let Some(env_level) = Self::from_env() {
235 return env_level;
236 }
237 if config.compression_level != Self::Off {
238 return config.compression_level;
239 }
240 if config.ultra_compact {
241 return Self::Max;
242 }
243 let ta_env = TerseAgent::from_env();
244 let od_env = OutputDensity::from_env();
245 let ta = if ta_env == TerseAgent::Off {
246 config.terse_agent.clone()
247 } else {
248 ta_env
249 };
250 let od = if od_env == OutputDensity::Normal {
251 config.output_density.clone()
252 } else {
253 od_env
254 };
255 Self::from_legacy(&ta, &od)
256 }
257
258 /// Session-level degrade: correction loop detected, temporarily reduce compression.
259 /// 0 = no override, 1 = Off, 2 = Lite
260 pub fn session_degrade_level() -> Option<Self> {
261 match SESSION_DEGRADE_LEVEL.load(std::sync::atomic::Ordering::Relaxed) {
262 1 => Some(Self::Off),
263 2 => Some(Self::Lite),
264 _ => None,
265 }
266 }
267
268 /// Sets a session-level compression degrade (called by correction loop detection).
269 pub fn set_session_degrade(level: &Self) {
270 let val = match level {
271 Self::Off => 1u8,
272 Self::Lite => 2u8,
273 _ => 0u8,
274 };
275 SESSION_DEGRADE_LEVEL.store(val, std::sync::atomic::Ordering::Relaxed);
276 }
277
278 /// Clears the session-level degrade (recovery after correction rate drops).
279 pub fn clear_session_degrade() {
280 SESSION_DEGRADE_LEVEL.store(0, std::sync::atomic::Ordering::Relaxed);
281 }
282
283 /// Maps re-fetch *pressure* to a session-degrade decision. Pressure is the
284 /// stronger of the correction-loop count (re-reads/re-runs) and the CCR
285 /// retrieve count (`ctx_expand`/`ctx_retrieve`) — two views of the same "too
286 /// aggressive" signal (#941): 5+ degrades to `Off`, 3+ to `Lite`, 0 clears,
287 /// and the 1–2 band leaves the current degrade untouched.
288 ///
289 /// Pure and total so the thresholds are unit-testable without the dispatch
290 /// path — the regression guard for the brittle source-grep test this replaced
291 /// (#957).
292 pub fn degrade_action(correction_count: u32, retrieve_count: u32) -> SessionDegrade {
293 let pressure = correction_count.max(retrieve_count);
294 if pressure >= 5 {
295 SessionDegrade::Set(Self::Off)
296 } else if pressure >= 3 {
297 SessionDegrade::Set(Self::Lite)
298 } else if pressure == 0 {
299 SessionDegrade::Clear
300 } else {
301 SessionDegrade::Leave
302 }
303 }
304
305 /// Applies a [`SessionDegrade`] decision to the process-global session state.
306 pub fn apply_degrade_action(action: SessionDegrade) {
307 match action {
308 SessionDegrade::Set(level) => Self::set_session_degrade(&level),
309 SessionDegrade::Clear => Self::clear_session_degrade(),
310 SessionDegrade::Leave => {}
311 }
312 }
313
314 pub fn from_str_label(s: &str) -> Option<Self> {
315 match s.trim().to_lowercase().as_str() {
316 "off" => Some(Self::Off),
317 "lite" => Some(Self::Lite),
318 "standard" | "std" => Some(Self::Standard),
319 "max" => Some(Self::Max),
320 _ => None,
321 }
322 }
323
324 pub fn is_active(&self) -> bool {
325 !matches!(self, Self::Off)
326 }
327
328 pub fn label(&self) -> &'static str {
329 match self {
330 Self::Off => "off",
331 Self::Lite => "lite",
332 Self::Standard => "standard",
333 Self::Max => "max",
334 }
335 }
336
337 pub fn description(&self) -> &'static str {
338 match self {
339 Self::Off => "No compression — full verbose output",
340 Self::Lite => "Light compression — concise output, basic terse filtering",
341 Self::Standard => {
342 "Standard compression — dense output, compact protocol, pattern-aware"
343 }
344 Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
345 }
346 }
347}
348
349/// Where agent rule files are installed: global home dir, project-local, or both.
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum RulesScope {
352 Both,
353 Global,
354 Project,
355}
356
357/// How agent rules are injected for AGENTS.md/CLAUDE.md/CODEBUDDY.md/GEMINI.md consumers.
358///
359/// - `Shared` (default): write a marker-delimited block into the user's shared
360/// instruction file (`CLAUDE.md`, `CODEBUDDY.md`, `AGENTS.md`, `GEMINI.md`) — zero-config
361/// discoverability, but touches a file the user also authors.
362/// - `Dedicated`: never write into those shared files. Instead use each agent's
363/// config-driven, fully-removable auto-load path (Claude/Codex `SessionStart`
364/// hook `additionalContext`, OpenCode `instructions[]`, Gemini
365/// `context.fileName`) plus a lean-ctx-owned rules file. See issue #343.
366/// - `Off`: never write any rules file. For hosts that already supply their own
367/// tool-steering workflow (e.g. an embedded extension) or for phase-isolated /
368/// non-caching harnesses where the injected prefix is pure re-billed overhead
369/// with no cached-re-read dividend to amortize it. See GitHub #361.
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371pub enum RulesInjection {
372 Shared,
373 Dedicated,
374 Off,
375}
376
377/// Whether lean-ctx mirrors the host IDE's tool-permission rules onto its own
378/// MCP tools ("permission inheritance").
379///
380/// - `Off` (default): lean-ctx tools are governed only by lean-ctx's own layers
381/// (role policy, shell allowlist). lean-ctx's `ctx_shell` therefore runs
382/// independently of the IDE's `bash`/`rm *` permission rules.
383/// - `On`: before dispatching, lean-ctx reads the active IDE's permission config
384/// (v1: OpenCode `opencode.json[c]`) and applies the equivalent decision to
385/// the matching lean-ctx tool — `deny` blocks, `ask` is held back (MCP cannot
386/// prompt for these tools), `allow` proceeds. Read-only; lean-ctx never writes
387/// the IDE's `permission` block.
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum PermissionInheritance {
390 Off,
391 On,
392}
393
394#[cfg(test)]
395mod tests {
396 use super::{CompressionLevel, SessionDegrade};
397
398 #[test]
399 fn degrade_action_thresholds_are_pressure_based() {
400 use CompressionLevel::{Lite, Off};
401 use SessionDegrade::{Clear, Leave, Set};
402 // 5+ pressure → Off, driven by EITHER the correction-loop or the CCR
403 // retrieve count (the stronger of the two), per #941.
404 assert_eq!(CompressionLevel::degrade_action(5, 0), Set(Off));
405 assert_eq!(CompressionLevel::degrade_action(0, 5), Set(Off));
406 assert_eq!(CompressionLevel::degrade_action(9, 1), Set(Off));
407 // 3–4 pressure → Lite.
408 assert_eq!(CompressionLevel::degrade_action(3, 0), Set(Lite));
409 assert_eq!(CompressionLevel::degrade_action(0, 4), Set(Lite));
410 assert_eq!(CompressionLevel::degrade_action(4, 4), Set(Lite));
411 // 0 pressure → clear; the 1–2 band holds the current degrade.
412 assert_eq!(CompressionLevel::degrade_action(0, 0), Clear);
413 assert_eq!(CompressionLevel::degrade_action(2, 1), Leave);
414 assert_eq!(CompressionLevel::degrade_action(1, 2), Leave);
415 }
416}