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