1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Mutex};
3
4use rmcp::model::Tool;
5use rmcp::ErrorData;
6use serde_json::{json, Map, Value};
7
8use crate::server::tool_trait::{
9 get_bool, get_int, get_str, require_resolved_path, McpTool, ToolContext, ToolOutput,
10};
11use crate::tool_defs::tool_def;
12
13fn per_file_lock(path: &str) -> Arc<Mutex<()>> {
24 crate::core::path_locks::per_file_lock(path)
25}
26
27pub struct CtxReadTool;
28
29impl McpTool for CtxReadTool {
30 fn name(&self) -> &'static str {
31 "ctx_read"
32 }
33
34 fn tool_def(&self) -> Tool {
35 tool_def(
36 "ctx_read",
37 "Read a file. Prefer over native Read/cat/head/tail (cached, compressed).\n\
38 Unchanged re-reads cost ~13 tokens. Auto-selects mode. fresh=true forces a disk re-read.",
39 json!({
40 "type": "object",
41 "properties": {
42 "path": { "type": "string", "description": "Absolute file path" },
43 "mode": {
44 "type": "string",
45 "description": "auto (default)|full|raw (no header/footer)|map|signatures|diff|task|reference|aggressive|entropy|lines:N-M|density:0.X"
46 },
47 "start_line": { "type": "integer", "description": "Read from this line on" },
48 "fresh": { "type": "boolean", "description": "Bypass cache, force disk re-read" }
49 },
50 "required": ["path"]
51 }),
52 )
53 }
54
55 fn handle(
56 &self,
57 args: &Map<String, Value>,
58 ctx: &ToolContext,
59 ) -> Result<ToolOutput, ErrorData> {
60 let path = require_resolved_path(ctx, args, "path")?;
61
62 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
63 self.handle_inner(args, ctx, &path)
64 })) {
65 Ok(result) => result,
66 Err(_) => Err(ErrorData::internal_error(
67 format!("ctx_read panicked while processing '{path}'. This is a bug — please report it."),
68 None,
69 )),
70 }
71 }
72}
73
74impl CtxReadTool {
75 #[allow(clippy::unused_self)]
76 fn handle_inner(
77 &self,
78 args: &Map<String, Value>,
79 ctx: &ToolContext,
80 path: &str,
81 ) -> Result<ToolOutput, ErrorData> {
82 let session_lock = ctx
83 .session
84 .as_ref()
85 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
86 let cache_lock = ctx
87 .cache
88 .as_ref()
89 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
90
91 let current_task = {
92 let rt = tokio::runtime::Handle::current();
93 let mut attempt = 0u32;
94 loop {
95 if let Ok(session) = rt.block_on(tokio::time::timeout(
96 std::time::Duration::from_secs(5),
97 session_lock.read(),
98 )) {
99 break session.task.as_ref().map(|t| t.description.clone());
100 }
101 attempt += 1;
102 if attempt >= 3 {
103 tracing::warn!(
104 "session read-lock timeout after {attempt} attempts in ctx_read for {path}"
105 );
106 return Err(ErrorData::internal_error(
107 "session lock timeout — another tool may be holding it. Retry in a moment.",
108 None,
109 ));
110 }
111 tracing::debug!(
112 "session read-lock attempt {attempt}/3 timed out for {path}, retrying"
113 );
114 std::thread::sleep(std::time::Duration::from_millis(100 * u64::from(attempt)));
115 }
116 };
117 let task_ref = current_task.as_deref();
118
119 let profile = crate::core::profiles::active_profile();
120 let explicit_mode_arg = get_str(args, "mode");
121 let explicit_mode = explicit_mode_arg.is_some();
122 let mut mode = if let Some(m) = explicit_mode_arg {
123 m
124 } else if profile.read.default_mode_effective() == "auto" {
125 if let Ok(cache) = cache_lock.try_read() {
126 crate::tools::ctx_smart_read::select_mode_with_task(&cache, path, task_ref)
127 } else {
128 tracing::debug!(
129 "cache lock contested during auto-mode selection for {path}; \
130 falling back to full"
131 );
132 "full".to_string()
133 }
134 } else {
135 profile.read.default_mode_effective().to_string()
136 };
137 let mut fresh = get_bool(args, "fresh").unwrap_or(false);
138 let cache_policy = crate::server::compaction_sync::effective_cache_policy();
139 if cache_policy == "off" {
140 fresh = true;
141 }
142 let start_line = get_int(args, "start_line");
143 if let Some(sl) = start_line {
144 let sl = sl.max(1_i64);
145 if sl > 1 {
146 fresh = true;
147 if !explicit_mode || mode.starts_with("lines") {
152 mode = format!("lines:{sl}-999999");
153 }
154 }
155 }
156
157 let pressure_action = ctx.pressure_snapshot.as_ref().map(|p| &p.recommendation);
158 let resolved_agent_id = ctx.agent_id.as_ref().and_then(|a| match a.try_read() {
159 Ok(guard) => guard.clone(),
160 Err(_) => None,
161 });
162 let gate_result = crate::server::context_gate::pre_dispatch_read_for_agent(
163 path,
164 &mode,
165 task_ref,
166 Some(&ctx.project_root),
167 pressure_action,
168 resolved_agent_id.as_deref(),
169 );
170 if gate_result.budget_blocked {
171 let msg = gate_result
172 .budget_warning
173 .unwrap_or_else(|| "Agent token budget exceeded".to_string());
174 return Err(ErrorData::invalid_params(msg, None));
175 }
176 let budget_warning = gate_result.budget_warning.clone();
177 if let Some(overridden) = gate_result.overridden_mode {
178 mode = overridden;
179 }
180
181 let (mode, degrade_warning) = if crate::tools::ctx_read::is_instruction_file(path) {
182 ("full".to_string(), None)
183 } else {
184 auto_degrade_read_mode(&mode)
185 };
186
187 if mode.starts_with("lines:") {
188 fresh = true;
189 }
190
191 if crate::core::binary_detect::is_binary_file(path) {
192 let msg = crate::core::binary_detect::binary_file_message(path);
193 return Err(ErrorData::invalid_params(msg, None));
194 }
195 {
196 let cap = crate::core::limits::max_read_bytes() as u64;
197 if let Ok(meta) = std::fs::metadata(path) {
198 if meta.len() > cap {
199 let msg = format!(
200 "File too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
201 Use mode=\"lines:1-100\" for partial reads or increase the limit.",
202 meta.len(),
203 cap
204 );
205 return Err(ErrorData::invalid_params(msg, None));
206 }
207 }
208 }
209
210 if !fresh {
213 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
214 if let Ok(mut cache) = cache_lock.try_write() {
215 crate::server::compaction_sync::sync_if_compacted(&mut cache, &data_dir);
216 }
217 }
218 }
219
220 let read_timeout = std::time::Duration::from_secs(30);
224 let cancelled = Arc::new(AtomicBool::new(false));
225 let (output, resolved_mode, original, is_cache_hit, file_ref, cache_stats) = {
226 let crp_mode = ctx.crp_mode;
227 let task_ref = current_task.as_deref();
228
229 let fast_result = 'fast: {
230 let file_lock = per_file_lock(path);
231 let Some(_file_guard) = file_lock.try_lock().ok() else {
232 break 'fast None;
233 };
234
235 if !fresh && mode == "full" {
240 if let Ok(cache) = cache_lock.try_read() {
241 if let Some(read_output) =
242 crate::tools::ctx_read::try_stub_hit_readonly(&cache, path)
243 {
244 let content = read_output.content;
245 let rmode = read_output.resolved_mode;
246 let orig = cache.get(path).map_or(0, |e| e.original_tokens);
247 let hit = content.contains(" cached ")
248 || content.contains("[unchanged")
249 || content.contains("[delta:");
250 let fref = cache.file_ref_map().get(path).cloned();
251 let stats = cache.get_stats();
252 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
253 break 'fast Some((content, rmode, orig, hit, fref, stats_snapshot));
254 }
255 }
256 }
257
258 let Some(mut cache) = cache_lock.try_write().ok() else {
261 break 'fast None;
262 };
263 let read_output = if fresh {
264 crate::tools::ctx_read::handle_fresh_with_task_resolved(
265 &mut cache, path, &mode, crp_mode, task_ref,
266 )
267 } else {
268 crate::tools::ctx_read::handle_with_task_resolved(
269 &mut cache, path, &mode, crp_mode, task_ref,
270 )
271 };
272 let content = read_output.content;
273 let rmode = read_output.resolved_mode;
274 let orig = cache.get(path).map_or(0, |e| e.original_tokens);
275 let hit = content.contains(" cached ")
276 || content.contains("[unchanged")
277 || content.contains("[delta:");
278 let fref = cache.file_ref_map().get(path).cloned();
279 let stats = cache.get_stats();
280 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
281 Some((content, rmode, orig, hit, fref, stats_snapshot))
282 };
283
284 if let Some(result) = fast_result {
285 result
286 } else {
287 let cache_lock = cache_lock.clone();
289 let mode = mode.clone();
290 let task_owned = current_task.clone();
291 let path_owned = path.to_string();
292 let cancel_flag = cancelled.clone();
293 let (tx, rx) = std::sync::mpsc::sync_channel(1);
294 std::thread::spawn(move || {
295 let file_lock = per_file_lock(&path_owned);
296
297 let _file_guard = {
300 let deadline =
301 std::time::Instant::now() + std::time::Duration::from_secs(25);
302 loop {
303 if cancel_flag.load(Ordering::Relaxed) {
304 return;
305 }
306 if let Ok(guard) = file_lock.try_lock() {
307 break guard;
308 }
309 if std::time::Instant::now() >= deadline {
310 tracing::error!(
311 "ctx_read: per-file lock timeout after 25s for {path_owned}"
312 );
313 let _ = tx.send((
314 format!("per-file lock contention for {path_owned} — retry in a moment"),
315 "error".to_string(), 0, false, None, (0, 0),
316 ));
317 return;
318 }
319 std::thread::sleep(std::time::Duration::from_millis(50));
320 }
321 };
322
323 if cancel_flag.load(Ordering::Relaxed) {
324 return;
325 }
326
327 let mut cache = {
330 let deadline =
331 std::time::Instant::now() + std::time::Duration::from_secs(25);
332 loop {
333 if cancel_flag.load(Ordering::Relaxed) {
334 return;
335 }
336 if let Ok(guard) = cache_lock.try_write() {
337 break guard;
338 }
339 if std::time::Instant::now() >= deadline {
340 tracing::error!(
341 "ctx_read: cache write-lock timeout after 25s for {path_owned}"
342 );
343 let _ = tx.send((
344 format!("cache lock contention for {path_owned} — retry in a moment"),
345 "error".to_string(), 0, false, None, (0, 0),
346 ));
347 return;
348 }
349 std::thread::sleep(std::time::Duration::from_millis(50));
350 }
351 };
352
353 let task_ref = task_owned.as_deref();
354 let read_output = if fresh {
355 crate::tools::ctx_read::handle_fresh_with_task_resolved(
356 &mut cache,
357 &path_owned,
358 &mode,
359 crp_mode,
360 task_ref,
361 )
362 } else {
363 crate::tools::ctx_read::handle_with_task_resolved(
364 &mut cache,
365 &path_owned,
366 &mode,
367 crp_mode,
368 task_ref,
369 )
370 };
371 let content = read_output.content;
372 let rmode = read_output.resolved_mode;
373 let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
374 let hit = content.contains(" cached ");
375 let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
376 let stats = cache.get_stats();
377 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
378 let _ = tx.send((content, rmode, orig, hit, fref, stats_snapshot));
379 });
380 if let Ok(result) = rx.recv_timeout(read_timeout) {
381 result
382 } else {
383 cancelled.store(true, Ordering::Relaxed);
384 tracing::error!("ctx_read timed out after {read_timeout:?} for {path}");
385 let msg = format!(
386 "ERROR: ctx_read timed out after {}s reading {path}. \
387 The file may be very large or a blocking I/O issue occurred. \
388 Try mode=\"lines:1-100\" for a partial read.",
389 read_timeout.as_secs()
390 );
391 return Err(ErrorData::internal_error(msg, None));
392 }
393 } };
395
396 if resolved_mode == "error" {
398 return Err(ErrorData::invalid_params(output, None));
399 }
400
401 let output_tokens = crate::core::tokens::count_tokens(&output);
402 let saved = original.saturating_sub(output_tokens);
403
404 let mut ensured_root: Option<String> = None;
406 let mut traversal_working_set: Vec<String> = Vec::new();
407 let project_root_snapshot;
408 {
409 let rt = tokio::runtime::Handle::current();
410 let session_guard = rt.block_on(tokio::time::timeout(
411 std::time::Duration::from_secs(10),
412 session_lock.write(),
413 ));
414 if let Ok(mut session) = session_guard {
415 session.touch_file(path, file_ref.as_deref(), &resolved_mode, original);
416 traversal_working_set =
419 crate::core::tool_lifecycle::recent_working_set(&session, path);
420 let file_summary = extract_file_summary(&output, path);
422 if !file_summary.is_empty() {
423 session.set_file_summary(path, &file_summary);
424 }
425 if is_cache_hit {
426 session.record_cache_hit();
427 }
428 if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
429 let touched: Vec<String> = session
430 .files_touched
431 .iter()
432 .map(|f| f.path.clone())
433 .collect();
434 let inferred =
435 crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
436 if inferred.confidence >= 0.4 {
437 session.active_structured_intent = Some(inferred);
438 }
439 }
440 if session.task.is_none() && session.stats.files_read % 5 == 0 {
442 session.auto_infer_task();
443 }
444 let root_missing = session
445 .project_root
446 .as_deref()
447 .is_none_or(|r| r.trim().is_empty());
448 if root_missing {
449 if let Some(root) = crate::core::protocol::detect_project_root(path) {
450 session.project_root = Some(root.clone());
451 ensured_root = Some(root);
452 }
453 }
454 project_root_snapshot = session
455 .project_root
456 .clone()
457 .unwrap_or_else(|| ".".to_string());
458 } else {
459 tracing::warn!(
460 "session write-lock timeout (5s) in ctx_read post-update for {path}"
461 );
462 project_root_snapshot = ctx.project_root.clone();
463 }
464 }
465
466 if let Some(root) = ensured_root.as_deref() {
467 crate::core::index_orchestrator::ensure_all_background(root);
468 }
469
470 {
476 let path_bg = path.to_string();
477 let resolved_mode_bg = resolved_mode.clone();
478 let project_root_bg = project_root_snapshot.clone();
479 let (turns, hits) = cache_stats;
480 std::thread::spawn(move || {
481 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
484 crate::core::heatmap::record_file_access(&path_bg, original, saved);
485
486 if let Some(root) =
489 crate::core::tool_lifecycle::usable_root(Some(project_root_bg.as_str()))
490 {
491 crate::core::cooccurrence::record_focus_access(
492 root,
493 &path_bg,
494 &traversal_working_set,
495 );
496 }
497
498 let sig =
499 crate::core::mode_predictor::FileSignature::from_path(&path_bg, original);
500 let density = if output_tokens > 0 {
501 original as f64 / output_tokens as f64
502 } else {
503 1.0
504 };
505 let outcome = crate::core::mode_predictor::ModeOutcome {
506 mode: resolved_mode_bg,
507 tokens_in: original,
508 tokens_out: output_tokens,
509 density: density.min(1.0),
510 };
511 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
512 predictor.set_project_root(&project_root_bg);
513 predictor.record(sig, outcome);
514 predictor.save();
515
516 let ext = std::path::Path::new(&path_bg)
517 .extension()
518 .and_then(|e| e.to_str())
519 .unwrap_or("")
520 .to_string();
521 let thresholds =
522 crate::core::adaptive_thresholds::thresholds_for_path(&path_bg);
523 let feedback_outcome = crate::core::feedback::CompressionOutcome {
524 session_id: format!("{}", std::process::id()),
525 language: ext,
526 entropy_threshold: thresholds.bpe_entropy,
527 jaccard_threshold: thresholds.jaccard,
528 total_turns: turns as u32,
529 tokens_saved: saved as u64,
530 tokens_original: original as u64,
531 cache_hits: hits as u32,
532 total_reads: turns as u32,
533 task_completed: crate::core::bounce_tracker::global()
541 .lock()
542 .ok()
543 .and_then(|bt| bt.bounce_rate_for_extension(&path_bg))
544 .is_none_or(|rate| rate < 0.30),
545 timestamp: chrono::Local::now().to_rfc3339(),
546 };
547 let mut store = crate::core::feedback::FeedbackStore::load();
548 store.project_root = Some(project_root_bg);
549 store.record_outcome(feedback_outcome);
550 }));
551 });
552 }
553
554 if let Some(aid) = resolved_agent_id.as_deref() {
555 crate::core::agent_budget::record_consumption(aid, output_tokens);
556 }
557
558 let hints_suffix = {
562 if let Some(index) = crate::core::graph_index::ProjectIndex::load(&ctx.project_root) {
563 let hints = crate::core::cross_source_hints::hints_for_file(
564 path,
565 &index.edges,
566 &ctx.project_root,
567 );
568 if hints.is_empty() {
569 String::new()
570 } else {
571 crate::core::cross_source_hints::format_hints(&hints)
572 }
573 } else {
574 String::new()
575 }
576 };
577
578 let mut warnings = Vec::new();
579 if let Some(ref w) = budget_warning {
580 warnings.push(w.as_str());
581 }
582 if let Some(ref w) = degrade_warning {
583 warnings.push(w.as_str());
584 }
585 let final_output = if !warnings.is_empty() {
586 format!("{output}{hints_suffix}\n\n{}", warnings.join("\n"))
587 } else if hints_suffix.is_empty() {
588 output
589 } else {
590 format!("{output}{hints_suffix}")
591 };
592
593 Ok(ToolOutput {
594 text: final_output,
595 original_tokens: original,
596 saved_tokens: saved,
597 mode: Some(resolved_mode),
598 path: Some(path.to_string()),
599 changed: false,
600 shell_outcome: None,
601 })
602 }
603}
604
605fn apply_verdict(
606 mode: &str,
607 verdict: crate::core::degradation_policy::DegradationVerdictV1,
608) -> (String, bool) {
609 use crate::core::degradation_policy::DegradationVerdictV1;
610 match verdict {
611 DegradationVerdictV1::Ok => (mode.to_string(), false),
612 DegradationVerdictV1::Warn => match mode {
613 "full" => ("map".to_string(), true),
614 other => (other.to_string(), false),
615 },
616 DegradationVerdictV1::Throttle => match mode {
617 "full" | "map" => ("signatures".to_string(), true),
618 other => (other.to_string(), false),
619 },
620 DegradationVerdictV1::Block => {
621 if mode == "signatures" {
622 ("signatures".to_string(), false)
623 } else {
624 ("signatures".to_string(), true)
625 }
626 }
627 }
628}
629
630fn auto_degrade_read_mode(mode: &str) -> (String, Option<String>) {
631 if crate::core::config::Config::load().no_degrade_effective() {
632 return (mode.to_string(), None);
633 }
634 let profile = crate::core::profiles::active_profile();
635 if !profile.degradation.enforce_effective() {
636 return (mode.to_string(), None);
637 }
638 let policy = crate::core::degradation_policy::evaluate_v1_for_tool("ctx_read", None);
639 let (new_mode, degraded) = apply_verdict(mode, policy.decision.verdict);
640 let warning = if degraded {
641 Some(format!(
642 "⚠ Context pressure: mode={mode} was downgraded to mode={new_mode} \
643 (verdict: {:?}). Use start_line=1 to bypass, or run ctx_compress to free budget.",
644 policy.decision.verdict
645 ))
646 } else {
647 None
648 };
649 (new_mode, warning)
650}
651
652fn extract_file_summary(output: &str, path: &str) -> String {
653 let hint = crate::core::auto_findings::extract_content_hint(output);
654 if !hint.is_empty() {
655 return hint;
656 }
657 let ext = std::path::Path::new(path)
658 .extension()
659 .and_then(|e| e.to_str())
660 .unwrap_or("");
661 let line_count = output.lines().count();
662 if line_count > 5 {
663 format!("{ext} file, {line_count} lines")
664 } else {
665 String::new()
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use std::sync::atomic::{AtomicUsize, Ordering};
673
674 #[test]
675 fn per_file_lock_same_path_returns_same_mutex() {
676 let lock_a1 = per_file_lock("/tmp/test_same_path.txt");
677 let lock_a2 = per_file_lock("/tmp/test_same_path.txt");
678 assert!(Arc::ptr_eq(&lock_a1, &lock_a2));
679 }
680
681 #[test]
682 fn per_file_lock_different_paths_return_different_mutexes() {
683 let lock_a = per_file_lock("/tmp/test_path_a.txt");
684 let lock_b = per_file_lock("/tmp/test_path_b.txt");
685 assert!(!Arc::ptr_eq(&lock_a, &lock_b));
686 }
687
688 #[test]
689 fn per_file_lock_serializes_concurrent_access() {
690 let counter = Arc::new(AtomicUsize::new(0));
691 let max_concurrent = Arc::new(AtomicUsize::new(0));
692 let path = "/tmp/test_concurrent_serialization.txt";
693 let mut handles = Vec::new();
694
695 for _ in 0..5 {
696 let counter = counter.clone();
697 let max_concurrent = max_concurrent.clone();
698 let path = path.to_string();
699 handles.push(std::thread::spawn(move || {
700 let lock = per_file_lock(&path);
701 let _guard = lock.lock().unwrap();
702 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
703 max_concurrent.fetch_max(active, Ordering::SeqCst);
704 std::thread::sleep(std::time::Duration::from_millis(10));
705 counter.fetch_sub(1, Ordering::SeqCst);
706 }));
707 }
708
709 for h in handles {
710 h.join().unwrap();
711 }
712
713 assert_eq!(max_concurrent.load(Ordering::SeqCst), 1);
714 }
715
716 #[test]
717 fn per_file_lock_allows_parallel_different_paths() {
718 let counter = Arc::new(AtomicUsize::new(0));
719 let max_concurrent = Arc::new(AtomicUsize::new(0));
720 let mut handles = Vec::new();
721
722 for i in 0..4 {
723 let counter = counter.clone();
724 let max_concurrent = max_concurrent.clone();
725 let path = format!("/tmp/test_parallel_{i}.txt");
726 handles.push(std::thread::spawn(move || {
727 let lock = per_file_lock(&path);
728 let _guard = lock.lock().unwrap();
729 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
730 max_concurrent.fetch_max(active, Ordering::SeqCst);
731 std::thread::sleep(std::time::Duration::from_millis(50));
732 counter.fetch_sub(1, Ordering::SeqCst);
733 }));
734 }
735
736 for h in handles {
737 h.join().unwrap();
738 }
739
740 assert!(max_concurrent.load(Ordering::SeqCst) > 1);
741 }
742
743 #[test]
747 fn zombie_thread_does_not_block_subsequent_cache_access() {
748 let cache: Arc<tokio::sync::RwLock<u32>> = Arc::new(tokio::sync::RwLock::new(0));
749
750 let zombie_lock = cache.clone();
752 let _zombie = std::thread::spawn(move || {
753 let _guard = zombie_lock.blocking_write();
754 std::thread::sleep(std::time::Duration::from_secs(2));
755 });
756 std::thread::sleep(std::time::Duration::from_millis(50));
757
758 assert!(cache.try_read().is_err());
760
761 let cancel = Arc::new(AtomicBool::new(false));
763 let cancel2 = cancel.clone();
764 let lock2 = cache.clone();
765 let waiter = std::thread::spawn(move || {
766 let start = std::time::Instant::now();
767 loop {
768 if cancel2.load(Ordering::Relaxed) {
769 return (false, start.elapsed());
770 }
771 if let Ok(_guard) = lock2.try_write() {
772 return (true, start.elapsed());
773 }
774 std::thread::sleep(std::time::Duration::from_millis(50));
775 }
776 });
777
778 std::thread::sleep(std::time::Duration::from_millis(200));
780 cancel.store(true, Ordering::Relaxed);
781
782 let (acquired, elapsed) = waiter.join().unwrap();
783 assert!(
784 !acquired,
785 "should not have acquired lock while zombie holds it"
786 );
787 assert!(
788 elapsed < std::time::Duration::from_secs(1),
789 "cancellation should have stopped the loop promptly"
790 );
791 }
792
793 fn apply_start_line(
796 mode: &mut String,
797 fresh: &mut bool,
798 explicit_mode: bool,
799 start_line: Option<i64>,
800 ) {
801 if let Some(sl) = start_line {
802 let sl = sl.max(1_i64);
803 if sl <= 1 {
804 return;
805 }
806 *fresh = true;
807 if !explicit_mode || mode.starts_with("lines") {
808 *mode = format!("lines:{sl}-999999");
809 }
810 }
811 }
812
813 #[test]
814 fn start_line_1_does_not_override_mode() {
815 let mut mode = "auto".to_string();
816 let mut fresh = false;
817 apply_start_line(&mut mode, &mut fresh, false, Some(1));
818 assert_eq!(mode, "auto", "start_line=1 should not change mode");
819 assert!(!fresh, "start_line=1 should not force fresh=true");
820 }
821
822 #[test]
823 fn start_line_gt1_overrides_implicit_mode() {
824 let mut mode = "auto".to_string();
825 let mut fresh = false;
826 apply_start_line(&mut mode, &mut fresh, false, Some(50));
827 assert_eq!(mode, "lines:50-999999");
828 assert!(fresh);
829 }
830
831 #[test]
832 fn start_line_gt1_does_not_override_explicit_map() {
833 let mut mode = "map".to_string();
835 let mut fresh = false;
836 apply_start_line(&mut mode, &mut fresh, true, Some(50));
837 assert_eq!(
838 mode, "map",
839 "explicit mode=map must not be clobbered by start_line"
840 );
841 assert!(fresh, "start_line>1 should still force fresh");
842 }
843
844 #[test]
845 fn start_line_gt1_does_not_override_explicit_signatures() {
846 let mut mode = "signatures".to_string();
847 let mut fresh = false;
848 apply_start_line(&mut mode, &mut fresh, true, Some(100));
849 assert_eq!(mode, "signatures");
850 assert!(fresh);
851 }
852
853 #[test]
854 fn start_line_gt1_honors_explicit_lines_mode() {
855 let mut mode = "lines:1-50".to_string();
856 let mut fresh = false;
857 apply_start_line(&mut mode, &mut fresh, true, Some(30));
858 assert_eq!(
859 mode, "lines:30-999999",
860 "explicit lines mode should accept start_line override"
861 );
862 assert!(fresh);
863 }
864
865 #[test]
866 fn start_line_none_does_nothing() {
867 let mut mode = "map".to_string();
868 let mut fresh = false;
869 apply_start_line(&mut mode, &mut fresh, true, None);
870 assert_eq!(mode, "map");
871 assert!(!fresh);
872 }
873
874 #[test]
875 fn start_line_1_with_explicit_mode_preserves_it() {
876 let mut mode = "map".to_string();
878 let mut fresh = false;
879 apply_start_line(&mut mode, &mut fresh, true, Some(1));
880 assert_eq!(mode, "map");
881 assert!(!fresh);
882 }
883
884 use crate::core::degradation_policy::DegradationVerdictV1;
888
889 #[test]
890 fn verdict_ok_does_not_degrade() {
891 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Ok);
892 assert_eq!(mode, "full");
893 assert!(!degraded);
894 }
895
896 #[test]
897 fn verdict_warn_degrades_full_to_map() {
898 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
899 assert_eq!(mode, "map");
900 assert!(degraded, "full→map must be flagged as degraded");
901 }
902
903 #[test]
904 fn verdict_warn_keeps_map() {
905 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Warn);
906 assert_eq!(mode, "map");
907 assert!(!degraded, "map is not degraded under Warn");
908 }
909
910 #[test]
911 fn verdict_warn_keeps_signatures() {
912 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Warn);
913 assert_eq!(mode, "signatures");
914 assert!(!degraded);
915 }
916
917 #[test]
918 fn verdict_throttle_degrades_full_to_signatures() {
919 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Throttle);
920 assert_eq!(mode, "signatures");
921 assert!(degraded);
922 }
923
924 #[test]
925 fn verdict_throttle_degrades_map_to_signatures() {
926 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Throttle);
927 assert_eq!(mode, "signatures");
928 assert!(degraded);
929 }
930
931 #[test]
932 fn verdict_throttle_keeps_lines() {
933 let (mode, degraded) = super::apply_verdict("lines:1-50", DegradationVerdictV1::Throttle);
934 assert_eq!(mode, "lines:1-50");
935 assert!(!degraded, "lines mode bypasses degradation");
936 }
937
938 #[test]
939 fn verdict_block_degrades_full_to_signatures() {
940 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Block);
941 assert_eq!(mode, "signatures");
942 assert!(degraded);
943 }
944
945 #[test]
946 fn verdict_block_does_not_degrade_signatures() {
947 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Block);
948 assert_eq!(mode, "signatures");
949 assert!(!degraded, "already at signatures — no degradation needed");
950 }
951
952 #[test]
953 fn degrade_warning_message_contains_mode_info() {
954 let (new_mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
955 assert!(degraded);
956 let warning = format!(
957 "⚠ Context pressure: mode=full was downgraded to mode={new_mode} (verdict: {:?}).",
958 DegradationVerdictV1::Warn
959 );
960 assert!(warning.contains("mode=full"));
961 assert!(warning.contains("mode=map"));
962 assert!(warning.contains("Warn"));
963 }
964
965 #[test]
970 fn auto_degrade_preserves_full_when_default_config() {
971 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
972 return;
973 }
974 let (mode, warning) = super::auto_degrade_read_mode("full");
975 assert_eq!(mode, "full");
976 assert!(warning.is_none());
977 }
978
979 #[test]
980 fn auto_degrade_preserves_map_when_default_config() {
981 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
982 return;
983 }
984 let (mode, warning) = super::auto_degrade_read_mode("map");
985 assert_eq!(mode, "map");
986 assert!(warning.is_none());
987 }
988
989 #[test]
990 fn auto_degrade_preserves_signatures_when_default_config() {
991 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
992 return;
993 }
994 let (mode, warning) = super::auto_degrade_read_mode("signatures");
995 assert_eq!(mode, "signatures");
996 assert!(warning.is_none());
997 }
998
999 #[test]
1000 fn auto_degrade_preserves_diff_always() {
1001 let (mode, warning) = super::auto_degrade_read_mode("diff");
1002 assert_eq!(mode, "diff");
1003 assert!(warning.is_none());
1004 }
1005
1006 #[test]
1007 fn auto_degrade_preserves_lines_mode_always() {
1008 let (mode, warning) = super::auto_degrade_read_mode("lines:10-50");
1009 assert_eq!(mode, "lines:10-50");
1010 assert!(warning.is_none());
1011 }
1012
1013 #[test]
1014 fn auto_degrade_preserves_aggressive_when_default_config() {
1015 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1016 return;
1017 }
1018 let (mode, warning) = super::auto_degrade_read_mode("aggressive");
1019 assert_eq!(mode, "aggressive");
1020 assert!(warning.is_none());
1021 }
1022
1023 #[test]
1024 fn auto_degrade_preserves_entropy_when_default_config() {
1025 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1026 return;
1027 }
1028 let (mode, warning) = super::auto_degrade_read_mode("entropy");
1029 assert_eq!(mode, "entropy");
1030 assert!(warning.is_none());
1031 }
1032
1033 #[test]
1034 fn auto_degrade_preserves_auto_when_default_config() {
1035 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1036 return;
1037 }
1038 let (mode, warning) = super::auto_degrade_read_mode("auto");
1039 assert_eq!(mode, "auto");
1040 assert!(warning.is_none());
1041 }
1042
1043 #[test]
1046 fn verdict_warn_does_not_degrade_diff() {
1047 let (mode, degraded) = super::apply_verdict("diff", DegradationVerdictV1::Warn);
1048 assert_eq!(mode, "diff");
1049 assert!(!degraded);
1050 }
1051
1052 #[test]
1053 fn verdict_throttle_does_not_degrade_signatures() {
1054 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Throttle);
1055 assert_eq!(mode, "signatures");
1056 assert!(!degraded);
1057 }
1058
1059 #[test]
1060 fn verdict_ok_preserves_map() {
1061 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Ok);
1062 assert_eq!(mode, "map");
1063 assert!(!degraded);
1064 }
1065
1066 #[test]
1067 fn verdict_ok_preserves_signatures() {
1068 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Ok);
1069 assert_eq!(mode, "signatures");
1070 assert!(!degraded);
1071 }
1072
1073 #[test]
1074 fn verdict_ok_preserves_lines() {
1075 let (mode, degraded) = super::apply_verdict("lines:1-100", DegradationVerdictV1::Ok);
1076 assert_eq!(mode, "lines:1-100");
1077 assert!(!degraded);
1078 }
1079
1080 #[test]
1081 fn verdict_block_degrades_map_to_signatures() {
1082 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Block);
1083 assert_eq!(mode, "signatures");
1084 assert!(degraded);
1085 }
1086}