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: true,
534 timestamp: chrono::Local::now().to_rfc3339(),
535 };
536 let mut store = crate::core::feedback::FeedbackStore::load();
537 store.project_root = Some(project_root_bg);
538 store.record_outcome(feedback_outcome);
539 }));
540 });
541 }
542
543 if let Some(aid) = resolved_agent_id.as_deref() {
544 crate::core::agent_budget::record_consumption(aid, output_tokens);
545 }
546
547 let hints_suffix = {
551 if let Some(index) = crate::core::graph_index::ProjectIndex::load(&ctx.project_root) {
552 let hints = crate::core::cross_source_hints::hints_for_file(
553 path,
554 &index.edges,
555 &ctx.project_root,
556 );
557 if hints.is_empty() {
558 String::new()
559 } else {
560 crate::core::cross_source_hints::format_hints(&hints)
561 }
562 } else {
563 String::new()
564 }
565 };
566
567 let mut warnings = Vec::new();
568 if let Some(ref w) = budget_warning {
569 warnings.push(w.as_str());
570 }
571 if let Some(ref w) = degrade_warning {
572 warnings.push(w.as_str());
573 }
574 let final_output = if !warnings.is_empty() {
575 format!("{output}{hints_suffix}\n\n{}", warnings.join("\n"))
576 } else if hints_suffix.is_empty() {
577 output
578 } else {
579 format!("{output}{hints_suffix}")
580 };
581
582 Ok(ToolOutput {
583 text: final_output,
584 original_tokens: original,
585 saved_tokens: saved,
586 mode: Some(resolved_mode),
587 path: Some(path.to_string()),
588 changed: false,
589 shell_outcome: None,
590 })
591 }
592}
593
594fn apply_verdict(
595 mode: &str,
596 verdict: crate::core::degradation_policy::DegradationVerdictV1,
597) -> (String, bool) {
598 use crate::core::degradation_policy::DegradationVerdictV1;
599 match verdict {
600 DegradationVerdictV1::Ok => (mode.to_string(), false),
601 DegradationVerdictV1::Warn => match mode {
602 "full" => ("map".to_string(), true),
603 other => (other.to_string(), false),
604 },
605 DegradationVerdictV1::Throttle => match mode {
606 "full" | "map" => ("signatures".to_string(), true),
607 other => (other.to_string(), false),
608 },
609 DegradationVerdictV1::Block => {
610 if mode == "signatures" {
611 ("signatures".to_string(), false)
612 } else {
613 ("signatures".to_string(), true)
614 }
615 }
616 }
617}
618
619fn auto_degrade_read_mode(mode: &str) -> (String, Option<String>) {
620 if crate::core::config::Config::load().no_degrade_effective() {
621 return (mode.to_string(), None);
622 }
623 let profile = crate::core::profiles::active_profile();
624 if !profile.degradation.enforce_effective() {
625 return (mode.to_string(), None);
626 }
627 let policy = crate::core::degradation_policy::evaluate_v1_for_tool("ctx_read", None);
628 let (new_mode, degraded) = apply_verdict(mode, policy.decision.verdict);
629 let warning = if degraded {
630 Some(format!(
631 "⚠ Context pressure: mode={mode} was downgraded to mode={new_mode} \
632 (verdict: {:?}). Use start_line=1 to bypass, or run ctx_compress to free budget.",
633 policy.decision.verdict
634 ))
635 } else {
636 None
637 };
638 (new_mode, warning)
639}
640
641fn extract_file_summary(output: &str, path: &str) -> String {
642 let hint = crate::core::auto_findings::extract_content_hint(output);
643 if !hint.is_empty() {
644 return hint;
645 }
646 let ext = std::path::Path::new(path)
647 .extension()
648 .and_then(|e| e.to_str())
649 .unwrap_or("");
650 let line_count = output.lines().count();
651 if line_count > 5 {
652 format!("{ext} file, {line_count} lines")
653 } else {
654 String::new()
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use std::sync::atomic::{AtomicUsize, Ordering};
662
663 #[test]
664 fn per_file_lock_same_path_returns_same_mutex() {
665 let lock_a1 = per_file_lock("/tmp/test_same_path.txt");
666 let lock_a2 = per_file_lock("/tmp/test_same_path.txt");
667 assert!(Arc::ptr_eq(&lock_a1, &lock_a2));
668 }
669
670 #[test]
671 fn per_file_lock_different_paths_return_different_mutexes() {
672 let lock_a = per_file_lock("/tmp/test_path_a.txt");
673 let lock_b = per_file_lock("/tmp/test_path_b.txt");
674 assert!(!Arc::ptr_eq(&lock_a, &lock_b));
675 }
676
677 #[test]
678 fn per_file_lock_serializes_concurrent_access() {
679 let counter = Arc::new(AtomicUsize::new(0));
680 let max_concurrent = Arc::new(AtomicUsize::new(0));
681 let path = "/tmp/test_concurrent_serialization.txt";
682 let mut handles = Vec::new();
683
684 for _ in 0..5 {
685 let counter = counter.clone();
686 let max_concurrent = max_concurrent.clone();
687 let path = path.to_string();
688 handles.push(std::thread::spawn(move || {
689 let lock = per_file_lock(&path);
690 let _guard = lock.lock().unwrap();
691 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
692 max_concurrent.fetch_max(active, Ordering::SeqCst);
693 std::thread::sleep(std::time::Duration::from_millis(10));
694 counter.fetch_sub(1, Ordering::SeqCst);
695 }));
696 }
697
698 for h in handles {
699 h.join().unwrap();
700 }
701
702 assert_eq!(max_concurrent.load(Ordering::SeqCst), 1);
703 }
704
705 #[test]
706 fn per_file_lock_allows_parallel_different_paths() {
707 let counter = Arc::new(AtomicUsize::new(0));
708 let max_concurrent = Arc::new(AtomicUsize::new(0));
709 let mut handles = Vec::new();
710
711 for i in 0..4 {
712 let counter = counter.clone();
713 let max_concurrent = max_concurrent.clone();
714 let path = format!("/tmp/test_parallel_{i}.txt");
715 handles.push(std::thread::spawn(move || {
716 let lock = per_file_lock(&path);
717 let _guard = lock.lock().unwrap();
718 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
719 max_concurrent.fetch_max(active, Ordering::SeqCst);
720 std::thread::sleep(std::time::Duration::from_millis(50));
721 counter.fetch_sub(1, Ordering::SeqCst);
722 }));
723 }
724
725 for h in handles {
726 h.join().unwrap();
727 }
728
729 assert!(max_concurrent.load(Ordering::SeqCst) > 1);
730 }
731
732 #[test]
736 fn zombie_thread_does_not_block_subsequent_cache_access() {
737 let cache: Arc<tokio::sync::RwLock<u32>> = Arc::new(tokio::sync::RwLock::new(0));
738
739 let zombie_lock = cache.clone();
741 let _zombie = std::thread::spawn(move || {
742 let _guard = zombie_lock.blocking_write();
743 std::thread::sleep(std::time::Duration::from_secs(2));
744 });
745 std::thread::sleep(std::time::Duration::from_millis(50));
746
747 assert!(cache.try_read().is_err());
749
750 let cancel = Arc::new(AtomicBool::new(false));
752 let cancel2 = cancel.clone();
753 let lock2 = cache.clone();
754 let waiter = std::thread::spawn(move || {
755 let start = std::time::Instant::now();
756 loop {
757 if cancel2.load(Ordering::Relaxed) {
758 return (false, start.elapsed());
759 }
760 if let Ok(_guard) = lock2.try_write() {
761 return (true, start.elapsed());
762 }
763 std::thread::sleep(std::time::Duration::from_millis(50));
764 }
765 });
766
767 std::thread::sleep(std::time::Duration::from_millis(200));
769 cancel.store(true, Ordering::Relaxed);
770
771 let (acquired, elapsed) = waiter.join().unwrap();
772 assert!(
773 !acquired,
774 "should not have acquired lock while zombie holds it"
775 );
776 assert!(
777 elapsed < std::time::Duration::from_secs(1),
778 "cancellation should have stopped the loop promptly"
779 );
780 }
781
782 fn apply_start_line(
785 mode: &mut String,
786 fresh: &mut bool,
787 explicit_mode: bool,
788 start_line: Option<i64>,
789 ) {
790 if let Some(sl) = start_line {
791 let sl = sl.max(1_i64);
792 if sl <= 1 {
793 return;
794 }
795 *fresh = true;
796 if !explicit_mode || mode.starts_with("lines") {
797 *mode = format!("lines:{sl}-999999");
798 }
799 }
800 }
801
802 #[test]
803 fn start_line_1_does_not_override_mode() {
804 let mut mode = "auto".to_string();
805 let mut fresh = false;
806 apply_start_line(&mut mode, &mut fresh, false, Some(1));
807 assert_eq!(mode, "auto", "start_line=1 should not change mode");
808 assert!(!fresh, "start_line=1 should not force fresh=true");
809 }
810
811 #[test]
812 fn start_line_gt1_overrides_implicit_mode() {
813 let mut mode = "auto".to_string();
814 let mut fresh = false;
815 apply_start_line(&mut mode, &mut fresh, false, Some(50));
816 assert_eq!(mode, "lines:50-999999");
817 assert!(fresh);
818 }
819
820 #[test]
821 fn start_line_gt1_does_not_override_explicit_map() {
822 let mut mode = "map".to_string();
824 let mut fresh = false;
825 apply_start_line(&mut mode, &mut fresh, true, Some(50));
826 assert_eq!(
827 mode, "map",
828 "explicit mode=map must not be clobbered by start_line"
829 );
830 assert!(fresh, "start_line>1 should still force fresh");
831 }
832
833 #[test]
834 fn start_line_gt1_does_not_override_explicit_signatures() {
835 let mut mode = "signatures".to_string();
836 let mut fresh = false;
837 apply_start_line(&mut mode, &mut fresh, true, Some(100));
838 assert_eq!(mode, "signatures");
839 assert!(fresh);
840 }
841
842 #[test]
843 fn start_line_gt1_honors_explicit_lines_mode() {
844 let mut mode = "lines:1-50".to_string();
845 let mut fresh = false;
846 apply_start_line(&mut mode, &mut fresh, true, Some(30));
847 assert_eq!(
848 mode, "lines:30-999999",
849 "explicit lines mode should accept start_line override"
850 );
851 assert!(fresh);
852 }
853
854 #[test]
855 fn start_line_none_does_nothing() {
856 let mut mode = "map".to_string();
857 let mut fresh = false;
858 apply_start_line(&mut mode, &mut fresh, true, None);
859 assert_eq!(mode, "map");
860 assert!(!fresh);
861 }
862
863 #[test]
864 fn start_line_1_with_explicit_mode_preserves_it() {
865 let mut mode = "map".to_string();
867 let mut fresh = false;
868 apply_start_line(&mut mode, &mut fresh, true, Some(1));
869 assert_eq!(mode, "map");
870 assert!(!fresh);
871 }
872
873 use crate::core::degradation_policy::DegradationVerdictV1;
877
878 #[test]
879 fn verdict_ok_does_not_degrade() {
880 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Ok);
881 assert_eq!(mode, "full");
882 assert!(!degraded);
883 }
884
885 #[test]
886 fn verdict_warn_degrades_full_to_map() {
887 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
888 assert_eq!(mode, "map");
889 assert!(degraded, "full→map must be flagged as degraded");
890 }
891
892 #[test]
893 fn verdict_warn_keeps_map() {
894 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Warn);
895 assert_eq!(mode, "map");
896 assert!(!degraded, "map is not degraded under Warn");
897 }
898
899 #[test]
900 fn verdict_warn_keeps_signatures() {
901 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Warn);
902 assert_eq!(mode, "signatures");
903 assert!(!degraded);
904 }
905
906 #[test]
907 fn verdict_throttle_degrades_full_to_signatures() {
908 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Throttle);
909 assert_eq!(mode, "signatures");
910 assert!(degraded);
911 }
912
913 #[test]
914 fn verdict_throttle_degrades_map_to_signatures() {
915 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Throttle);
916 assert_eq!(mode, "signatures");
917 assert!(degraded);
918 }
919
920 #[test]
921 fn verdict_throttle_keeps_lines() {
922 let (mode, degraded) = super::apply_verdict("lines:1-50", DegradationVerdictV1::Throttle);
923 assert_eq!(mode, "lines:1-50");
924 assert!(!degraded, "lines mode bypasses degradation");
925 }
926
927 #[test]
928 fn verdict_block_degrades_full_to_signatures() {
929 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Block);
930 assert_eq!(mode, "signatures");
931 assert!(degraded);
932 }
933
934 #[test]
935 fn verdict_block_does_not_degrade_signatures() {
936 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Block);
937 assert_eq!(mode, "signatures");
938 assert!(!degraded, "already at signatures — no degradation needed");
939 }
940
941 #[test]
942 fn degrade_warning_message_contains_mode_info() {
943 let (new_mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
944 assert!(degraded);
945 let warning = format!(
946 "⚠ Context pressure: mode=full was downgraded to mode={new_mode} (verdict: {:?}).",
947 DegradationVerdictV1::Warn
948 );
949 assert!(warning.contains("mode=full"));
950 assert!(warning.contains("mode=map"));
951 assert!(warning.contains("Warn"));
952 }
953
954 #[test]
959 fn auto_degrade_preserves_full_when_default_config() {
960 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
961 return;
962 }
963 let (mode, warning) = super::auto_degrade_read_mode("full");
964 assert_eq!(mode, "full");
965 assert!(warning.is_none());
966 }
967
968 #[test]
969 fn auto_degrade_preserves_map_when_default_config() {
970 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
971 return;
972 }
973 let (mode, warning) = super::auto_degrade_read_mode("map");
974 assert_eq!(mode, "map");
975 assert!(warning.is_none());
976 }
977
978 #[test]
979 fn auto_degrade_preserves_signatures_when_default_config() {
980 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
981 return;
982 }
983 let (mode, warning) = super::auto_degrade_read_mode("signatures");
984 assert_eq!(mode, "signatures");
985 assert!(warning.is_none());
986 }
987
988 #[test]
989 fn auto_degrade_preserves_diff_always() {
990 let (mode, warning) = super::auto_degrade_read_mode("diff");
991 assert_eq!(mode, "diff");
992 assert!(warning.is_none());
993 }
994
995 #[test]
996 fn auto_degrade_preserves_lines_mode_always() {
997 let (mode, warning) = super::auto_degrade_read_mode("lines:10-50");
998 assert_eq!(mode, "lines:10-50");
999 assert!(warning.is_none());
1000 }
1001
1002 #[test]
1003 fn auto_degrade_preserves_aggressive_when_default_config() {
1004 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1005 return;
1006 }
1007 let (mode, warning) = super::auto_degrade_read_mode("aggressive");
1008 assert_eq!(mode, "aggressive");
1009 assert!(warning.is_none());
1010 }
1011
1012 #[test]
1013 fn auto_degrade_preserves_entropy_when_default_config() {
1014 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1015 return;
1016 }
1017 let (mode, warning) = super::auto_degrade_read_mode("entropy");
1018 assert_eq!(mode, "entropy");
1019 assert!(warning.is_none());
1020 }
1021
1022 #[test]
1023 fn auto_degrade_preserves_auto_when_default_config() {
1024 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1025 return;
1026 }
1027 let (mode, warning) = super::auto_degrade_read_mode("auto");
1028 assert_eq!(mode, "auto");
1029 assert!(warning.is_none());
1030 }
1031
1032 #[test]
1035 fn verdict_warn_does_not_degrade_diff() {
1036 let (mode, degraded) = super::apply_verdict("diff", DegradationVerdictV1::Warn);
1037 assert_eq!(mode, "diff");
1038 assert!(!degraded);
1039 }
1040
1041 #[test]
1042 fn verdict_throttle_does_not_degrade_signatures() {
1043 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Throttle);
1044 assert_eq!(mode, "signatures");
1045 assert!(!degraded);
1046 }
1047
1048 #[test]
1049 fn verdict_ok_preserves_map() {
1050 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Ok);
1051 assert_eq!(mode, "map");
1052 assert!(!degraded);
1053 }
1054
1055 #[test]
1056 fn verdict_ok_preserves_signatures() {
1057 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Ok);
1058 assert_eq!(mode, "signatures");
1059 assert!(!degraded);
1060 }
1061
1062 #[test]
1063 fn verdict_ok_preserves_lines() {
1064 let (mode, degraded) = super::apply_verdict("lines:1-100", DegradationVerdictV1::Ok);
1065 assert_eq!(mode, "lines:1-100");
1066 assert!(!degraded);
1067 }
1068
1069 #[test]
1070 fn verdict_block_degrades_map_to_signatures() {
1071 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Block);
1072 assert_eq!(mode, "signatures");
1073 assert!(degraded);
1074 }
1075}