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 let file_summary = extract_file_summary(&output, path);
410 if !file_summary.is_empty() {
411 session.set_file_summary(path, &file_summary);
412 }
413 if is_cache_hit {
414 session.record_cache_hit();
415 }
416 if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
417 let touched: Vec<String> = session
418 .files_touched
419 .iter()
420 .map(|f| f.path.clone())
421 .collect();
422 let inferred =
423 crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
424 if inferred.confidence >= 0.4 {
425 session.active_structured_intent = Some(inferred);
426 }
427 }
428 if session.task.is_none() && session.stats.files_read % 5 == 0 {
430 session.auto_infer_task();
431 }
432 let root_missing = session
433 .project_root
434 .as_deref()
435 .is_none_or(|r| r.trim().is_empty());
436 if root_missing {
437 if let Some(root) = crate::core::protocol::detect_project_root(path) {
438 session.project_root = Some(root.clone());
439 ensured_root = Some(root);
440 }
441 }
442 project_root_snapshot = session
443 .project_root
444 .clone()
445 .unwrap_or_else(|| ".".to_string());
446 } else {
447 tracing::warn!(
448 "session write-lock timeout (5s) in ctx_read post-update for {path}"
449 );
450 project_root_snapshot = ctx.project_root.clone();
451 }
452 }
453
454 if let Some(root) = ensured_root.as_deref() {
455 crate::core::index_orchestrator::ensure_all_background(root);
456 }
457
458 crate::core::heatmap::record_file_access(path, original, saved);
459
460 {
462 let sig = crate::core::mode_predictor::FileSignature::from_path(path, original);
463 let density = if output_tokens > 0 {
464 original as f64 / output_tokens as f64
465 } else {
466 1.0
467 };
468 let outcome = crate::core::mode_predictor::ModeOutcome {
469 mode: resolved_mode.clone(),
470 tokens_in: original,
471 tokens_out: output_tokens,
472 density: density.min(1.0),
473 };
474 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
475 predictor.set_project_root(&project_root_snapshot);
476 predictor.record(sig, outcome);
477 predictor.save();
478
479 let ext = std::path::Path::new(path)
480 .extension()
481 .and_then(|e| e.to_str())
482 .unwrap_or("")
483 .to_string();
484 let thresholds = crate::core::adaptive_thresholds::thresholds_for_path(path);
485 let feedback_outcome = crate::core::feedback::CompressionOutcome {
486 session_id: format!("{}", std::process::id()),
487 language: ext,
488 entropy_threshold: thresholds.bpe_entropy,
489 jaccard_threshold: thresholds.jaccard,
490 total_turns: cache_stats.0 as u32,
491 tokens_saved: saved as u64,
492 tokens_original: original as u64,
493 cache_hits: cache_stats.1 as u32,
494 total_reads: cache_stats.0 as u32,
495 task_completed: true,
496 timestamp: chrono::Local::now().to_rfc3339(),
497 };
498 let mut store = crate::core::feedback::FeedbackStore::load();
499 store.project_root = Some(project_root_snapshot.clone());
500 store.record_outcome(feedback_outcome);
501 }
502
503 if let Some(aid) = resolved_agent_id.as_deref() {
504 crate::core::agent_budget::record_consumption(aid, output_tokens);
505 }
506
507 let hints_suffix = {
511 if let Some(index) = crate::core::graph_index::ProjectIndex::load(&ctx.project_root) {
512 let hints = crate::core::cross_source_hints::hints_for_file(path, &index.edges);
513 if hints.is_empty() {
514 String::new()
515 } else {
516 crate::core::cross_source_hints::format_hints(&hints)
517 }
518 } else {
519 String::new()
520 }
521 };
522
523 let mut warnings = Vec::new();
524 if let Some(ref w) = budget_warning {
525 warnings.push(w.as_str());
526 }
527 if let Some(ref w) = degrade_warning {
528 warnings.push(w.as_str());
529 }
530 let final_output = if !warnings.is_empty() {
531 format!("{output}{hints_suffix}\n\n{}", warnings.join("\n"))
532 } else if hints_suffix.is_empty() {
533 output
534 } else {
535 format!("{output}{hints_suffix}")
536 };
537
538 Ok(ToolOutput {
539 text: final_output,
540 original_tokens: original,
541 saved_tokens: saved,
542 mode: Some(resolved_mode),
543 path: Some(path.to_string()),
544 changed: false,
545 })
546 }
547}
548
549fn apply_verdict(
550 mode: &str,
551 verdict: crate::core::degradation_policy::DegradationVerdictV1,
552) -> (String, bool) {
553 use crate::core::degradation_policy::DegradationVerdictV1;
554 match verdict {
555 DegradationVerdictV1::Ok => (mode.to_string(), false),
556 DegradationVerdictV1::Warn => match mode {
557 "full" => ("map".to_string(), true),
558 other => (other.to_string(), false),
559 },
560 DegradationVerdictV1::Throttle => match mode {
561 "full" | "map" => ("signatures".to_string(), true),
562 other => (other.to_string(), false),
563 },
564 DegradationVerdictV1::Block => {
565 if mode == "signatures" {
566 ("signatures".to_string(), false)
567 } else {
568 ("signatures".to_string(), true)
569 }
570 }
571 }
572}
573
574fn auto_degrade_read_mode(mode: &str) -> (String, Option<String>) {
575 if crate::core::config::Config::load().no_degrade_effective() {
576 return (mode.to_string(), None);
577 }
578 let profile = crate::core::profiles::active_profile();
579 if !profile.degradation.enforce_effective() {
580 return (mode.to_string(), None);
581 }
582 let policy = crate::core::degradation_policy::evaluate_v1_for_tool("ctx_read", None);
583 let (new_mode, degraded) = apply_verdict(mode, policy.decision.verdict);
584 let warning = if degraded {
585 Some(format!(
586 "⚠ Context pressure: mode={mode} was downgraded to mode={new_mode} \
587 (verdict: {:?}). Use start_line=1 to bypass, or run ctx_compress to free budget.",
588 policy.decision.verdict
589 ))
590 } else {
591 None
592 };
593 (new_mode, warning)
594}
595
596fn extract_file_summary(output: &str, path: &str) -> String {
597 let hint = crate::core::auto_findings::extract_content_hint(output);
598 if !hint.is_empty() {
599 return hint;
600 }
601 let ext = std::path::Path::new(path)
602 .extension()
603 .and_then(|e| e.to_str())
604 .unwrap_or("");
605 let line_count = output.lines().count();
606 if line_count > 5 {
607 format!("{ext} file, {line_count} lines")
608 } else {
609 String::new()
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use std::sync::atomic::{AtomicUsize, Ordering};
617
618 #[test]
619 fn per_file_lock_same_path_returns_same_mutex() {
620 let lock_a1 = per_file_lock("/tmp/test_same_path.txt");
621 let lock_a2 = per_file_lock("/tmp/test_same_path.txt");
622 assert!(Arc::ptr_eq(&lock_a1, &lock_a2));
623 }
624
625 #[test]
626 fn per_file_lock_different_paths_return_different_mutexes() {
627 let lock_a = per_file_lock("/tmp/test_path_a.txt");
628 let lock_b = per_file_lock("/tmp/test_path_b.txt");
629 assert!(!Arc::ptr_eq(&lock_a, &lock_b));
630 }
631
632 #[test]
633 fn per_file_lock_serializes_concurrent_access() {
634 let counter = Arc::new(AtomicUsize::new(0));
635 let max_concurrent = Arc::new(AtomicUsize::new(0));
636 let path = "/tmp/test_concurrent_serialization.txt";
637 let mut handles = Vec::new();
638
639 for _ in 0..5 {
640 let counter = counter.clone();
641 let max_concurrent = max_concurrent.clone();
642 let path = path.to_string();
643 handles.push(std::thread::spawn(move || {
644 let lock = per_file_lock(&path);
645 let _guard = lock.lock().unwrap();
646 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
647 max_concurrent.fetch_max(active, Ordering::SeqCst);
648 std::thread::sleep(std::time::Duration::from_millis(10));
649 counter.fetch_sub(1, Ordering::SeqCst);
650 }));
651 }
652
653 for h in handles {
654 h.join().unwrap();
655 }
656
657 assert_eq!(max_concurrent.load(Ordering::SeqCst), 1);
658 }
659
660 #[test]
661 fn per_file_lock_allows_parallel_different_paths() {
662 let counter = Arc::new(AtomicUsize::new(0));
663 let max_concurrent = Arc::new(AtomicUsize::new(0));
664 let mut handles = Vec::new();
665
666 for i in 0..4 {
667 let counter = counter.clone();
668 let max_concurrent = max_concurrent.clone();
669 let path = format!("/tmp/test_parallel_{i}.txt");
670 handles.push(std::thread::spawn(move || {
671 let lock = per_file_lock(&path);
672 let _guard = lock.lock().unwrap();
673 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
674 max_concurrent.fetch_max(active, Ordering::SeqCst);
675 std::thread::sleep(std::time::Duration::from_millis(50));
676 counter.fetch_sub(1, Ordering::SeqCst);
677 }));
678 }
679
680 for h in handles {
681 h.join().unwrap();
682 }
683
684 assert!(max_concurrent.load(Ordering::SeqCst) > 1);
685 }
686
687 #[test]
691 fn zombie_thread_does_not_block_subsequent_cache_access() {
692 let cache: Arc<tokio::sync::RwLock<u32>> = Arc::new(tokio::sync::RwLock::new(0));
693
694 let zombie_lock = cache.clone();
696 let _zombie = std::thread::spawn(move || {
697 let _guard = zombie_lock.blocking_write();
698 std::thread::sleep(std::time::Duration::from_secs(2));
699 });
700 std::thread::sleep(std::time::Duration::from_millis(50));
701
702 assert!(cache.try_read().is_err());
704
705 let cancel = Arc::new(AtomicBool::new(false));
707 let cancel2 = cancel.clone();
708 let lock2 = cache.clone();
709 let waiter = std::thread::spawn(move || {
710 let start = std::time::Instant::now();
711 loop {
712 if cancel2.load(Ordering::Relaxed) {
713 return (false, start.elapsed());
714 }
715 if let Ok(_guard) = lock2.try_write() {
716 return (true, start.elapsed());
717 }
718 std::thread::sleep(std::time::Duration::from_millis(50));
719 }
720 });
721
722 std::thread::sleep(std::time::Duration::from_millis(200));
724 cancel.store(true, Ordering::Relaxed);
725
726 let (acquired, elapsed) = waiter.join().unwrap();
727 assert!(
728 !acquired,
729 "should not have acquired lock while zombie holds it"
730 );
731 assert!(
732 elapsed < std::time::Duration::from_secs(1),
733 "cancellation should have stopped the loop promptly"
734 );
735 }
736
737 fn apply_start_line(
740 mode: &mut String,
741 fresh: &mut bool,
742 explicit_mode: bool,
743 start_line: Option<i64>,
744 ) {
745 if let Some(sl) = start_line {
746 let sl = sl.max(1_i64);
747 if sl <= 1 {
748 return;
749 }
750 *fresh = true;
751 if !explicit_mode || mode.starts_with("lines") {
752 *mode = format!("lines:{sl}-999999");
753 }
754 }
755 }
756
757 #[test]
758 fn start_line_1_does_not_override_mode() {
759 let mut mode = "auto".to_string();
760 let mut fresh = false;
761 apply_start_line(&mut mode, &mut fresh, false, Some(1));
762 assert_eq!(mode, "auto", "start_line=1 should not change mode");
763 assert!(!fresh, "start_line=1 should not force fresh=true");
764 }
765
766 #[test]
767 fn start_line_gt1_overrides_implicit_mode() {
768 let mut mode = "auto".to_string();
769 let mut fresh = false;
770 apply_start_line(&mut mode, &mut fresh, false, Some(50));
771 assert_eq!(mode, "lines:50-999999");
772 assert!(fresh);
773 }
774
775 #[test]
776 fn start_line_gt1_does_not_override_explicit_map() {
777 let mut mode = "map".to_string();
779 let mut fresh = false;
780 apply_start_line(&mut mode, &mut fresh, true, Some(50));
781 assert_eq!(
782 mode, "map",
783 "explicit mode=map must not be clobbered by start_line"
784 );
785 assert!(fresh, "start_line>1 should still force fresh");
786 }
787
788 #[test]
789 fn start_line_gt1_does_not_override_explicit_signatures() {
790 let mut mode = "signatures".to_string();
791 let mut fresh = false;
792 apply_start_line(&mut mode, &mut fresh, true, Some(100));
793 assert_eq!(mode, "signatures");
794 assert!(fresh);
795 }
796
797 #[test]
798 fn start_line_gt1_honors_explicit_lines_mode() {
799 let mut mode = "lines:1-50".to_string();
800 let mut fresh = false;
801 apply_start_line(&mut mode, &mut fresh, true, Some(30));
802 assert_eq!(
803 mode, "lines:30-999999",
804 "explicit lines mode should accept start_line override"
805 );
806 assert!(fresh);
807 }
808
809 #[test]
810 fn start_line_none_does_nothing() {
811 let mut mode = "map".to_string();
812 let mut fresh = false;
813 apply_start_line(&mut mode, &mut fresh, true, None);
814 assert_eq!(mode, "map");
815 assert!(!fresh);
816 }
817
818 #[test]
819 fn start_line_1_with_explicit_mode_preserves_it() {
820 let mut mode = "map".to_string();
822 let mut fresh = false;
823 apply_start_line(&mut mode, &mut fresh, true, Some(1));
824 assert_eq!(mode, "map");
825 assert!(!fresh);
826 }
827
828 use crate::core::degradation_policy::DegradationVerdictV1;
832
833 #[test]
834 fn verdict_ok_does_not_degrade() {
835 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Ok);
836 assert_eq!(mode, "full");
837 assert!(!degraded);
838 }
839
840 #[test]
841 fn verdict_warn_degrades_full_to_map() {
842 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
843 assert_eq!(mode, "map");
844 assert!(degraded, "full→map must be flagged as degraded");
845 }
846
847 #[test]
848 fn verdict_warn_keeps_map() {
849 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Warn);
850 assert_eq!(mode, "map");
851 assert!(!degraded, "map is not degraded under Warn");
852 }
853
854 #[test]
855 fn verdict_warn_keeps_signatures() {
856 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Warn);
857 assert_eq!(mode, "signatures");
858 assert!(!degraded);
859 }
860
861 #[test]
862 fn verdict_throttle_degrades_full_to_signatures() {
863 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Throttle);
864 assert_eq!(mode, "signatures");
865 assert!(degraded);
866 }
867
868 #[test]
869 fn verdict_throttle_degrades_map_to_signatures() {
870 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Throttle);
871 assert_eq!(mode, "signatures");
872 assert!(degraded);
873 }
874
875 #[test]
876 fn verdict_throttle_keeps_lines() {
877 let (mode, degraded) = super::apply_verdict("lines:1-50", DegradationVerdictV1::Throttle);
878 assert_eq!(mode, "lines:1-50");
879 assert!(!degraded, "lines mode bypasses degradation");
880 }
881
882 #[test]
883 fn verdict_block_degrades_full_to_signatures() {
884 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Block);
885 assert_eq!(mode, "signatures");
886 assert!(degraded);
887 }
888
889 #[test]
890 fn verdict_block_does_not_degrade_signatures() {
891 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Block);
892 assert_eq!(mode, "signatures");
893 assert!(!degraded, "already at signatures — no degradation needed");
894 }
895
896 #[test]
897 fn degrade_warning_message_contains_mode_info() {
898 let (new_mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
899 assert!(degraded);
900 let warning = format!(
901 "⚠ Context pressure: mode=full was downgraded to mode={new_mode} (verdict: {:?}).",
902 DegradationVerdictV1::Warn
903 );
904 assert!(warning.contains("mode=full"));
905 assert!(warning.contains("mode=map"));
906 assert!(warning.contains("Warn"));
907 }
908
909 #[test]
914 fn auto_degrade_preserves_full_when_default_config() {
915 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
916 return;
917 }
918 let (mode, warning) = super::auto_degrade_read_mode("full");
919 assert_eq!(mode, "full");
920 assert!(warning.is_none());
921 }
922
923 #[test]
924 fn auto_degrade_preserves_map_when_default_config() {
925 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
926 return;
927 }
928 let (mode, warning) = super::auto_degrade_read_mode("map");
929 assert_eq!(mode, "map");
930 assert!(warning.is_none());
931 }
932
933 #[test]
934 fn auto_degrade_preserves_signatures_when_default_config() {
935 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
936 return;
937 }
938 let (mode, warning) = super::auto_degrade_read_mode("signatures");
939 assert_eq!(mode, "signatures");
940 assert!(warning.is_none());
941 }
942
943 #[test]
944 fn auto_degrade_preserves_diff_always() {
945 let (mode, warning) = super::auto_degrade_read_mode("diff");
946 assert_eq!(mode, "diff");
947 assert!(warning.is_none());
948 }
949
950 #[test]
951 fn auto_degrade_preserves_lines_mode_always() {
952 let (mode, warning) = super::auto_degrade_read_mode("lines:10-50");
953 assert_eq!(mode, "lines:10-50");
954 assert!(warning.is_none());
955 }
956
957 #[test]
958 fn auto_degrade_preserves_aggressive_when_default_config() {
959 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
960 return;
961 }
962 let (mode, warning) = super::auto_degrade_read_mode("aggressive");
963 assert_eq!(mode, "aggressive");
964 assert!(warning.is_none());
965 }
966
967 #[test]
968 fn auto_degrade_preserves_entropy_when_default_config() {
969 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
970 return;
971 }
972 let (mode, warning) = super::auto_degrade_read_mode("entropy");
973 assert_eq!(mode, "entropy");
974 assert!(warning.is_none());
975 }
976
977 #[test]
978 fn auto_degrade_preserves_auto_when_default_config() {
979 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
980 return;
981 }
982 let (mode, warning) = super::auto_degrade_read_mode("auto");
983 assert_eq!(mode, "auto");
984 assert!(warning.is_none());
985 }
986
987 #[test]
990 fn verdict_warn_does_not_degrade_diff() {
991 let (mode, degraded) = super::apply_verdict("diff", DegradationVerdictV1::Warn);
992 assert_eq!(mode, "diff");
993 assert!(!degraded);
994 }
995
996 #[test]
997 fn verdict_throttle_does_not_degrade_signatures() {
998 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Throttle);
999 assert_eq!(mode, "signatures");
1000 assert!(!degraded);
1001 }
1002
1003 #[test]
1004 fn verdict_ok_preserves_map() {
1005 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Ok);
1006 assert_eq!(mode, "map");
1007 assert!(!degraded);
1008 }
1009
1010 #[test]
1011 fn verdict_ok_preserves_signatures() {
1012 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Ok);
1013 assert_eq!(mode, "signatures");
1014 assert!(!degraded);
1015 }
1016
1017 #[test]
1018 fn verdict_ok_preserves_lines() {
1019 let (mode, degraded) = super::apply_verdict("lines:1-100", DegradationVerdictV1::Ok);
1020 assert_eq!(mode, "lines:1-100");
1021 assert!(!degraded);
1022 }
1023
1024 #[test]
1025 fn verdict_block_degrades_map_to_signatures() {
1026 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Block);
1027 assert_eq!(mode, "signatures");
1028 assert!(degraded);
1029 }
1030}