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 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 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 let aggressiveness =
165 crate::core::aggressiveness::effective(get_f64(args, "aggressiveness"));
166 let protect = get_str_array(args, "protect").unwrap_or_default();
169 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 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 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 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 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 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 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 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 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 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 } };
479
480 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 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 traversal_working_set =
503 crate::core::tool_lifecycle::recent_working_set(&session, path);
504 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 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 {
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 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 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
576 crate::core::heatmap::record_file_access(&path_bg, original, saved);
577
578 {
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 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 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 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
728fn 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
748fn 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
757fn 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 #[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 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 assert!(cache.try_read().is_err());
935
936 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 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 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 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 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 #[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 assert_eq!(
1092 super::resolve_line_window(Some(-5), None, Some(0)),
1093 Some((1, None))
1094 );
1095 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 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 #[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 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 #[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 #[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}