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