Skip to main content

lean_ctx/tools/registered/
ctx_read.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Mutex};
3
4use rmcp::ErrorData;
5use rmcp::model::Tool;
6use serde_json::{Map, Value, json};
7
8use crate::server::tool_trait::{
9    McpTool, ToolContext, ToolOutput, get_bool, get_f64, get_int, get_str, get_str_array,
10    require_resolved_path,
11};
12use crate::tool_defs::tool_def;
13
14/// Per-file lock that serializes concurrent reads of the same path.
15///
16/// When multiple subagents read sequentially through a shared set of files,
17/// they tend to hit the same path at the same time. Without per-file locking
18/// they all contend on the global cache write lock while doing redundant I/O.
19/// This lock ensures only one thread reads a given file from disk; the others
20/// wait cheaply on the per-file mutex, then hit the warm cache.
21///
22/// Backed by the shared `core::path_locks` registry so reads and edits of the
23/// same path coordinate through a single mutex (see issue #320).
24fn per_file_lock(path: &str) -> Arc<Mutex<()>> {
25    crate::core::path_locks::per_file_lock(path)
26}
27
28pub struct CtxReadTool;
29
30impl McpTool for CtxReadTool {
31    fn name(&self) -> &'static str {
32        "ctx_read"
33    }
34
35    fn tool_def(&self) -> Tool {
36        tool_def(
37            "ctx_read",
38            "Read a file. Prefer over native Read/cat/head/tail (cached, compressed).\n\
39             Omit mode to auto-select (recommended); use full only right before editing. \
40             Re-reads ~13 tokens. fresh=true forces a disk re-read.",
41            json!({
42                "type": "object",
43                "properties": {
44                    "path": { "type": "string", "description": "Absolute file path" },
45                    "mode": {
46                        "type": "string",
47                        "default": "auto",
48                        "description": "Omit to auto-select (recommended). auto(default)|full (for editing)|raw|map (large files)|signatures|diff|task|reference|aggressive|entropy|lines:N-M|density:0.X"
49                    },
50                    "start_line": { "type": "integer", "description": "First line, 1-based (alias: offset)" },
51                    "offset": { "type": "integer", "description": "Alias for start_line" },
52                    "limit": { "type": "integer", "description": "Max lines to read" },
53                    "fresh": { "type": "boolean", "description": "Bypass cache, force disk re-read" },
54                    "aggressiveness": { "type": "number", "description": "Compression intensity 0.0 (lossless)–1.0 (max). With no explicit mode, routes through the density path at the mapped target; also tunes entropy/task. Omit for per-mode defaults" },
55                    "protect": { "type": "array", "items": { "type": "string" }, "description": "Symbols/strings whose lines must never be compressed away — force-kept verbatim in entropy/task modes" }
56                },
57                "required": ["path"]
58            }),
59        )
60    }
61
62    fn handle(
63        &self,
64        args: &Map<String, Value>,
65        ctx: &ToolContext,
66    ) -> Result<ToolOutput, ErrorData> {
67        let path = require_resolved_path(ctx, args, "path")?;
68
69        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
70            self.handle_inner(args, ctx, &path)
71        })) {
72            Ok(result) => result,
73            Err(_) => Err(ErrorData::internal_error(
74                format!(
75                    "ctx_read panicked while processing '{path}'. This is a bug — please report it."
76                ),
77                None,
78            )),
79        }
80    }
81}
82
83impl CtxReadTool {
84    #[allow(clippy::unused_self)]
85    fn handle_inner(
86        &self,
87        args: &Map<String, Value>,
88        ctx: &ToolContext,
89        path: &str,
90    ) -> Result<ToolOutput, ErrorData> {
91        let session_lock = ctx
92            .session
93            .as_ref()
94            .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
95        let cache_lock = ctx
96            .cache
97            .as_ref()
98            .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
99
100        let current_task = {
101            let rt = tokio::runtime::Handle::current();
102            let mut attempt = 0u32;
103            loop {
104                if let Ok(session) = rt.block_on(tokio::time::timeout(
105                    std::time::Duration::from_secs(5),
106                    session_lock.read(),
107                )) {
108                    break session.task.as_ref().map(|t| t.description.clone());
109                }
110                attempt += 1;
111                if attempt >= 3 {
112                    tracing::warn!(
113                        "session read-lock timeout after {attempt} attempts in ctx_read for {path}"
114                    );
115                    return Err(ErrorData::internal_error(
116                        "session lock timeout — another tool may be holding it. Retry in a moment.",
117                        None,
118                    ));
119                }
120                tracing::debug!(
121                    "session read-lock attempt {attempt}/3 timed out for {path}, retrying"
122                );
123                std::thread::sleep(std::time::Duration::from_millis(100 * u64::from(attempt)));
124            }
125        };
126        let task_ref = current_task.as_deref();
127
128        let profile = crate::core::profiles::active_profile();
129        let explicit_mode_arg = get_str(args, "mode");
130        let explicit_mode = explicit_mode_arg.is_some();
131        // #673 — when the caller omits `mode`, a context policy pack's
132        // `default_read_mode` (if set) takes precedence over the profile/auto
133        // selection. An explicit `mode` arg always wins; line windows below may
134        // still narrow it (it is a default, not a pin).
135        let policy_default_mode = if explicit_mode {
136            None
137        } else {
138            crate::core::policy::runtime::active()
139                .and_then(|p| p.resolved.default_read_mode.clone())
140        };
141        let mut mode = if let Some(m) = explicit_mode_arg {
142            m
143        } else if let Some(pd) = policy_default_mode {
144            pd
145        } else if profile.read.default_mode_effective() == "auto" {
146            if let Ok(cache) = cache_lock.try_read() {
147                crate::tools::ctx_smart_read::select_mode_with_task(&cache, path, task_ref)
148            } else {
149                tracing::debug!(
150                    "cache lock contested during auto-mode selection for {path}; \
151                     falling back to full"
152                );
153                "full".to_string()
154            }
155        } else {
156            profile.read.default_mode_effective().to_string()
157        };
158        let mut fresh = get_bool(args, "fresh").unwrap_or(false);
159        let cache_policy = crate::server::compaction_sync::effective_cache_policy();
160        if cache_policy == "off" {
161            fresh = true;
162        }
163        // #714 aggressiveness knob: per-call arg > LEAN_CTX_AGGRESSIVENESS > config.
164        let aggressiveness =
165            crate::core::aggressiveness::effective(get_f64(args, "aggressiveness"));
166        // #720 protect list: lines containing any of these survive the lossy
167        // line filters (entropy/task) verbatim; threaded into ReadTuning.protect.
168        let protect = get_str_array(args, "protect").unwrap_or_default();
169        // One-knob UX: when the caller sets aggressiveness without pinning a mode,
170        // route through the proven density path at the mapped target. An explicit
171        // mode (incl. entropy/task) instead has the knob tune it via ReadTuning.
172        if !explicit_mode && let Some(a) = aggressiveness {
173            mode = format!(
174                "density:{:.2}",
175                crate::core::aggressiveness::AggressivenessProfile::from_level(a).density_target
176            );
177        }
178        // `start_line` (and its `offset`/`limit` aliases) can pin a line window.
179        // The resolution lives in `apply_line_window`/`resolve_line_window` so
180        // the runtime path and the unit tests share one implementation and can
181        // never drift (GitHub #432 aliases, #259 explicit-mode, #253 line-1).
182        apply_line_window(
183            &mut mode,
184            &mut fresh,
185            explicit_mode,
186            get_int(args, "start_line"),
187            get_int(args, "offset"),
188            get_int(args, "limit"),
189        );
190
191        let pressure_action = ctx.pressure_snapshot.as_ref().map(|p| &p.recommendation);
192        let resolved_agent_id = ctx.agent_id.as_ref().and_then(|a| match a.try_read() {
193            Ok(guard) => guard.clone(),
194            Err(_) => None,
195        });
196        let gate_result = crate::server::context_gate::pre_dispatch_read_for_agent(
197            path,
198            &mode,
199            task_ref,
200            Some(&ctx.project_root),
201            pressure_action,
202            resolved_agent_id.as_deref(),
203        );
204        if gate_result.budget_blocked {
205            let msg = gate_result
206                .budget_warning
207                .unwrap_or_else(|| "Agent token budget exceeded".to_string());
208            return Err(ErrorData::invalid_params(msg, None));
209        }
210        let budget_warning = gate_result.budget_warning.clone();
211        if let Some(overridden) = gate_result.overridden_mode {
212            mode = overridden;
213        }
214
215        let (mut mode, degrade_warning) = if crate::tools::ctx_read::is_instruction_file(path) {
216            ("full".to_string(), None)
217        } else {
218            auto_degrade_read_mode(&mode)
219        };
220
221        // Delta-aware explicit re-reads (opt-in: config `delta_explicit`, env
222        // LCTX_DELTA_EXPLICIT). Re-requesting full/lines:N-M content for a file
223        // this session already read re-emits content the model already holds;
224        // when the file changed on disk, a diff carries the same information in
225        // a fraction of the tokens, and an unchanged lines: request of a
226        // fully-delivered file collapses to the full-mode stub. The decision is
227        // a pure function of (cache, path, mode) — see
228        // `ctx_read::resolve_explicit_delta_mode`. First reads are unaffected;
229        // fresh=true always bypasses. Runs BEFORE the lines:→fresh guard below
230        // so a changed-file lines: re-read can still be diverted to a diff.
231        let mut delta_explicit_note: Option<String> = None;
232        if !fresh
233            && explicit_mode
234            && (mode == "full" || mode.starts_with("lines:"))
235            && crate::core::config::Config::load().delta_explicit_effective()
236            && let Ok(cache) = cache_lock.try_read()
237        {
238            let decision = crate::tools::ctx_read::resolve_explicit_delta_mode(
239                &cache,
240                path,
241                &mode,
242                explicit_mode,
243                fresh,
244                true,
245            );
246            mode = decision.mode;
247            delta_explicit_note = decision.note;
248        }
249
250        if mode.starts_with("lines:") {
251            fresh = true;
252        }
253
254        if crate::core::binary_detect::is_binary_file(path) {
255            let msg = crate::core::binary_detect::binary_file_message(path);
256            return Err(ErrorData::invalid_params(msg, None));
257        }
258        {
259            let cap = crate::core::limits::max_read_bytes() as u64;
260            if let Ok(meta) = std::fs::metadata(path)
261                && meta.len() > cap
262            {
263                let msg = format!(
264                    "File too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
265                         Use mode=\"lines:1-100\" for partial reads or increase the limit.",
266                    meta.len(),
267                    cap
268                );
269                return Err(ErrorData::invalid_params(msg, None));
270            }
271        }
272
273        // Compaction-aware: if host compacted since last check, reset delivery flags
274        // so post-compaction reads deliver full content instead of stubs.
275        if !fresh
276            && let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir()
277            && let Ok(mut cache) = cache_lock.try_write()
278        {
279            crate::server::compaction_sync::sync_if_compacted(&mut cache, &data_dir);
280        }
281
282        // Fast path: if both per-file lock and cache write-lock are immediately
283        // available, execute inline without spawning a thread. This avoids thread +
284        // channel overhead for the ~90% of calls that are cache hits.
285        let read_timeout = std::time::Duration::from_secs(30);
286        let cancelled = Arc::new(AtomicBool::new(false));
287        let (output, resolved_mode, original, is_cache_hit, file_ref, cache_stats) = {
288            let crp_mode = ctx.crp_mode;
289            let task_ref = current_task.as_deref();
290
291            let fast_result = 'fast: {
292                let file_lock = per_file_lock(path);
293                let Some(_file_guard) = file_lock.try_lock().ok() else {
294                    break 'fast None;
295                };
296
297                // Phase 1 (shared lock): the dominant case is re-reading an
298                // unchanged file in full mode. Serve that stub under a *read*
299                // lock so parallel reads of distinct files run concurrently
300                // instead of serializing on the global write lock.
301                if !fresh
302                    && mode == "full"
303                    && let Ok(cache) = cache_lock.try_read()
304                    && let Some(read_output) =
305                        crate::tools::ctx_read::try_stub_hit_readonly(&cache, path)
306                {
307                    let content = read_output.content;
308                    let rmode = read_output.resolved_mode;
309                    let orig = cache.get(path).map_or(0, |e| e.original_tokens);
310                    let hit = content.contains(" cached ")
311                        || content.contains("[unchanged")
312                        || content.contains("[delta:");
313                    let fref = cache.file_ref_map().get(path).cloned();
314                    let stats = cache.get_stats();
315                    let stats_snapshot = (stats.total_reads(), stats.cache_hits());
316                    break 'fast Some((content, rmode, orig, hit, fref, stats_snapshot));
317                }
318
319                // Phase 2 (write lock): cache miss, changed file, or non-stub
320                // modes (map/signatures/diff/lines) that mutate cache state.
321                let Some(mut cache) = cache_lock.try_write().ok() else {
322                    break 'fast None;
323                };
324                let read_output = if fresh {
325                    crate::tools::ctx_read::handle_fresh_with_task_resolved_tuned(
326                        &mut cache,
327                        path,
328                        &mode,
329                        crp_mode,
330                        task_ref,
331                        aggressiveness,
332                        &protect,
333                    )
334                } else {
335                    crate::tools::ctx_read::handle_with_task_resolved_tuned(
336                        &mut cache,
337                        path,
338                        &mode,
339                        crp_mode,
340                        task_ref,
341                        aggressiveness,
342                        &protect,
343                    )
344                };
345                let content = read_output.content;
346                let rmode = read_output.resolved_mode;
347                let orig = cache.get(path).map_or(0, |e| e.original_tokens);
348                let hit = content.contains(" cached ")
349                    || content.contains("[unchanged")
350                    || content.contains("[delta:");
351                let fref = cache.file_ref_map().get(path).cloned();
352                let stats = cache.get_stats();
353                let stats_snapshot = (stats.total_reads(), stats.cache_hits());
354                Some((content, rmode, orig, hit, fref, stats_snapshot))
355            };
356
357            if let Some(result) = fast_result {
358                result
359            } else {
360                // Slow path: spawn thread with bounded timeout for contended locks.
361                let cache_lock = cache_lock.clone();
362                let mode = mode.clone();
363                let task_owned = current_task.clone();
364                let protect_owned = protect.clone();
365                let path_owned = path.to_string();
366                let cancel_flag = cancelled.clone();
367                let (tx, rx) = std::sync::mpsc::sync_channel(1);
368                std::thread::spawn(move || {
369                    let file_lock = per_file_lock(&path_owned);
370
371                    // Bounded per-file lock: if a zombie thread still holds it, don't
372                    // wait forever. 25s keeps us inside the 30s recv_timeout.
373                    let _file_guard = {
374                        let deadline =
375                            std::time::Instant::now() + std::time::Duration::from_secs(25);
376                        loop {
377                            if cancel_flag.load(Ordering::Relaxed) {
378                                return;
379                            }
380                            if let Ok(guard) = file_lock.try_lock() {
381                                break guard;
382                            }
383                            if std::time::Instant::now() >= deadline {
384                                tracing::error!(
385                                    "ctx_read: per-file lock timeout after 25s for {path_owned}"
386                                );
387                                let _ = tx.send((
388                                    format!("per-file lock contention for {path_owned} — retry in a moment"),
389                                    "error".to_string(), 0, false, None, (0, 0),
390                                ));
391                                return;
392                            }
393                            std::thread::sleep(std::time::Duration::from_millis(50));
394                        }
395                    };
396
397                    if cancel_flag.load(Ordering::Relaxed) {
398                        return;
399                    }
400
401                    // Bounded cache write-lock: avoids indefinite block when a zombie
402                    // thread from a previous timed-out call still holds the lock.
403                    let mut cache = {
404                        let deadline =
405                            std::time::Instant::now() + std::time::Duration::from_secs(25);
406                        loop {
407                            if cancel_flag.load(Ordering::Relaxed) {
408                                return;
409                            }
410                            if let Ok(guard) = cache_lock.try_write() {
411                                break guard;
412                            }
413                            if std::time::Instant::now() >= deadline {
414                                tracing::error!(
415                                    "ctx_read: cache write-lock timeout after 25s for {path_owned}"
416                                );
417                                let _ = tx.send((
418                                    format!(
419                                        "cache lock contention for {path_owned} — retry in a moment"
420                                    ),
421                                    "error".to_string(),
422                                    0,
423                                    false,
424                                    None,
425                                    (0, 0),
426                                ));
427                                return;
428                            }
429                            std::thread::sleep(std::time::Duration::from_millis(50));
430                        }
431                    };
432
433                    let task_ref = task_owned.as_deref();
434                    let read_output = if fresh {
435                        crate::tools::ctx_read::handle_fresh_with_task_resolved_tuned(
436                            &mut cache,
437                            &path_owned,
438                            &mode,
439                            crp_mode,
440                            task_ref,
441                            aggressiveness,
442                            &protect_owned,
443                        )
444                    } else {
445                        crate::tools::ctx_read::handle_with_task_resolved_tuned(
446                            &mut cache,
447                            &path_owned,
448                            &mode,
449                            crp_mode,
450                            task_ref,
451                            aggressiveness,
452                            &protect_owned,
453                        )
454                    };
455                    let content = read_output.content;
456                    let rmode = read_output.resolved_mode;
457                    let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
458                    let hit = content.contains(" cached ");
459                    let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
460                    let stats = cache.get_stats();
461                    let stats_snapshot = (stats.total_reads(), stats.cache_hits());
462                    let _ = tx.send((content, rmode, orig, hit, fref, stats_snapshot));
463                });
464                if let Ok(result) = rx.recv_timeout(read_timeout) {
465                    result
466                } else {
467                    cancelled.store(true, Ordering::Relaxed);
468                    tracing::error!("ctx_read timed out after {read_timeout:?} for {path}");
469                    let msg = format!(
470                        "ERROR: ctx_read timed out after {}s reading {path}. \
471                     The file may be very large or a blocking I/O issue occurred. \
472                     Try mode=\"lines:1-100\" for a partial read.",
473                        read_timeout.as_secs()
474                    );
475                    return Err(ErrorData::internal_error(msg, None));
476                }
477            } // end else (slow path)
478        };
479
480        // Convert error results to proper MCP ErrorData instead of success body
481        if resolved_mode == "error" {
482            return Err(ErrorData::invalid_params(output, None));
483        }
484
485        let output_tokens = crate::core::tokens::count_tokens(&output);
486        let saved = original.saturating_sub(output_tokens);
487
488        // Session updates (bounded lock — 10s timeout, read already succeeded)
489        let mut ensured_root: Option<String> = None;
490        let mut traversal_working_set: Vec<String> = Vec::new();
491        let project_root_snapshot;
492        {
493            let rt = tokio::runtime::Handle::current();
494            let session_guard = rt.block_on(tokio::time::timeout(
495                std::time::Duration::from_secs(10),
496                session_lock.write(),
497            ));
498            if let Ok(mut session) = session_guard {
499                session.touch_file(path, file_ref.as_deref(), &resolved_mode, original);
500                // Capture the recent working set (under the lock) so the
501                // background thread can record a traversal/co-access edge (#289).
502                traversal_working_set =
503                    crate::core::tool_lifecycle::recent_working_set(&session, path);
504                // Auto-generate file summary from output content
505                let file_summary = extract_file_summary(&output, path);
506                if !file_summary.is_empty() {
507                    session.set_file_summary(path, &file_summary);
508                }
509                if is_cache_hit {
510                    session.record_cache_hit();
511                }
512                if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
513                    let touched: Vec<String> = session
514                        .files_touched
515                        .iter()
516                        .map(|f| f.path.clone())
517                        .collect();
518                    let inferred =
519                        crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
520                    if inferred.confidence >= 0.4 {
521                        session.active_structured_intent = Some(inferred);
522                    }
523                }
524                // Auto-infer task every 5th file read if not explicitly set
525                if session.task.is_none() && session.stats.files_read % 5 == 0 {
526                    session.auto_infer_task();
527                }
528                let root_missing = session
529                    .project_root
530                    .as_deref()
531                    .is_none_or(|r| r.trim().is_empty());
532                if root_missing && let Some(root) = crate::core::protocol::detect_project_root(path)
533                {
534                    session.project_root = Some(root.clone());
535                    ensured_root = Some(root);
536                }
537                project_root_snapshot = session
538                    .project_root
539                    .clone()
540                    .unwrap_or_else(|| ".".to_string());
541            } else {
542                tracing::warn!(
543                    "session write-lock timeout (5s) in ctx_read post-update for {path}"
544                );
545                project_root_snapshot = ctx.project_root.clone();
546            }
547        }
548
549        if let Some(root) = ensured_root.as_deref() {
550            crate::core::index_orchestrator::ensure_all_background(root);
551        }
552
553        // Telemetry + learning are pure side-effects that never influence this
554        // response, yet they did synchronous disk I/O on every read (heatmap
555        // append, ModePredictor load+save, FeedbackStore load). Push them off
556        // the hot path so reads — especially cache-hit stubs — return without
557        // waiting on disk (#149).
558        {
559            let path_bg = path.to_string();
560            let resolved_mode_bg = resolved_mode.clone();
561            let project_root_bg = project_root_snapshot.clone();
562            let (turns, hits) = cache_stats;
563            // #685: model-correct verified-ledger inputs, computed off the hot path.
564            // The default O200kBase model reuses the o200k `original`/`saved` below
565            // (byte-identical, no clone). Only a resolved Claude/Gemini/Llama model
566            // carries the cache handle + output so the bg thread can re-tokenize the
567            // raw source and the sent output in the family the provider actually bills.
568            let ledger_cache = (crate::core::savings_ledger::ledger_family()
569                != crate::core::tokens::TokenizerFamily::O200kBase)
570                .then(|| cache_lock.clone());
571            let ledger_output = ledger_cache.as_ref().map(|_| output.clone());
572            std::thread::spawn(move || {
573                // A panic in telemetry must not poison locks or leave a zombie thread;
574                // it never affects the already-returned read response.
575                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
576                    crate::core::heatmap::record_file_access(&path_bg, original, saved);
577
578                    // #685: verified savings ledger, decoupled from the heatmap so it
579                    // can denominate in the active model's tokenizer family. O200kBase
580                    // reuses the o200k counts; other families re-tokenize raw (cache)
581                    // + output. A cache miss falls back to o200k (conservative).
582                    {
583                        use crate::core::savings_ledger as ledger;
584                        let (lbase, lsaved) = match (&ledger_cache, &ledger_output) {
585                            (Some(cl), Some(out)) => match cl.try_read().ok().and_then(|c| {
586                                c.get(&path_bg)
587                                    .and_then(crate::core::cache::CacheEntry::content)
588                            }) {
589                                Some(raw) => {
590                                    let lo = ledger::count_for_ledger(&raw);
591                                    (lo, lo.saturating_sub(ledger::count_for_ledger(out)))
592                                }
593                                None => (original, saved),
594                            },
595                            _ => (original, saved),
596                        };
597                        ledger::record_read_event(lbase, lsaved);
598                    }
599
600                    // Traversal/co-access edge: this read fired together with the
601                    // recent working set captured under the session lock (#289).
602                    if let Some(root) =
603                        crate::core::tool_lifecycle::usable_root(Some(project_root_bg.as_str()))
604                    {
605                        crate::core::cooccurrence::record_focus_access(
606                            root,
607                            &path_bg,
608                            &traversal_working_set,
609                        );
610                    }
611
612                    let sig =
613                        crate::core::mode_predictor::FileSignature::from_path(&path_bg, original);
614                    let density = if output_tokens > 0 {
615                        original as f64 / output_tokens as f64
616                    } else {
617                        1.0
618                    };
619                    let outcome = crate::core::mode_predictor::ModeOutcome {
620                        mode: resolved_mode_bg,
621                        tokens_in: original,
622                        tokens_out: output_tokens,
623                        density: density.min(1.0),
624                    };
625                    let mut predictor = crate::core::mode_predictor::ModePredictor::new();
626                    predictor.set_project_root(&project_root_bg);
627                    predictor.record(sig, outcome);
628                    predictor.save();
629
630                    let ext = std::path::Path::new(&path_bg)
631                        .extension()
632                        .and_then(|e| e.to_str())
633                        .unwrap_or("")
634                        .to_string();
635                    let thresholds =
636                        crate::core::adaptive_thresholds::thresholds_for_path(&path_bg);
637                    let feedback_outcome = crate::core::feedback::CompressionOutcome {
638                        session_id: format!("{}", std::process::id()),
639                        language: ext,
640                        entropy_threshold: thresholds.bpe_entropy,
641                        jaccard_threshold: thresholds.jaccard,
642                        total_turns: turns as u32,
643                        tokens_saved: saved as u64,
644                        tokens_original: original as u64,
645                        cache_hits: hits as u32,
646                        total_reads: turns as u32,
647                        // Real behavioral signal instead of a hardcoded success
648                        // (#593): a compressed read only counts as task-completing
649                        // when this extension is not in a high-bounce state —
650                        // compression that keeps forcing full re-reads is not
651                        // "completing" anything. Unknown (too few reads) stays
652                        // optimistic so the cold start is unchanged. 0.30 mirrors
653                        // bounce_tracker::BOUNCE_RATE_THRESHOLD.
654                        task_completed: crate::core::bounce_tracker::global()
655                            .lock()
656                            .ok()
657                            .and_then(|bt| bt.bounce_rate_for_extension(&path_bg))
658                            .is_none_or(|rate| rate < 0.30),
659                        timestamp: chrono::Local::now().to_rfc3339(),
660                    };
661                    let mut store = crate::core::feedback::FeedbackStore::load();
662                    store.project_root = Some(project_root_bg);
663                    store.record_outcome(feedback_outcome);
664                }));
665            });
666        }
667
668        if let Some(aid) = resolved_agent_id.as_deref() {
669            crate::core::agent_budget::record_consumption(aid, output_tokens);
670        }
671
672        // Cross-source hints: if the property graph has cross-source edges
673        // pointing to this file, append compact hints so the agent knows about
674        // related issues/PRs/schemas without a separate tool call (#682). Only
675        // touch the DB when it already exists — never create graph.db on a read.
676        let hints_suffix = {
677            let graph_db =
678                crate::core::property_graph::graph_dir(&ctx.project_root).join("graph.db");
679            let edges = if graph_db.exists() {
680                crate::core::property_graph::CodeGraph::open(&ctx.project_root)
681                    .map(|g| g.all_cross_source_edges())
682                    .unwrap_or_default()
683            } else {
684                Vec::new()
685            };
686            if edges.is_empty() {
687                String::new()
688            } else {
689                let hints = crate::core::cross_source_hints::hints_for_file(
690                    path,
691                    &edges,
692                    &ctx.project_root,
693                );
694                crate::core::cross_source_hints::format_hints(&hints)
695            }
696        };
697
698        let mut warnings = Vec::new();
699        if let Some(ref w) = budget_warning {
700            warnings.push(w.as_str());
701        }
702        if let Some(ref w) = degrade_warning {
703            warnings.push(w.as_str());
704        }
705        if let Some(ref w) = delta_explicit_note {
706            warnings.push(w.as_str());
707        }
708        let final_output = if !warnings.is_empty() {
709            format!("{output}{hints_suffix}\n\n{}", warnings.join("\n"))
710        } else if hints_suffix.is_empty() {
711            output
712        } else {
713            format!("{output}{hints_suffix}")
714        };
715
716        Ok(ToolOutput {
717            text: final_output,
718            original_tokens: original,
719            saved_tokens: saved,
720            mode: Some(resolved_mode),
721            path: Some(path.to_string()),
722            changed: false,
723            shell_outcome: None,
724        })
725    }
726}
727
728/// Resolve the `start_line`/`offset`/`limit` arguments into `(start, limit)`.
729///
730/// `offset` is an alias for `start_line` (1-based first line); `start_line`
731/// wins if a caller passes both. `limit` (when > 0) bounds the number of lines;
732/// a bare `limit` reads from line 1. Returns `None` when no windowing argument
733/// is present, so the caller leaves the mode untouched (GitHub #432).
734fn resolve_line_window(
735    start_line: Option<i64>,
736    offset: Option<i64>,
737    limit: Option<i64>,
738) -> Option<(i64, Option<i64>)> {
739    let start = start_line.or(offset).map(|v| v.max(1));
740    let limit = limit.filter(|&l| l > 0);
741    match (start, limit) {
742        (Some(s), l) => Some((s, l)),
743        (None, Some(_)) => Some((1, limit)),
744        (None, None) => None,
745    }
746}
747
748/// Build the `lines:N-M` mode string for a resolved window. An unbounded window
749/// (no `limit`) reads to EOF via the historical `999999` sentinel.
750fn lines_mode(start: i64, limit: Option<i64>) -> String {
751    match limit {
752        Some(l) => format!("lines:{start}-{}", start + l - 1),
753        None => format!("lines:{start}-999999"),
754    }
755}
756
757/// Apply a resolved line window to `mode`/`fresh`. An explicit non-lines mode
758/// (map/signatures/…) is never clobbered (#259), and `start_line=1` with no
759/// limit is a no-op so it cannot disturb an auto/explicit read (#253).
760fn apply_line_window(
761    mode: &mut String,
762    fresh: &mut bool,
763    explicit_mode: bool,
764    start_line: Option<i64>,
765    offset: Option<i64>,
766    limit: Option<i64>,
767) {
768    let Some((start, limit)) = resolve_line_window(start_line, offset, limit) else {
769        return;
770    };
771    if start <= 1 && limit.is_none() {
772        return;
773    }
774    *fresh = true;
775    if !explicit_mode || mode.starts_with("lines") {
776        *mode = lines_mode(start, limit);
777    }
778}
779
780fn apply_verdict(
781    mode: &str,
782    verdict: crate::core::degradation_policy::DegradationVerdictV1,
783) -> (String, bool) {
784    use crate::core::degradation_policy::DegradationVerdictV1;
785    match verdict {
786        DegradationVerdictV1::Ok => (mode.to_string(), false),
787        DegradationVerdictV1::Warn => match mode {
788            "full" => ("map".to_string(), true),
789            other => (other.to_string(), false),
790        },
791        DegradationVerdictV1::Throttle => match mode {
792            "full" | "map" => ("signatures".to_string(), true),
793            other => (other.to_string(), false),
794        },
795        DegradationVerdictV1::Block => {
796            if mode == "signatures" {
797                ("signatures".to_string(), false)
798            } else {
799                ("signatures".to_string(), true)
800            }
801        }
802    }
803}
804
805fn auto_degrade_read_mode(mode: &str) -> (String, Option<String>) {
806    if crate::core::config::Config::load().no_degrade_effective() {
807        return (mode.to_string(), None);
808    }
809    let profile = crate::core::profiles::active_profile();
810    if !profile.degradation.enforce_effective() {
811        return (mode.to_string(), None);
812    }
813    let policy = crate::core::degradation_policy::evaluate_v1_for_tool("ctx_read", None);
814    let (new_mode, degraded) = apply_verdict(mode, policy.decision.verdict);
815    let warning = if degraded {
816        Some(format!(
817            "⚠ Context pressure: mode={mode} was downgraded to mode={new_mode} \
818             (verdict: {:?}). Use start_line=1 to bypass, or run ctx_compress to free budget.",
819            policy.decision.verdict
820        ))
821    } else {
822        None
823    };
824    (new_mode, warning)
825}
826
827fn extract_file_summary(output: &str, path: &str) -> String {
828    let hint = crate::core::auto_findings::extract_content_hint(output);
829    if !hint.is_empty() {
830        return hint;
831    }
832    let ext = std::path::Path::new(path)
833        .extension()
834        .and_then(|e| e.to_str())
835        .unwrap_or("");
836    let line_count = output.lines().count();
837    if line_count > 5 {
838        format!("{ext} file, {line_count} lines")
839    } else {
840        String::new()
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use std::sync::atomic::{AtomicUsize, Ordering};
848
849    #[test]
850    fn per_file_lock_same_path_returns_same_mutex() {
851        let lock_a1 = per_file_lock("/tmp/test_same_path.txt");
852        let lock_a2 = per_file_lock("/tmp/test_same_path.txt");
853        assert!(Arc::ptr_eq(&lock_a1, &lock_a2));
854    }
855
856    #[test]
857    fn per_file_lock_different_paths_return_different_mutexes() {
858        let lock_a = per_file_lock("/tmp/test_path_a.txt");
859        let lock_b = per_file_lock("/tmp/test_path_b.txt");
860        assert!(!Arc::ptr_eq(&lock_a, &lock_b));
861    }
862
863    #[test]
864    fn per_file_lock_serializes_concurrent_access() {
865        let counter = Arc::new(AtomicUsize::new(0));
866        let max_concurrent = Arc::new(AtomicUsize::new(0));
867        let path = "/tmp/test_concurrent_serialization.txt";
868        let mut handles = Vec::new();
869
870        for _ in 0..5 {
871            let counter = counter.clone();
872            let max_concurrent = max_concurrent.clone();
873            let path = path.to_string();
874            handles.push(std::thread::spawn(move || {
875                let lock = per_file_lock(&path);
876                let _guard = lock.lock().unwrap();
877                let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
878                max_concurrent.fetch_max(active, Ordering::SeqCst);
879                std::thread::sleep(std::time::Duration::from_millis(10));
880                counter.fetch_sub(1, Ordering::SeqCst);
881            }));
882        }
883
884        for h in handles {
885            h.join().unwrap();
886        }
887
888        assert_eq!(max_concurrent.load(Ordering::SeqCst), 1);
889    }
890
891    #[test]
892    fn per_file_lock_allows_parallel_different_paths() {
893        let counter = Arc::new(AtomicUsize::new(0));
894        let max_concurrent = Arc::new(AtomicUsize::new(0));
895        let mut handles = Vec::new();
896
897        for i in 0..4 {
898            let counter = counter.clone();
899            let max_concurrent = max_concurrent.clone();
900            let path = format!("/tmp/test_parallel_{i}.txt");
901            handles.push(std::thread::spawn(move || {
902                let lock = per_file_lock(&path);
903                let _guard = lock.lock().unwrap();
904                let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
905                max_concurrent.fetch_max(active, Ordering::SeqCst);
906                std::thread::sleep(std::time::Duration::from_millis(50));
907                counter.fetch_sub(1, Ordering::SeqCst);
908            }));
909        }
910
911        for h in handles {
912            h.join().unwrap();
913        }
914
915        assert!(max_concurrent.load(Ordering::SeqCst) > 1);
916    }
917
918    /// Regression test for Issue #229: a zombie thread holding the cache write-lock
919    /// must not block subsequent reads indefinitely. The try_write() loop inside
920    /// the spawned thread should respect its 25s deadline and the cancellation flag.
921    #[test]
922    fn zombie_thread_does_not_block_subsequent_cache_access() {
923        let cache: Arc<tokio::sync::RwLock<u32>> = Arc::new(tokio::sync::RwLock::new(0));
924
925        // Simulate a zombie: hold the write-lock on a background thread for 2s.
926        let zombie_lock = cache.clone();
927        let _zombie = std::thread::spawn(move || {
928            let _guard = zombie_lock.blocking_write();
929            std::thread::sleep(std::time::Duration::from_secs(2));
930        });
931        std::thread::sleep(std::time::Duration::from_millis(50));
932
933        // A try_read() must fail immediately (zombie holds write-lock).
934        assert!(cache.try_read().is_err());
935
936        // A try_write() loop with cancellation must exit promptly.
937        let cancel = Arc::new(AtomicBool::new(false));
938        let cancel2 = cancel.clone();
939        let lock2 = cache.clone();
940        let waiter = std::thread::spawn(move || {
941            let start = std::time::Instant::now();
942            loop {
943                if cancel2.load(Ordering::Relaxed) {
944                    return (false, start.elapsed());
945                }
946                if let Ok(_guard) = lock2.try_write() {
947                    return (true, start.elapsed());
948                }
949                std::thread::sleep(std::time::Duration::from_millis(50));
950            }
951        });
952
953        // Set cancellation after 200ms — the loop should exit quickly.
954        std::thread::sleep(std::time::Duration::from_millis(200));
955        cancel.store(true, Ordering::Relaxed);
956
957        let (acquired, elapsed) = waiter.join().unwrap();
958        assert!(
959            !acquired,
960            "should not have acquired lock while zombie holds it"
961        );
962        assert!(
963            elapsed < std::time::Duration::from_secs(1),
964            "cancellation should have stopped the loop promptly"
965        );
966    }
967
968    // -- Regression: GitHub Issue #253 + #259 --
969    // Delegates to the real runtime helper so this test can never drift from
970    // production behaviour.
971    fn apply_start_line(
972        mode: &mut String,
973        fresh: &mut bool,
974        explicit_mode: bool,
975        start_line: Option<i64>,
976    ) {
977        super::apply_line_window(mode, fresh, explicit_mode, start_line, None, None);
978    }
979
980    #[test]
981    fn start_line_1_does_not_override_mode() {
982        let mut mode = "auto".to_string();
983        let mut fresh = false;
984        apply_start_line(&mut mode, &mut fresh, false, Some(1));
985        assert_eq!(mode, "auto", "start_line=1 should not change mode");
986        assert!(!fresh, "start_line=1 should not force fresh=true");
987    }
988
989    #[test]
990    fn start_line_gt1_overrides_implicit_mode() {
991        let mut mode = "auto".to_string();
992        let mut fresh = false;
993        apply_start_line(&mut mode, &mut fresh, false, Some(50));
994        assert_eq!(mode, "lines:50-999999");
995        assert!(fresh);
996    }
997
998    #[test]
999    fn start_line_gt1_does_not_override_explicit_map() {
1000        // GitHub #259: mode=map + start_line=50 → mode stays map
1001        let mut mode = "map".to_string();
1002        let mut fresh = false;
1003        apply_start_line(&mut mode, &mut fresh, true, Some(50));
1004        assert_eq!(
1005            mode, "map",
1006            "explicit mode=map must not be clobbered by start_line"
1007        );
1008        assert!(fresh, "start_line>1 should still force fresh");
1009    }
1010
1011    #[test]
1012    fn start_line_gt1_does_not_override_explicit_signatures() {
1013        let mut mode = "signatures".to_string();
1014        let mut fresh = false;
1015        apply_start_line(&mut mode, &mut fresh, true, Some(100));
1016        assert_eq!(mode, "signatures");
1017        assert!(fresh);
1018    }
1019
1020    #[test]
1021    fn start_line_gt1_honors_explicit_lines_mode() {
1022        let mut mode = "lines:1-50".to_string();
1023        let mut fresh = false;
1024        apply_start_line(&mut mode, &mut fresh, true, Some(30));
1025        assert_eq!(
1026            mode, "lines:30-999999",
1027            "explicit lines mode should accept start_line override"
1028        );
1029        assert!(fresh);
1030    }
1031
1032    #[test]
1033    fn start_line_none_does_nothing() {
1034        let mut mode = "map".to_string();
1035        let mut fresh = false;
1036        apply_start_line(&mut mode, &mut fresh, true, None);
1037        assert_eq!(mode, "map");
1038        assert!(!fresh);
1039    }
1040
1041    #[test]
1042    fn start_line_1_with_explicit_mode_preserves_it() {
1043        // OpenCode sends start_line=1 + mode=map — both should be preserved
1044        let mut mode = "map".to_string();
1045        let mut fresh = false;
1046        apply_start_line(&mut mode, &mut fresh, true, Some(1));
1047        assert_eq!(mode, "map");
1048        assert!(!fresh);
1049    }
1050
1051    // -- Regression: GitHub Issue #432 — `offset`/`limit` aliases --
1052
1053    #[test]
1054    fn offset_is_alias_for_start_line() {
1055        let mut mode = "auto".to_string();
1056        let mut fresh = false;
1057        super::apply_line_window(&mut mode, &mut fresh, false, None, Some(40), None);
1058        assert_eq!(mode, "lines:40-999999");
1059        assert!(fresh);
1060    }
1061
1062    #[test]
1063    fn offset_and_limit_make_bounded_window() {
1064        let mut mode = "auto".to_string();
1065        let mut fresh = false;
1066        super::apply_line_window(&mut mode, &mut fresh, false, None, Some(40), Some(20));
1067        assert_eq!(mode, "lines:40-59", "20 inclusive lines starting at 40");
1068        assert!(fresh);
1069    }
1070
1071    #[test]
1072    fn limit_alone_reads_from_first_line() {
1073        let mut mode = "auto".to_string();
1074        let mut fresh = false;
1075        super::apply_line_window(&mut mode, &mut fresh, false, None, None, Some(25));
1076        assert_eq!(mode, "lines:1-25");
1077        assert!(fresh);
1078    }
1079
1080    #[test]
1081    fn start_line_wins_over_offset_when_both_present() {
1082        assert_eq!(
1083            super::resolve_line_window(Some(10), Some(99), None),
1084            Some((10, None))
1085        );
1086    }
1087
1088    #[test]
1089    fn resolve_clamps_start_and_drops_nonpositive_limit() {
1090        // Negative/zero start clamps to 1; non-positive limit is ignored.
1091        assert_eq!(
1092            super::resolve_line_window(Some(-5), None, Some(0)),
1093            Some((1, None))
1094        );
1095        // A bare non-positive limit yields no window at all.
1096        assert_eq!(super::resolve_line_window(None, None, Some(-3)), None);
1097        assert_eq!(super::resolve_line_window(None, None, None), None);
1098    }
1099
1100    #[test]
1101    fn lines_mode_bounds_are_inclusive() {
1102        assert_eq!(super::lines_mode(40, Some(20)), "lines:40-59");
1103        assert_eq!(super::lines_mode(5, None), "lines:5-999999");
1104    }
1105
1106    #[test]
1107    fn explicit_map_not_clobbered_by_offset_limit() {
1108        // #259 must also hold for the new aliases.
1109        let mut mode = "map".to_string();
1110        let mut fresh = false;
1111        super::apply_line_window(&mut mode, &mut fresh, true, None, Some(40), Some(20));
1112        assert_eq!(mode, "map", "explicit mode wins over offset/limit");
1113        assert!(fresh);
1114    }
1115
1116    /// Schema/handler consistency (GitHub #432): the handler reads
1117    /// start_line/offset/limit, so the advertised schema must document them —
1118    /// otherwise agents (and the generated docs/manifest) can't discover the
1119    /// aliases and the divergence that caused this bug returns.
1120    #[test]
1121    fn schema_advertises_line_window_aliases() {
1122        let tool = CtxReadTool.tool_def();
1123        let props = tool
1124            .input_schema
1125            .get("properties")
1126            .and_then(|p| p.as_object())
1127            .expect("ctx_read schema has a properties object");
1128        for key in ["path", "mode", "start_line", "offset", "limit", "fresh"] {
1129            assert!(props.contains_key(key), "ctx_read schema missing '{key}'");
1130        }
1131    }
1132
1133    // -- Regression: GitHub Issue #262 --
1134    // auto_degrade_read_mode must produce a warning when mode is downgraded.
1135
1136    use crate::core::degradation_policy::DegradationVerdictV1;
1137
1138    #[test]
1139    fn verdict_ok_does_not_degrade() {
1140        let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Ok);
1141        assert_eq!(mode, "full");
1142        assert!(!degraded);
1143    }
1144
1145    #[test]
1146    fn verdict_warn_degrades_full_to_map() {
1147        let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
1148        assert_eq!(mode, "map");
1149        assert!(degraded, "full→map must be flagged as degraded");
1150    }
1151
1152    #[test]
1153    fn verdict_warn_keeps_map() {
1154        let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Warn);
1155        assert_eq!(mode, "map");
1156        assert!(!degraded, "map is not degraded under Warn");
1157    }
1158
1159    #[test]
1160    fn verdict_warn_keeps_signatures() {
1161        let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Warn);
1162        assert_eq!(mode, "signatures");
1163        assert!(!degraded);
1164    }
1165
1166    #[test]
1167    fn verdict_throttle_degrades_full_to_signatures() {
1168        let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Throttle);
1169        assert_eq!(mode, "signatures");
1170        assert!(degraded);
1171    }
1172
1173    #[test]
1174    fn verdict_throttle_degrades_map_to_signatures() {
1175        let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Throttle);
1176        assert_eq!(mode, "signatures");
1177        assert!(degraded);
1178    }
1179
1180    #[test]
1181    fn verdict_throttle_keeps_lines() {
1182        let (mode, degraded) = super::apply_verdict("lines:1-50", DegradationVerdictV1::Throttle);
1183        assert_eq!(mode, "lines:1-50");
1184        assert!(!degraded, "lines mode bypasses degradation");
1185    }
1186
1187    #[test]
1188    fn verdict_block_degrades_full_to_signatures() {
1189        let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Block);
1190        assert_eq!(mode, "signatures");
1191        assert!(degraded);
1192    }
1193
1194    #[test]
1195    fn verdict_block_does_not_degrade_signatures() {
1196        let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Block);
1197        assert_eq!(mode, "signatures");
1198        assert!(!degraded, "already at signatures — no degradation needed");
1199    }
1200
1201    #[test]
1202    fn degrade_warning_message_contains_mode_info() {
1203        let (new_mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
1204        assert!(degraded);
1205        let warning = format!(
1206            "⚠ Context pressure: mode=full was downgraded to mode={new_mode} (verdict: {:?}).",
1207            DegradationVerdictV1::Warn
1208        );
1209        assert!(warning.contains("mode=full"));
1210        assert!(warning.contains("mode=map"));
1211        assert!(warning.contains("Warn"));
1212    }
1213
1214    // --- auto_degrade_read_mode: no_degrade integration ---
1215    // With default config (no LCTX_NO_DEGRADE), the profile's degradation.enforce
1216    // is also off by default, so auto_degrade_read_mode returns mode unchanged.
1217
1218    #[test]
1219    fn auto_degrade_preserves_full_when_default_config() {
1220        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1221            return;
1222        }
1223        let (mode, warning) = super::auto_degrade_read_mode("full");
1224        assert_eq!(mode, "full");
1225        assert!(warning.is_none());
1226    }
1227
1228    #[test]
1229    fn auto_degrade_preserves_map_when_default_config() {
1230        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1231            return;
1232        }
1233        let (mode, warning) = super::auto_degrade_read_mode("map");
1234        assert_eq!(mode, "map");
1235        assert!(warning.is_none());
1236    }
1237
1238    #[test]
1239    fn auto_degrade_preserves_signatures_when_default_config() {
1240        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1241            return;
1242        }
1243        let (mode, warning) = super::auto_degrade_read_mode("signatures");
1244        assert_eq!(mode, "signatures");
1245        assert!(warning.is_none());
1246    }
1247
1248    #[test]
1249    fn auto_degrade_preserves_diff_always() {
1250        let (mode, warning) = super::auto_degrade_read_mode("diff");
1251        assert_eq!(mode, "diff");
1252        assert!(warning.is_none());
1253    }
1254
1255    #[test]
1256    fn auto_degrade_preserves_lines_mode_always() {
1257        let (mode, warning) = super::auto_degrade_read_mode("lines:10-50");
1258        assert_eq!(mode, "lines:10-50");
1259        assert!(warning.is_none());
1260    }
1261
1262    #[test]
1263    fn auto_degrade_preserves_aggressive_when_default_config() {
1264        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1265            return;
1266        }
1267        let (mode, warning) = super::auto_degrade_read_mode("aggressive");
1268        assert_eq!(mode, "aggressive");
1269        assert!(warning.is_none());
1270    }
1271
1272    #[test]
1273    fn auto_degrade_preserves_entropy_when_default_config() {
1274        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1275            return;
1276        }
1277        let (mode, warning) = super::auto_degrade_read_mode("entropy");
1278        assert_eq!(mode, "entropy");
1279        assert!(warning.is_none());
1280    }
1281
1282    #[test]
1283    fn auto_degrade_preserves_auto_when_default_config() {
1284        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1285            return;
1286        }
1287        let (mode, warning) = super::auto_degrade_read_mode("auto");
1288        assert_eq!(mode, "auto");
1289        assert!(warning.is_none());
1290    }
1291
1292    // --- apply_verdict: exhaustive mode × verdict matrix ---
1293
1294    #[test]
1295    fn verdict_warn_does_not_degrade_diff() {
1296        let (mode, degraded) = super::apply_verdict("diff", DegradationVerdictV1::Warn);
1297        assert_eq!(mode, "diff");
1298        assert!(!degraded);
1299    }
1300
1301    #[test]
1302    fn verdict_throttle_does_not_degrade_signatures() {
1303        let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Throttle);
1304        assert_eq!(mode, "signatures");
1305        assert!(!degraded);
1306    }
1307
1308    #[test]
1309    fn verdict_ok_preserves_map() {
1310        let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Ok);
1311        assert_eq!(mode, "map");
1312        assert!(!degraded);
1313    }
1314
1315    #[test]
1316    fn verdict_ok_preserves_signatures() {
1317        let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Ok);
1318        assert_eq!(mode, "signatures");
1319        assert!(!degraded);
1320    }
1321
1322    #[test]
1323    fn verdict_ok_preserves_lines() {
1324        let (mode, degraded) = super::apply_verdict("lines:1-100", DegradationVerdictV1::Ok);
1325        assert_eq!(mode, "lines:1-100");
1326        assert!(!degraded);
1327    }
1328
1329    #[test]
1330    fn verdict_block_degrades_map_to_signatures() {
1331        let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Block);
1332        assert_eq!(mode, "signatures");
1333        assert!(degraded);
1334    }
1335}