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
14fn 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 source files. mode REQUIRED — choose by intent (see `mode` below).\n\
39 WORKFLOW: after ctx_compose identified relevant files.\n\
40 ANTIPATTERN: not for understanding code — use ctx_compose FIRST (saves tokens).\n\
41 anchored → edit by reference via ctx_patch (no exact-recall).",
42 json!({
43 "type": "object",
44 "properties": {
45 "path": { "type": "string", "description": "Absolute path" },
46 "paths": { "type": "array", "items": { "type": "string" }, "description": "Batch-read many files (replaces ctx_multi_read)" },
47 "mode": {
48 "type": "string",
49 "description": "REQUIRED. full=verbatim(edit-ready) anchored=full+N:hh|anchors(edit via ctx_patch) raw=exact-bytes signatures=API map=structure auto=smart diff=git-delta lines:N-M=window reference=quotes task=focus"
50 },
51 "raw": { "type": "boolean", "description": "Verbatim, no compression (= mode=\"raw\" + fresh)" },
52 "start_line": { "type": "integer", "description": "1-based first line (offset alias)" },
53 "offset": { "type": "integer", "description": "start_line alias" },
54 "limit": { "type": "integer", "description": "Max lines" },
55 "fresh": { "type": "boolean", "description": "Bypass cache, disk re-read" },
56 "aggressiveness": { "type": "number", "description": "0.0(lossless)–1.0(max). Without explicit mode→density; also tunes entropy/task. Omit for defaults" },
57 "protect": { "type": "array", "items": { "type": "string" }, "description": "Symbols/strings force-kept verbatim in entropy/task modes" }
58 },
59 "required": []
60 }),
61 )
62 }
63
64 fn handle(
65 &self,
66 args: &Map<String, Value>,
67 ctx: &ToolContext,
68 ) -> Result<ToolOutput, ErrorData> {
69 if args
72 .get("paths")
73 .and_then(|v| v.as_array())
74 .is_some_and(|a| !a.is_empty())
75 {
76 return super::ctx_multi_read::batch_read(args, ctx);
77 }
78
79 let path = require_resolved_path(ctx, args, "path")?;
80
81 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
82 self.handle_inner(args, ctx, &path)
83 })) {
84 Ok(result) => result,
85 Err(_) => Err(ErrorData::internal_error(
86 format!(
87 "ctx_read panicked while processing '{path}'. This is a bug — please report it."
88 ),
89 None,
90 )),
91 }
92 }
93}
94
95impl CtxReadTool {
96 #[allow(clippy::unused_self)]
97 fn handle_inner(
98 &self,
99 args: &Map<String, Value>,
100 ctx: &ToolContext,
101 path: &str,
102 ) -> Result<ToolOutput, ErrorData> {
103 let session_lock = ctx
104 .session
105 .as_ref()
106 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
107 let cache_lock = ctx
108 .cache
109 .as_ref()
110 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
111
112 let current_task = {
113 let rt = tokio::runtime::Handle::current();
114 let mut attempt = 0u32;
115 loop {
116 if let Ok(session) = rt.block_on(tokio::time::timeout(
117 std::time::Duration::from_secs(5),
118 session_lock.read(),
119 )) {
120 break session.task.as_ref().map(|t| t.description.clone());
121 }
122 attempt += 1;
123 if attempt >= 3 {
124 tracing::warn!(
125 "session read-lock timeout after {attempt} attempts in ctx_read for {path}"
126 );
127 return Err(ErrorData::internal_error(
128 "session lock timeout — another tool may be holding it. Retry in a moment.",
129 None,
130 ));
131 }
132 tracing::debug!(
133 "session read-lock attempt {attempt}/3 timed out for {path}, retrying"
134 );
135 std::thread::sleep(std::time::Duration::from_millis(100 * u64::from(attempt)));
136 }
137 };
138 let task_ref = current_task.as_deref();
139
140 let profile = crate::core::profiles::active_profile();
141 let arg_raw = get_bool(args, "raw").unwrap_or(false);
146 let explicit_mode_arg = resolve_raw_alias(arg_raw, get_str(args, "mode"));
147 let explicit_mode = explicit_mode_arg.is_some();
148 let policy_default_mode = if explicit_mode {
153 None
154 } else {
155 crate::core::policy::runtime::active()
156 .and_then(|p| p.resolved.default_read_mode.clone())
157 };
158 let mut mode = if let Some(m) = explicit_mode_arg {
159 m
160 } else if let Some(pd) = policy_default_mode {
161 pd
162 } else if profile.read.default_mode_effective() == "auto" {
163 if let Ok(cache) = cache_lock.try_read() {
164 crate::tools::ctx_smart_read::select_mode_with_task(&cache, path, task_ref)
165 } else {
166 tracing::debug!(
167 "cache lock contested during auto-mode selection for {path}; \
168 falling back to full"
169 );
170 "full".to_string()
171 }
172 } else {
173 profile.read.default_mode_effective().to_string()
174 };
175 let mut fresh = get_bool(args, "fresh").unwrap_or(false);
176 if arg_raw {
179 fresh = true;
180 }
181 let cache_policy = crate::server::compaction_sync::effective_cache_policy();
182 if cache_policy == "off" {
183 fresh = true;
184 }
185 let aggressiveness =
186 crate::core::aggressiveness::effective(get_f64(args, "aggressiveness"));
187 let protect = get_str_array(args, "protect").unwrap_or_default();
188 if !explicit_mode && let Some(a) = aggressiveness {
192 mode = crate::tools::ctx_read::ReadMode::Density(
195 crate::core::aggressiveness::AggressivenessProfile::from_level(a).density_target,
196 )
197 .to_string();
198 }
199 apply_line_window(
204 &mut mode,
205 &mut fresh,
206 explicit_mode,
207 get_int(args, "start_line"),
208 get_int(args, "offset"),
209 get_int(args, "limit"),
210 );
211
212 let pressure_action = ctx.pressure_snapshot.as_ref().map(|p| &p.recommendation);
213 let resolved_agent_id = ctx.agent_id.as_ref().and_then(|a| match a.try_read() {
214 Ok(guard) => guard.clone(),
215 Err(_) => None,
216 });
217 let gate_result = crate::server::context_gate::pre_dispatch_read_for_agent(
218 path,
219 &mode,
220 task_ref,
221 Some(&ctx.project_root),
222 pressure_action,
223 resolved_agent_id.as_deref(),
224 );
225 if gate_result.budget_blocked {
226 let msg = gate_result
227 .budget_warning
228 .unwrap_or_else(|| "Agent token budget exceeded".to_string());
229 return Err(ErrorData::invalid_params(msg, None));
230 }
231 let budget_warning = gate_result.budget_warning.clone();
232 if mode != "raw"
235 && let Some(overridden) = gate_result.overridden_mode
236 {
237 mode = overridden;
238 }
239
240 let (mut mode, degrade_warning) = if crate::tools::ctx_read::is_instruction_file(path) {
241 ("full".to_string(), None)
242 } else if mode == "raw" {
243 ("raw".to_string(), None)
247 } else {
248 auto_degrade_read_mode(&mode)
249 };
250
251 let mut delta_explicit_note: Option<String> = None;
262 if !fresh
263 && explicit_mode
264 && (mode == "full" || mode.starts_with("lines:"))
265 && crate::core::config::Config::load().delta_explicit_effective()
266 && let Ok(cache) = cache_lock.try_read()
267 {
268 let decision = crate::tools::ctx_read::resolve_explicit_delta_mode(
269 &cache,
270 path,
271 &mode,
272 explicit_mode,
273 fresh,
274 true,
275 );
276 mode = decision.mode;
277 delta_explicit_note = decision.note;
278 }
279
280 if mode.starts_with("lines:") {
281 fresh = true;
282 }
283
284 if crate::core::binary_detect::is_binary_file(path) {
285 let msg = crate::core::binary_detect::binary_file_message(path);
286 return Err(ErrorData::invalid_params(msg, None));
287 }
288 {
289 let cap = crate::core::limits::max_read_bytes() as u64;
290 if let Ok(meta) = std::fs::metadata(path)
291 && meta.len() > cap
292 {
293 let msg = format!(
294 "File too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
295 Use mode=\"lines:1-100\" for partial reads or increase the limit.",
296 meta.len(),
297 cap
298 );
299 return Err(ErrorData::invalid_params(msg, None));
300 }
301 }
302
303 if !fresh
306 && let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir()
307 && let Ok(mut cache) = cache_lock.try_write()
308 {
309 crate::server::compaction_sync::sync_if_compacted(&mut cache, &data_dir);
310 }
311
312 let read_timeout = std::time::Duration::from_secs(30);
316 let cancelled = Arc::new(AtomicBool::new(false));
317 let (output, resolved_mode, original, is_cache_hit, file_ref, cache_stats) = {
318 let crp_mode = ctx.crp_mode;
319 let task_ref = current_task.as_deref();
320
321 let fast_result = 'fast: {
322 let file_lock = per_file_lock(path);
323 let Some(_file_guard) = file_lock.try_lock().ok() else {
324 break 'fast None;
325 };
326
327 if !fresh
338 && (mode == "full" || mode == "auto")
339 && let Ok(cache) = cache_lock.try_read()
340 && let Some(read_output) =
341 crate::tools::ctx_read::try_stub_hit_readonly(&cache, path)
342 {
343 let content = read_output.content;
344 let rmode = read_output.resolved_mode;
345 let orig = cache.get(path).map_or(0, |e| e.original_tokens);
346 let hit = content.contains(" cached ")
347 || content.contains("[unchanged")
348 || content.contains("[delta:");
349 let fref = cache.file_ref_map().get(path).cloned();
350 let stats = cache.get_stats();
351 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
352 break 'fast Some((content, rmode, orig, hit, fref, stats_snapshot));
353 }
354
355 let Some(mut cache) = cache_lock.try_write().ok() else {
358 break 'fast None;
359 };
360 let read_output = if fresh {
361 crate::tools::ctx_read::handle_fresh_with_task_resolved_tuned(
362 &mut cache,
363 path,
364 &mode,
365 crp_mode,
366 task_ref,
367 aggressiveness,
368 &protect,
369 )
370 } else {
371 crate::tools::ctx_read::handle_with_task_resolved_tuned(
372 &mut cache,
373 path,
374 &mode,
375 crp_mode,
376 task_ref,
377 aggressiveness,
378 &protect,
379 )
380 };
381 let content = read_output.content;
382 let rmode = read_output.resolved_mode;
383 let orig = cache.get(path).map_or(0, |e| e.original_tokens);
384 let hit = content.contains(" cached ")
385 || content.contains("[unchanged")
386 || content.contains("[delta:");
387 let fref = cache.file_ref_map().get(path).cloned();
388 let stats = cache.get_stats();
389 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
390 Some((content, rmode, orig, hit, fref, stats_snapshot))
391 };
392
393 if let Some(result) = fast_result {
394 result
395 } else {
396 let cache_lock = cache_lock.clone();
397 let mode = mode.clone();
398 let task_owned = current_task.clone();
399 let protect_owned = protect.clone();
400 let path_owned = path.to_string();
401 let cancel_flag = cancelled.clone();
402 let (tx, rx) = std::sync::mpsc::sync_channel(1);
403 std::thread::spawn(move || {
404 let file_lock = per_file_lock(&path_owned);
405
406 let _file_guard = {
409 let deadline =
410 std::time::Instant::now() + std::time::Duration::from_secs(25);
411 loop {
412 if cancel_flag.load(Ordering::Relaxed) {
413 return;
414 }
415 if let Ok(guard) = file_lock.try_lock() {
416 break guard;
417 }
418 if std::time::Instant::now() >= deadline {
419 tracing::error!(
420 "ctx_read: per-file lock timeout after 25s for {path_owned}"
421 );
422 let _ = tx.send((
423 format!("per-file lock contention for {path_owned} — retry in a moment"),
424 "error".to_string(), 0, false, None, (0, 0),
425 ));
426 return;
427 }
428 std::thread::sleep(std::time::Duration::from_millis(50));
429 }
430 };
431
432 if cancel_flag.load(Ordering::Relaxed) {
433 return;
434 }
435
436 let mut cache = {
439 let deadline =
440 std::time::Instant::now() + std::time::Duration::from_secs(25);
441 loop {
442 if cancel_flag.load(Ordering::Relaxed) {
443 return;
444 }
445 if let Ok(guard) = cache_lock.try_write() {
446 break guard;
447 }
448 if std::time::Instant::now() >= deadline {
449 tracing::error!(
450 "ctx_read: cache write-lock timeout after 25s for {path_owned}"
451 );
452 let _ = tx.send((
453 format!(
454 "cache lock contention for {path_owned} — retry in a moment"
455 ),
456 "error".to_string(),
457 0,
458 false,
459 None,
460 (0, 0),
461 ));
462 return;
463 }
464 std::thread::sleep(std::time::Duration::from_millis(50));
465 }
466 };
467
468 let task_ref = task_owned.as_deref();
469 let read_output = if fresh {
470 crate::tools::ctx_read::handle_fresh_with_task_resolved_tuned(
471 &mut cache,
472 &path_owned,
473 &mode,
474 crp_mode,
475 task_ref,
476 aggressiveness,
477 &protect_owned,
478 )
479 } else {
480 crate::tools::ctx_read::handle_with_task_resolved_tuned(
481 &mut cache,
482 &path_owned,
483 &mode,
484 crp_mode,
485 task_ref,
486 aggressiveness,
487 &protect_owned,
488 )
489 };
490 let content = read_output.content;
491 let rmode = read_output.resolved_mode;
492 let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
493 let hit = content.contains(" cached ");
494 let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
495 let stats = cache.get_stats();
496 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
497 let _ = tx.send((content, rmode, orig, hit, fref, stats_snapshot));
498 });
499 if let Ok(result) = rx.recv_timeout(read_timeout) {
500 result
501 } else {
502 cancelled.store(true, Ordering::Relaxed);
503 tracing::error!("ctx_read timed out after {read_timeout:?} for {path}");
504 let msg = format!(
505 "ERROR: ctx_read timed out after {}s reading {path}. \
506 The file may be very large or a blocking I/O issue occurred. \
507 Try mode=\"lines:1-100\" for a partial read.",
508 read_timeout.as_secs()
509 );
510 return Err(ErrorData::internal_error(msg, None));
511 }
512 } };
514
515 if resolved_mode == "error" {
516 return Err(ErrorData::invalid_params(output, None));
517 }
518
519 let output_tokens = crate::core::tokens::count_tokens(&output);
520 let saved = original.saturating_sub(output_tokens);
521
522 let mut ensured_root: Option<String> = None;
524 let mut traversal_working_set: Vec<String> = Vec::new();
525 let project_root_snapshot;
526 {
527 let rt = tokio::runtime::Handle::current();
528 let session_guard = rt.block_on(tokio::time::timeout(
529 std::time::Duration::from_secs(10),
530 session_lock.write(),
531 ));
532 if let Ok(mut session) = session_guard {
533 session.touch_file(path, file_ref.as_deref(), &resolved_mode, original);
534 traversal_working_set =
537 crate::core::tool_lifecycle::recent_working_set(&session, path);
538 let file_summary = extract_file_summary(&output, path);
539 if !file_summary.is_empty() {
540 session.set_file_summary(path, &file_summary);
541 }
542 if is_cache_hit {
543 session.record_cache_hit();
544 }
545 if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
546 let touched: Vec<String> = session
547 .files_touched
548 .iter()
549 .map(|f| f.path.clone())
550 .collect();
551 let inferred =
552 crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
553 if inferred.confidence >= 0.4 {
554 session.active_structured_intent = Some(inferred);
555 }
556 }
557 if session.task.is_none() && session.stats.files_read % 5 == 0 {
558 session.auto_infer_task();
559 }
560 let root_missing = session
561 .project_root
562 .as_deref()
563 .is_none_or(|r| r.trim().is_empty());
564 if root_missing && let Some(root) = crate::core::protocol::detect_project_root(path)
565 {
566 session.project_root = Some(root.clone());
567 ensured_root = Some(root);
568 }
569 project_root_snapshot = session
570 .project_root
571 .clone()
572 .unwrap_or_else(|| ".".to_string());
573 } else {
574 tracing::warn!(
575 "session write-lock timeout (5s) in ctx_read post-update for {path}"
576 );
577 project_root_snapshot = ctx.project_root.clone();
578 }
579 }
580
581 if let Some(root) = ensured_root.as_deref() {
582 crate::core::index_orchestrator::ensure_all_background(root);
583 }
584
585 {
591 let path_bg = path.to_string();
592 let resolved_mode_bg = resolved_mode.clone();
593 let project_root_bg = project_root_snapshot.clone();
594 let (turns, hits) = cache_stats;
595 let ledger_cache = (crate::core::savings_ledger::ledger_family()
601 != crate::core::tokens::TokenizerFamily::O200kBase)
602 .then(|| cache_lock.clone());
603 let ledger_output = ledger_cache.as_ref().map(|_| output.clone());
604 std::thread::spawn(move || {
605 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
608 crate::core::heatmap::record_file_access(&path_bg, original, saved);
609
610 {
615 use crate::core::savings_ledger as ledger;
616 let (lbase, lsaved) = match (&ledger_cache, &ledger_output) {
617 (Some(cl), Some(out)) => match cl.try_read().ok().and_then(|c| {
618 c.get(&path_bg)
619 .and_then(crate::core::cache::CacheEntry::content)
620 }) {
621 Some(raw) => {
622 let lo = ledger::count_for_ledger(&raw);
623 (lo, lo.saturating_sub(ledger::count_for_ledger(out)))
624 }
625 None => (original, saved),
626 },
627 _ => (original, saved),
628 };
629 ledger::record_read_event(lbase, lsaved);
630 }
631
632 if let Some(root) =
635 crate::core::tool_lifecycle::usable_root(Some(project_root_bg.as_str()))
636 {
637 crate::core::cooccurrence::record_focus_access(
638 root,
639 &path_bg,
640 &traversal_working_set,
641 );
642 }
643
644 let sig =
645 crate::core::mode_predictor::FileSignature::from_path(&path_bg, original);
646 let density = if output_tokens > 0 {
647 original as f64 / output_tokens as f64
648 } else {
649 1.0
650 };
651 let outcome = crate::core::mode_predictor::ModeOutcome {
652 mode: resolved_mode_bg,
653 tokens_in: original,
654 tokens_out: output_tokens,
655 density: density.min(1.0),
656 };
657 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
658 predictor.set_project_root(&project_root_bg);
659 predictor.record(sig, outcome);
660 predictor.save();
661
662 let ext = std::path::Path::new(&path_bg)
663 .extension()
664 .and_then(|e| e.to_str())
665 .unwrap_or("")
666 .to_string();
667 let thresholds =
668 crate::core::adaptive_thresholds::thresholds_for_path(&path_bg);
669 let feedback_outcome = crate::core::feedback::CompressionOutcome {
670 session_id: format!("{}", std::process::id()),
671 language: ext,
672 entropy_threshold: thresholds.bpe_entropy,
673 jaccard_threshold: thresholds.jaccard,
674 total_turns: turns as u32,
675 tokens_saved: saved as u64,
676 tokens_original: original as u64,
677 cache_hits: hits as u32,
678 total_reads: turns as u32,
679 task_completed: crate::core::bounce_tracker::global()
687 .lock()
688 .ok()
689 .and_then(|bt| bt.bounce_rate_for_extension(&path_bg))
690 .is_none_or(|rate| rate < 0.30),
691 timestamp: chrono::Local::now().to_rfc3339(),
692 };
693 let mut store = crate::core::feedback::FeedbackStore::load();
694 store.project_root = Some(project_root_bg);
695 store.record_outcome(feedback_outcome);
696 }));
697 });
698 }
699
700 if let Some(aid) = resolved_agent_id.as_deref() {
701 crate::core::agent_budget::record_consumption(aid, output_tokens);
702 }
703
704 let hints_suffix = {
709 let graph_db =
710 crate::core::property_graph::graph_dir(&ctx.project_root).join("graph.db");
711 let edges = if graph_db.exists() {
712 crate::core::property_graph::CodeGraph::open(&ctx.project_root)
713 .map(|g| g.all_cross_source_edges())
714 .unwrap_or_default()
715 } else {
716 Vec::new()
717 };
718 if edges.is_empty() {
719 String::new()
720 } else {
721 let hints = crate::core::cross_source_hints::hints_for_file(
722 path,
723 &edges,
724 &ctx.project_root,
725 );
726 crate::core::cross_source_hints::format_hints(&hints)
727 }
728 };
729
730 let mut warnings = Vec::new();
731 if let Some(ref w) = budget_warning {
732 warnings.push(w.as_str());
733 }
734 if let Some(ref w) = degrade_warning {
735 warnings.push(w.as_str());
736 }
737 if let Some(ref w) = delta_explicit_note {
738 warnings.push(w.as_str());
739 }
740 let final_output = if !warnings.is_empty() {
741 format!("{output}{hints_suffix}\n\n{}", warnings.join("\n"))
742 } else if hints_suffix.is_empty() {
743 output
744 } else {
745 format!("{output}{hints_suffix}")
746 };
747
748 Ok(ToolOutput {
749 text: final_output,
750 original_tokens: original,
751 saved_tokens: saved,
752 mode: Some(resolved_mode),
753 path: Some(path.to_string()),
754 changed: false,
755 shell_outcome: None,
756 })
757 }
758}
759
760fn resolve_line_window(
767 start_line: Option<i64>,
768 offset: Option<i64>,
769 limit: Option<i64>,
770) -> Option<(i64, Option<i64>)> {
771 let start = start_line.or(offset).map(|v| v.max(1));
772 let limit = limit.filter(|&l| l > 0);
773 match (start, limit) {
774 (Some(s), l) => Some((s, l)),
775 (None, Some(_)) => Some((1, limit)),
776 (None, None) => None,
777 }
778}
779
780fn lines_mode(start: i64, limit: Option<i64>) -> String {
783 match limit {
784 Some(l) => format!("lines:{start}-{}", start + l - 1),
785 None => format!("lines:{start}-999999"),
786 }
787}
788
789fn apply_line_window(
793 mode: &mut String,
794 fresh: &mut bool,
795 explicit_mode: bool,
796 start_line: Option<i64>,
797 offset: Option<i64>,
798 limit: Option<i64>,
799) {
800 let Some((start, limit)) = resolve_line_window(start_line, offset, limit) else {
801 return;
802 };
803 if start <= 1 && limit.is_none() {
804 return;
805 }
806 *fresh = true;
807 if !explicit_mode || mode.starts_with("lines") {
808 *mode = lines_mode(start, limit);
809 }
810}
811
812fn resolve_raw_alias(arg_raw: bool, mode_arg: Option<String>) -> Option<String> {
819 if arg_raw {
820 Some("raw".to_string())
821 } else {
822 mode_arg
823 }
824}
825
826fn apply_verdict(
827 mode: &str,
828 verdict: crate::core::degradation_policy::DegradationVerdictV1,
829) -> (String, bool) {
830 use crate::core::degradation_policy::DegradationVerdictV1;
831 match verdict {
832 DegradationVerdictV1::Ok => (mode.to_string(), false),
833 DegradationVerdictV1::Warn => match mode {
834 "full" => ("map".to_string(), true),
835 other => (other.to_string(), false),
836 },
837 DegradationVerdictV1::Throttle => match mode {
838 "full" | "map" => ("signatures".to_string(), true),
839 other => (other.to_string(), false),
840 },
841 DegradationVerdictV1::Block => {
842 if mode == "signatures" {
843 ("signatures".to_string(), false)
844 } else {
845 ("signatures".to_string(), true)
846 }
847 }
848 }
849}
850
851fn auto_degrade_read_mode(mode: &str) -> (String, Option<String>) {
852 if crate::core::config::Config::load().no_degrade_effective() {
853 return (mode.to_string(), None);
854 }
855 let profile = crate::core::profiles::active_profile();
856 if !profile.degradation.enforce_effective() {
857 return (mode.to_string(), None);
858 }
859 let policy = crate::core::degradation_policy::evaluate_v1_for_tool("ctx_read", None);
860 let (new_mode, degraded) = apply_verdict(mode, policy.decision.verdict);
861 let warning = if degraded {
862 Some(format!(
863 "⚠ Context pressure: mode={mode} was downgraded to mode={new_mode} \
864 (verdict: {:?}). Use start_line=1 to bypass, or run ctx_compress to free budget.",
865 policy.decision.verdict
866 ))
867 } else {
868 None
869 };
870 (new_mode, warning)
871}
872
873fn extract_file_summary(output: &str, path: &str) -> String {
874 let hint = crate::core::auto_findings::extract_content_hint(output);
875 if !hint.is_empty() {
876 return hint;
877 }
878 let ext = std::path::Path::new(path)
879 .extension()
880 .and_then(|e| e.to_str())
881 .unwrap_or("");
882 let line_count = output.lines().count();
883 if line_count > 5 {
884 format!("{ext} file, {line_count} lines")
885 } else {
886 String::new()
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893 use std::sync::atomic::{AtomicUsize, Ordering};
894
895 #[test]
896 fn raw_alias_forces_raw_mode_over_explicit_mode() {
897 assert_eq!(
900 resolve_raw_alias(true, Some("signatures".to_string())),
901 Some("raw".to_string())
902 );
903 assert_eq!(resolve_raw_alias(true, None), Some("raw".to_string()));
904 }
905
906 #[test]
907 fn raw_alias_absent_passes_mode_through() {
908 assert_eq!(
911 resolve_raw_alias(false, Some("full".to_string())),
912 Some("full".to_string())
913 );
914 assert_eq!(resolve_raw_alias(false, None), None);
915 }
916
917 #[test]
918 fn per_file_lock_same_path_returns_same_mutex() {
919 let lock_a1 = per_file_lock("/tmp/test_same_path.txt");
920 let lock_a2 = per_file_lock("/tmp/test_same_path.txt");
921 assert!(Arc::ptr_eq(&lock_a1, &lock_a2));
922 }
923
924 #[test]
925 fn per_file_lock_different_paths_return_different_mutexes() {
926 let lock_a = per_file_lock("/tmp/test_path_a.txt");
927 let lock_b = per_file_lock("/tmp/test_path_b.txt");
928 assert!(!Arc::ptr_eq(&lock_a, &lock_b));
929 }
930
931 #[test]
932 fn per_file_lock_serializes_concurrent_access() {
933 let counter = Arc::new(AtomicUsize::new(0));
934 let max_concurrent = Arc::new(AtomicUsize::new(0));
935 let path = "/tmp/test_concurrent_serialization.txt";
936 let mut handles = Vec::new();
937
938 for _ in 0..5 {
939 let counter = counter.clone();
940 let max_concurrent = max_concurrent.clone();
941 let path = path.to_string();
942 handles.push(std::thread::spawn(move || {
943 let lock = per_file_lock(&path);
944 let _guard = lock.lock().unwrap();
945 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
946 max_concurrent.fetch_max(active, Ordering::SeqCst);
947 std::thread::sleep(std::time::Duration::from_millis(10));
948 counter.fetch_sub(1, Ordering::SeqCst);
949 }));
950 }
951
952 for h in handles {
953 h.join().unwrap();
954 }
955
956 assert_eq!(max_concurrent.load(Ordering::SeqCst), 1);
957 }
958
959 #[test]
960 fn per_file_lock_allows_parallel_different_paths() {
961 let counter = Arc::new(AtomicUsize::new(0));
962 let max_concurrent = Arc::new(AtomicUsize::new(0));
963 let mut handles = Vec::new();
964
965 for i in 0..4 {
966 let counter = counter.clone();
967 let max_concurrent = max_concurrent.clone();
968 let path = format!("/tmp/test_parallel_{i}.txt");
969 handles.push(std::thread::spawn(move || {
970 let lock = per_file_lock(&path);
971 let _guard = lock.lock().unwrap();
972 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
973 max_concurrent.fetch_max(active, Ordering::SeqCst);
974 std::thread::sleep(std::time::Duration::from_millis(50));
975 counter.fetch_sub(1, Ordering::SeqCst);
976 }));
977 }
978
979 for h in handles {
980 h.join().unwrap();
981 }
982
983 assert!(max_concurrent.load(Ordering::SeqCst) > 1);
984 }
985
986 #[test]
990 fn zombie_thread_does_not_block_subsequent_cache_access() {
991 let cache: Arc<tokio::sync::RwLock<u32>> = Arc::new(tokio::sync::RwLock::new(0));
992
993 let zombie_lock = cache.clone();
995 let _zombie = std::thread::spawn(move || {
996 let _guard = zombie_lock.blocking_write();
997 std::thread::sleep(std::time::Duration::from_secs(2));
998 });
999 std::thread::sleep(std::time::Duration::from_millis(50));
1000
1001 assert!(cache.try_read().is_err());
1003
1004 let cancel = Arc::new(AtomicBool::new(false));
1006 let cancel2 = cancel.clone();
1007 let lock2 = cache.clone();
1008 let waiter = std::thread::spawn(move || {
1009 let start = std::time::Instant::now();
1010 loop {
1011 if cancel2.load(Ordering::Relaxed) {
1012 return (false, start.elapsed());
1013 }
1014 if let Ok(_guard) = lock2.try_write() {
1015 return (true, start.elapsed());
1016 }
1017 std::thread::sleep(std::time::Duration::from_millis(50));
1018 }
1019 });
1020
1021 std::thread::sleep(std::time::Duration::from_millis(200));
1023 cancel.store(true, Ordering::Relaxed);
1024
1025 let (acquired, elapsed) = waiter.join().unwrap();
1026 assert!(
1027 !acquired,
1028 "should not have acquired lock while zombie holds it"
1029 );
1030 assert!(
1031 elapsed < std::time::Duration::from_secs(1),
1032 "cancellation should have stopped the loop promptly"
1033 );
1034 }
1035
1036 fn apply_start_line(
1040 mode: &mut String,
1041 fresh: &mut bool,
1042 explicit_mode: bool,
1043 start_line: Option<i64>,
1044 ) {
1045 super::apply_line_window(mode, fresh, explicit_mode, start_line, None, None);
1046 }
1047
1048 #[test]
1049 fn start_line_1_does_not_override_mode() {
1050 let mut mode = "auto".to_string();
1051 let mut fresh = false;
1052 apply_start_line(&mut mode, &mut fresh, false, Some(1));
1053 assert_eq!(mode, "auto", "start_line=1 should not change mode");
1054 assert!(!fresh, "start_line=1 should not force fresh=true");
1055 }
1056
1057 #[test]
1058 fn start_line_gt1_overrides_implicit_mode() {
1059 let mut mode = "auto".to_string();
1060 let mut fresh = false;
1061 apply_start_line(&mut mode, &mut fresh, false, Some(50));
1062 assert_eq!(mode, "lines:50-999999");
1063 assert!(fresh);
1064 }
1065
1066 #[test]
1067 fn start_line_gt1_does_not_override_explicit_map() {
1068 let mut mode = "map".to_string();
1070 let mut fresh = false;
1071 apply_start_line(&mut mode, &mut fresh, true, Some(50));
1072 assert_eq!(
1073 mode, "map",
1074 "explicit mode=map must not be clobbered by start_line"
1075 );
1076 assert!(fresh, "start_line>1 should still force fresh");
1077 }
1078
1079 #[test]
1080 fn start_line_gt1_does_not_override_explicit_signatures() {
1081 let mut mode = "signatures".to_string();
1082 let mut fresh = false;
1083 apply_start_line(&mut mode, &mut fresh, true, Some(100));
1084 assert_eq!(mode, "signatures");
1085 assert!(fresh);
1086 }
1087
1088 #[test]
1089 fn start_line_gt1_honors_explicit_lines_mode() {
1090 let mut mode = "lines:1-50".to_string();
1091 let mut fresh = false;
1092 apply_start_line(&mut mode, &mut fresh, true, Some(30));
1093 assert_eq!(
1094 mode, "lines:30-999999",
1095 "explicit lines mode should accept start_line override"
1096 );
1097 assert!(fresh);
1098 }
1099
1100 #[test]
1101 fn start_line_none_does_nothing() {
1102 let mut mode = "map".to_string();
1103 let mut fresh = false;
1104 apply_start_line(&mut mode, &mut fresh, true, None);
1105 assert_eq!(mode, "map");
1106 assert!(!fresh);
1107 }
1108
1109 #[test]
1110 fn start_line_1_with_explicit_mode_preserves_it() {
1111 let mut mode = "map".to_string();
1113 let mut fresh = false;
1114 apply_start_line(&mut mode, &mut fresh, true, Some(1));
1115 assert_eq!(mode, "map");
1116 assert!(!fresh);
1117 }
1118
1119 #[test]
1122 fn offset_is_alias_for_start_line() {
1123 let mut mode = "auto".to_string();
1124 let mut fresh = false;
1125 super::apply_line_window(&mut mode, &mut fresh, false, None, Some(40), None);
1126 assert_eq!(mode, "lines:40-999999");
1127 assert!(fresh);
1128 }
1129
1130 #[test]
1131 fn offset_and_limit_make_bounded_window() {
1132 let mut mode = "auto".to_string();
1133 let mut fresh = false;
1134 super::apply_line_window(&mut mode, &mut fresh, false, None, Some(40), Some(20));
1135 assert_eq!(mode, "lines:40-59", "20 inclusive lines starting at 40");
1136 assert!(fresh);
1137 }
1138
1139 #[test]
1140 fn limit_alone_reads_from_first_line() {
1141 let mut mode = "auto".to_string();
1142 let mut fresh = false;
1143 super::apply_line_window(&mut mode, &mut fresh, false, None, None, Some(25));
1144 assert_eq!(mode, "lines:1-25");
1145 assert!(fresh);
1146 }
1147
1148 #[test]
1149 fn start_line_wins_over_offset_when_both_present() {
1150 assert_eq!(
1151 super::resolve_line_window(Some(10), Some(99), None),
1152 Some((10, None))
1153 );
1154 }
1155
1156 #[test]
1157 fn resolve_clamps_start_and_drops_nonpositive_limit() {
1158 assert_eq!(
1160 super::resolve_line_window(Some(-5), None, Some(0)),
1161 Some((1, None))
1162 );
1163 assert_eq!(super::resolve_line_window(None, None, Some(-3)), None);
1165 assert_eq!(super::resolve_line_window(None, None, None), None);
1166 }
1167
1168 #[test]
1169 fn lines_mode_bounds_are_inclusive() {
1170 assert_eq!(super::lines_mode(40, Some(20)), "lines:40-59");
1171 assert_eq!(super::lines_mode(5, None), "lines:5-999999");
1172 }
1173
1174 #[test]
1175 fn explicit_map_not_clobbered_by_offset_limit() {
1176 let mut mode = "map".to_string();
1178 let mut fresh = false;
1179 super::apply_line_window(&mut mode, &mut fresh, true, None, Some(40), Some(20));
1180 assert_eq!(mode, "map", "explicit mode wins over offset/limit");
1181 assert!(fresh);
1182 }
1183
1184 #[test]
1189 fn schema_advertises_line_window_aliases() {
1190 let tool = CtxReadTool.tool_def();
1191 let props = tool
1192 .input_schema
1193 .get("properties")
1194 .and_then(|p| p.as_object())
1195 .expect("ctx_read schema has a properties object");
1196 for key in ["path", "mode", "start_line", "offset", "limit", "fresh"] {
1197 assert!(props.contains_key(key), "ctx_read schema missing '{key}'");
1198 }
1199 }
1200
1201 use crate::core::degradation_policy::DegradationVerdictV1;
1205
1206 #[test]
1207 fn verdict_ok_does_not_degrade() {
1208 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Ok);
1209 assert_eq!(mode, "full");
1210 assert!(!degraded);
1211 }
1212
1213 #[test]
1214 fn verdict_warn_degrades_full_to_map() {
1215 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
1216 assert_eq!(mode, "map");
1217 assert!(degraded, "full→map must be flagged as degraded");
1218 }
1219
1220 #[test]
1221 fn verdict_warn_keeps_map() {
1222 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Warn);
1223 assert_eq!(mode, "map");
1224 assert!(!degraded, "map is not degraded under Warn");
1225 }
1226
1227 #[test]
1228 fn verdict_warn_keeps_signatures() {
1229 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Warn);
1230 assert_eq!(mode, "signatures");
1231 assert!(!degraded);
1232 }
1233
1234 #[test]
1235 fn verdict_throttle_degrades_full_to_signatures() {
1236 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Throttle);
1237 assert_eq!(mode, "signatures");
1238 assert!(degraded);
1239 }
1240
1241 #[test]
1242 fn verdict_throttle_degrades_map_to_signatures() {
1243 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Throttle);
1244 assert_eq!(mode, "signatures");
1245 assert!(degraded);
1246 }
1247
1248 #[test]
1249 fn verdict_throttle_keeps_lines() {
1250 let (mode, degraded) = super::apply_verdict("lines:1-50", DegradationVerdictV1::Throttle);
1251 assert_eq!(mode, "lines:1-50");
1252 assert!(!degraded, "lines mode bypasses degradation");
1253 }
1254
1255 #[test]
1256 fn verdict_block_degrades_full_to_signatures() {
1257 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Block);
1258 assert_eq!(mode, "signatures");
1259 assert!(degraded);
1260 }
1261
1262 #[test]
1263 fn verdict_block_does_not_degrade_signatures() {
1264 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Block);
1265 assert_eq!(mode, "signatures");
1266 assert!(!degraded, "already at signatures — no degradation needed");
1267 }
1268
1269 #[test]
1270 fn degrade_warning_message_contains_mode_info() {
1271 let (new_mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
1272 assert!(degraded);
1273 let warning = format!(
1274 "⚠ Context pressure: mode=full was downgraded to mode={new_mode} (verdict: {:?}).",
1275 DegradationVerdictV1::Warn
1276 );
1277 assert!(warning.contains("mode=full"));
1278 assert!(warning.contains("mode=map"));
1279 assert!(warning.contains("Warn"));
1280 }
1281
1282 #[test]
1287 fn auto_degrade_preserves_full_when_default_config() {
1288 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1289 return;
1290 }
1291 let (mode, warning) = super::auto_degrade_read_mode("full");
1292 assert_eq!(mode, "full");
1293 assert!(warning.is_none());
1294 }
1295
1296 #[test]
1297 fn auto_degrade_preserves_map_when_default_config() {
1298 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1299 return;
1300 }
1301 let (mode, warning) = super::auto_degrade_read_mode("map");
1302 assert_eq!(mode, "map");
1303 assert!(warning.is_none());
1304 }
1305
1306 #[test]
1307 fn auto_degrade_preserves_signatures_when_default_config() {
1308 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1309 return;
1310 }
1311 let (mode, warning) = super::auto_degrade_read_mode("signatures");
1312 assert_eq!(mode, "signatures");
1313 assert!(warning.is_none());
1314 }
1315
1316 #[test]
1317 fn auto_degrade_preserves_diff_always() {
1318 let (mode, warning) = super::auto_degrade_read_mode("diff");
1319 assert_eq!(mode, "diff");
1320 assert!(warning.is_none());
1321 }
1322
1323 #[test]
1324 fn auto_degrade_preserves_lines_mode_always() {
1325 let (mode, warning) = super::auto_degrade_read_mode("lines:10-50");
1326 assert_eq!(mode, "lines:10-50");
1327 assert!(warning.is_none());
1328 }
1329
1330 #[test]
1331 fn auto_degrade_preserves_aggressive_when_default_config() {
1332 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1333 return;
1334 }
1335 let (mode, warning) = super::auto_degrade_read_mode("aggressive");
1336 assert_eq!(mode, "aggressive");
1337 assert!(warning.is_none());
1338 }
1339
1340 #[test]
1341 fn auto_degrade_preserves_entropy_when_default_config() {
1342 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1343 return;
1344 }
1345 let (mode, warning) = super::auto_degrade_read_mode("entropy");
1346 assert_eq!(mode, "entropy");
1347 assert!(warning.is_none());
1348 }
1349
1350 #[test]
1351 fn auto_degrade_preserves_auto_when_default_config() {
1352 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1353 return;
1354 }
1355 let (mode, warning) = super::auto_degrade_read_mode("auto");
1356 assert_eq!(mode, "auto");
1357 assert!(warning.is_none());
1358 }
1359
1360 #[test]
1363 fn verdict_warn_does_not_degrade_diff() {
1364 let (mode, degraded) = super::apply_verdict("diff", DegradationVerdictV1::Warn);
1365 assert_eq!(mode, "diff");
1366 assert!(!degraded);
1367 }
1368
1369 #[test]
1370 fn verdict_throttle_does_not_degrade_signatures() {
1371 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Throttle);
1372 assert_eq!(mode, "signatures");
1373 assert!(!degraded);
1374 }
1375
1376 #[test]
1377 fn verdict_ok_preserves_map() {
1378 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Ok);
1379 assert_eq!(mode, "map");
1380 assert!(!degraded);
1381 }
1382
1383 #[test]
1384 fn verdict_ok_preserves_signatures() {
1385 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Ok);
1386 assert_eq!(mode, "signatures");
1387 assert!(!degraded);
1388 }
1389
1390 #[test]
1391 fn verdict_ok_preserves_lines() {
1392 let (mode, degraded) = super::apply_verdict("lines:1-100", DegradationVerdictV1::Ok);
1393 assert_eq!(mode, "lines:1-100");
1394 assert!(!degraded);
1395 }
1396
1397 #[test]
1398 fn verdict_block_degrades_map_to_signatures() {
1399 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Block);
1400 assert_eq!(mode, "signatures");
1401 assert!(degraded);
1402 }
1403}