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