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