1use std::path::Path;
2
3use crate::core::cache::SessionCache;
4use crate::core::compressor;
5use crate::core::deps;
6use crate::core::entropy;
7use crate::core::plugins::{executor::HookPoint, PluginManager};
8use crate::core::protocol;
9use crate::core::signatures;
10use crate::core::symbol_map::{self, SymbolMap};
11use crate::core::tokens::count_tokens;
12use crate::tools::CrpMode;
13pub(crate) mod render;
16pub(crate) use render::*;
17#[cfg(test)]
18mod tests;
19
20pub struct ReadOutput {
23 pub content: String,
24 pub resolved_mode: String,
25 pub output_tokens: usize,
28}
29
30const COMPRESSED_HINT: &str = "[compressed — use mode=\"full\" for complete source]";
31
32const CACHEABLE_MODES: &[&str] = &["map", "signatures"];
33
34fn is_cacheable_mode(mode: &str) -> bool {
35 CACHEABLE_MODES.contains(&mode)
36}
37
38fn compressed_cache_key(mode: &str, crp_mode: CrpMode, task: Option<&str>) -> String {
39 let versioned_mode = match mode {
42 "map" => "map:v2",
43 "signatures" => "signatures:v2",
44 _ => mode,
45 };
46 let base = if crp_mode.is_tdd() {
47 format!("{versioned_mode}:tdd")
48 } else {
49 versioned_mode.to_string()
50 };
51 match task.map(str::trim).filter(|t| !t.is_empty()) {
54 Some(t) => {
55 use std::hash::{Hash, Hasher};
56 let mut h = std::collections::hash_map::DefaultHasher::new();
57 t.hash(&mut h);
58 format!("{base}:t{:x}", h.finish())
59 }
60 None => base,
61 }
62}
63
64fn cache_hit_proof_line(content: &str, read_count: u32) -> Option<String> {
68 if read_count < 2 {
69 return None;
70 }
71 let first_line = content.lines().find(|l| !l.trim().is_empty())?;
72 let trimmed = first_line.trim();
73 if trimmed.len() > 60 {
74 let mut end = 57;
75 while end > 0 && !trimmed.is_char_boundary(end) {
76 end -= 1;
77 }
78 Some(format!("{}...", &trimmed[..end]))
79 } else {
80 Some(trimmed.to_string())
81 }
82}
83
84fn append_compressed_hint(output: &str, file_path: &str) -> String {
85 if !crate::core::profiles::active_profile()
86 .output_hints
87 .compressed_hint()
88 {
89 return output.to_string();
90 }
91 format!(
92 "{output}\n{COMPRESSED_HINT}\n ctx_read(\"{file_path}\", mode=\"full\") | ctx_retrieve(\"{file_path}\")"
93 )
94}
95
96pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
100 if crate::core::binary_detect::is_binary_file(path) {
101 let msg = crate::core::binary_detect::binary_file_message(path);
102 return Err(std::io::Error::other(msg));
103 }
104
105 {
106 let canonical =
107 crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
108 if let Ok(cwd) = std::env::current_dir() {
109 let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
110 if !canonical.starts_with(&root) {
111 let allow = crate::core::pathjail::allow_paths_from_env_and_config();
112 let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
113 .ok()
114 .is_some_and(|d| canonical.starts_with(d));
115 let tmp_ok = canonical.starts_with(std::env::temp_dir());
116 if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
117 tracing::warn!(
118 "defense-in-depth: path may escape project root: {}",
119 canonical.display()
120 );
121 }
122 }
123 }
124 }
125
126 let cap = crate::core::limits::max_read_bytes();
127
128 let file = open_with_retry(path)?;
129 let meta = file
130 .metadata()
131 .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
132 if meta.len() > cap as u64 {
133 return Err(std::io::Error::other(format!(
134 "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
135 Increase the limit or use a line-range read: mode=\"lines:1-100\"",
136 meta.len(),
137 cap
138 )));
139 }
140
141 use std::io::Read;
142 let mut bytes = Vec::with_capacity(meta.len() as usize);
143 std::io::BufReader::new(file).read_to_end(&mut bytes)?;
144 match String::from_utf8(bytes) {
145 Ok(s) => Ok(s),
146 Err(e) => Ok(String::from_utf8_lossy(e.as_bytes()).into_owned()),
147 }
148}
149
150fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
154 match open_nofollow(path) {
155 Ok(f) => Ok(f),
156 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
157 std::thread::sleep(std::time::Duration::from_millis(50));
158 open_nofollow(path).map_err(|e| {
159 if e.kind() == std::io::ErrorKind::NotFound {
160 std::io::Error::other(format!(
161 "file not found: {path} — verify the path with ctx_tree or ctx_search"
162 ))
163 } else {
164 e
165 }
166 })
167 }
168 Err(e) => Err(e),
169 }
170}
171
172#[cfg(unix)]
173fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
174 use std::os::unix::fs::OpenOptionsExt;
175 use std::path::Path;
176
177 let p = Path::new(path);
178 if let (Some(parent), Some(filename)) = (p.parent(), p.file_name()) {
183 if parent.exists() {
184 let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
185 let canonical_path = canonical_parent.join(filename);
186 return std::fs::OpenOptions::new()
187 .read(true)
188 .custom_flags(libc::O_NOFOLLOW)
189 .open(&canonical_path);
190 }
191 }
192
193 std::fs::OpenOptions::new()
195 .read(true)
196 .custom_flags(libc::O_NOFOLLOW)
197 .open(path)
198}
199
200#[cfg(not(unix))]
201fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
202 std::fs::File::open(path)
203}
204
205pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
207 handle_with_options(cache, path, mode, false, crp_mode, None)
208}
209
210pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
212 handle_with_options(cache, path, mode, true, crp_mode, None)
213}
214
215pub fn handle_with_task(
217 cache: &mut SessionCache,
218 path: &str,
219 mode: &str,
220 crp_mode: CrpMode,
221 task: Option<&str>,
222) -> String {
223 handle_with_options(cache, path, mode, false, crp_mode, task)
224}
225
226pub fn handle_with_task_resolved(
228 cache: &mut SessionCache,
229 path: &str,
230 mode: &str,
231 crp_mode: CrpMode,
232 task: Option<&str>,
233) -> ReadOutput {
234 handle_with_options_resolved(cache, path, mode, false, crp_mode, task)
235}
236
237pub fn handle_fresh_with_task(
239 cache: &mut SessionCache,
240 path: &str,
241 mode: &str,
242 crp_mode: CrpMode,
243 task: Option<&str>,
244) -> String {
245 handle_with_options(cache, path, mode, true, crp_mode, task)
246}
247
248pub fn handle_fresh_with_task_resolved(
250 cache: &mut SessionCache,
251 path: &str,
252 mode: &str,
253 crp_mode: CrpMode,
254 task: Option<&str>,
255) -> ReadOutput {
256 handle_with_options_resolved(cache, path, mode, true, crp_mode, task)
257}
258
259fn handle_with_options(
260 cache: &mut SessionCache,
261 path: &str,
262 mode: &str,
263 fresh: bool,
264 crp_mode: CrpMode,
265 task: Option<&str>,
266) -> String {
267 handle_with_options_resolved(cache, path, mode, fresh, crp_mode, task).content
268}
269
270fn is_subagent_context() -> bool {
273 static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
274 *IS_SUBAGENT.get_or_init(|| {
275 if std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true") {
276 return true;
277 }
278 std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
279 })
280}
281
282fn handle_with_options_resolved(
283 cache: &mut SessionCache,
284 path: &str,
285 mode: &str,
286 fresh: bool,
287 crp_mode: CrpMode,
288 task: Option<&str>,
289) -> ReadOutput {
290 let effective_fresh = fresh || is_subagent_context();
291
292 if PluginManager::has_listener("pre_read") {
295 PluginManager::fire_hook_background(HookPoint::PreRead {
296 path: path.to_string(),
297 });
298 }
299
300 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
301 bt.next_seq();
302 }
303 let mut result = handle_with_options_inner(cache, path, mode, effective_fresh, crp_mode, task);
304
305 if let Some(entry) = cache.get_mut(path) {
306 entry.last_mode.clone_from(&result.resolved_mode);
307 }
308
309 let dedup_allowed = matches!(
310 result.resolved_mode.as_str(),
311 "map" | "signatures" | "aggressive" | "entropy" | "task"
312 );
313 if dedup_allowed {
314 if let Some(deduped) = cache.apply_dedup(path, &result.content) {
315 let new_tokens = count_tokens(&deduped);
316 if new_tokens < result.output_tokens {
317 result.content = deduped;
318 result.output_tokens = new_tokens;
319 }
320 }
321 }
322
323 if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
324 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
325 bt.record_read(
326 path,
327 &result.resolved_mode,
328 result.output_tokens,
329 original_tokens,
330 );
331
332 let compressed = !matches!(result.resolved_mode.as_str(), "full" | "diff" | "lines");
337 if compressed {
338 crate::core::adaptive_thresholds::record_quality_signal(
339 path,
340 crate::core::threshold_learning::QualitySignal::CleanCompressed,
341 );
342 } else if result.resolved_mode == "full"
343 && result.output_tokens > 2000
344 && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
345 {
346 crate::core::adaptive_thresholds::record_quality_signal(
347 path,
348 crate::core::threshold_learning::QualitySignal::WastedFull,
349 );
350 }
351 }
352
353 if PluginManager::has_listener("post_compress") {
355 let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
356 PluginManager::fire_hook_background(HookPoint::PostCompress {
357 path: path.to_string(),
358 original_tokens,
359 compressed_tokens: result.output_tokens,
360 });
361 }
362
363 {
368 let self_agent = crate::core::scent_field::scent_agent_id();
369 let scent_path = crate::core::pathutil::normalize_tool_path(path);
370 std::thread::spawn(move || {
371 crate::core::scent_field::deposit(
372 self_agent,
373 crate::core::scent_field::ScentKind::Hot,
374 &scent_path,
375 0.3,
376 );
377 });
378 if let Some(hint) = crate::core::scent_field::read_hint(path, self_agent) {
379 result.content.push('\n');
380 result.content.push_str(&hint);
381 }
382 }
383
384 result
385}
386
387pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
398 let file_ref = cache.get_file_ref_readonly(path)?;
399 let (cached_mtime, cached_hash, read_count, line_count, content_opt) = {
400 let entry = cache.get(path)?;
401 (
402 entry.stored_mtime,
403 entry.hash.clone(),
404 entry.read_count(),
405 entry.line_count,
406 entry.content(),
407 )
408 };
409
410 let no_deg = crate::core::config::Config::load().no_degrade_effective();
411 let prof = crate::core::profiles::active_profile();
412 let force_full = no_deg
413 || (prof.read.default_mode_effective() == "full"
414 && prof.compression.crp_mode_effective() == "off");
415 let policy_allows_stub =
416 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
417 if !policy_allows_stub
418 || crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
419 || !cache.is_full_delivered(path)
420 {
421 return None;
422 }
423
424 cache.record_cache_hit(path);
425 let short = protocol::shorten_path(path);
426 let out = if crate::core::protocol::meta_visible() {
427 format!(
428 "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
429 )
430 } else {
431 let proof = content_opt
432 .as_deref()
433 .and_then(|c| cache_hit_proof_line(c, read_count));
434 let reads_note = if read_count > 3 {
435 format!(" (read {}x)", read_count + 1)
436 } else {
437 String::new()
438 };
439 match proof {
440 Some(p) => {
441 format!("{file_ref}={short} [unchanged {line_count}L{reads_note} | \"{p}\"]")
442 }
443 None => format!("{file_ref}={short} [unchanged {line_count}L{reads_note}]"),
444 }
445 };
446 let out = crate::core::redaction::redact_text_if_enabled(&out);
447 let sent = count_tokens(&out);
448 Some(ReadOutput {
449 content: out,
450 resolved_mode: "full".into(),
451 output_tokens: sent,
452 })
453}
454
455fn handle_with_options_inner(
456 cache: &mut SessionCache,
457 path: &str,
458 mode: &str,
459 fresh: bool,
460 crp_mode: CrpMode,
461 task: Option<&str>,
462) -> ReadOutput {
463 let file_ref = cache.get_file_ref(path);
464 let short = protocol::shorten_path(path);
465 let ext = Path::new(path)
466 .extension()
467 .and_then(|e| e.to_str())
468 .unwrap_or("");
469
470 if fresh {
471 if mode == "diff" {
472 let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
473 return ReadOutput {
474 content: warning.to_string(),
475 resolved_mode: "diff".into(),
476 output_tokens: count_tokens(warning),
477 };
478 }
479 cache.invalidate(path);
480 }
481
482 if mode == "diff" {
483 let (out, _) = handle_diff(cache, path, &file_ref);
484 let out = crate::core::redaction::redact_text_if_enabled(&out);
485 let sent = count_tokens(&out);
486 return ReadOutput {
487 content: out,
488 resolved_mode: "diff".into(),
489 output_tokens: sent,
490 };
491 }
492
493 if mode != "full" {
494 if let Some(existing) = cache.get(path) {
495 let stale = crate::core::cache::is_cache_entry_stale_verified(
496 path,
497 existing.stored_mtime,
498 &existing.hash,
499 );
500 if stale {
501 cache.invalidate(path);
502 }
503 }
504 }
505
506 let cache_snapshot = cache
509 .get(path)
510 .map(|existing| (existing.original_tokens, existing.content()));
511
512 if let Some((original_tokens, content_opt)) = cache_snapshot {
513 if mode == "full" {
514 if let Some(out) = try_stub_hit_readonly(cache, path) {
517 return out;
518 }
519 let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
520 let out = crate::core::redaction::redact_text_if_enabled(&out);
521 let sent = count_tokens(&out);
522 return ReadOutput {
523 content: out,
524 resolved_mode: "full".into(),
525 output_tokens: sent,
526 };
527 }
528
529 let resolved_mode = if mode == "auto" {
532 resolve_auto_mode(path, original_tokens, task)
533 } else {
534 mode.to_string()
535 };
536
537 if is_cacheable_mode(&resolved_mode) {
538 let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
539 let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
540 if let Some(cached_output) = compressed_hit {
541 cache.record_cache_hit(path);
542 let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
543 let sent = count_tokens(&out);
544 return ReadOutput {
545 content: out,
546 resolved_mode,
547 output_tokens: sent,
548 };
549 }
550 }
551
552 if let Some(content) = content_opt {
553 let (out, _) = process_mode(
554 &content,
555 &resolved_mode,
556 &file_ref,
557 &short,
558 ext,
559 original_tokens,
560 crp_mode,
561 path,
562 task,
563 );
564 if is_cacheable_mode(&resolved_mode) {
565 let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
566 cache.set_compressed(path, &cache_key, out.clone());
567 }
568 let out = if mode == "auto" {
571 let framed_tokens = count_tokens(&out);
572 cap_to_raw(out, framed_tokens, &content, original_tokens)
573 } else {
574 out
575 };
576 let out = crate::core::redaction::redact_text_if_enabled(&out);
577 let sent = count_tokens(&out);
578 return ReadOutput {
579 content: out,
580 resolved_mode,
581 output_tokens: sent,
582 };
583 }
584 cache.invalidate(path);
585 }
586
587 let content = match read_file_lossy(path) {
588 Ok(c) => c,
589 Err(e) => {
590 let msg = format!("ERROR: {e}");
591 let tokens = count_tokens(&msg);
592 return ReadOutput {
593 content: msg,
594 resolved_mode: "error".into(),
595 output_tokens: tokens,
596 };
597 }
598 };
599
600 let store_result = cache.store(path, &content);
601
602 let is_line_range = mode.starts_with("lines:");
605 let hints = crate::core::profiles::active_profile().output_hints;
606 let is_repeat_read = store_result.read_count > 1;
607 let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
608 find_similar_and_update_semantic_index(path, &content)
609 } else {
610 None
611 };
612 let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
613 build_graph_related_hint(path)
614 } else {
615 None
616 };
617
618 if mode == "full" {
619 cache.mark_full_delivered(path);
620 let (mut output, _) = format_full_output(
621 &file_ref,
622 &short,
623 ext,
624 &content,
625 store_result.original_tokens,
626 store_result.line_count,
627 task,
628 );
629 if let Some(hint) = &graph_hint {
630 output.push_str(&format!("\n{hint}"));
631 }
632 if let Some(hint) = similar_hint {
633 output.push_str(&format!("\n{hint}"));
634 }
635 let framed_tokens = count_tokens(&output);
636 let output = cap_to_raw(
637 output,
638 framed_tokens,
639 &content,
640 store_result.original_tokens,
641 );
642 let output = crate::core::redaction::redact_text_if_enabled(&output);
643 let sent = count_tokens(&output);
644 return ReadOutput {
645 content: output,
646 resolved_mode: "full".into(),
647 output_tokens: sent,
648 };
649 }
650
651 let resolved_mode = if mode == "auto" {
652 resolve_auto_mode(path, store_result.original_tokens, task)
653 } else {
654 mode.to_string()
655 };
656
657 let (mut output, _sent) = process_mode(
658 &content,
659 &resolved_mode,
660 &file_ref,
661 &short,
662 ext,
663 store_result.original_tokens,
664 crp_mode,
665 path,
666 task,
667 );
668 if let Some(hint) = &graph_hint {
669 output.push_str(&format!("\n{hint}"));
670 }
671 if let Some(hint) = similar_hint {
672 output.push_str(&format!("\n{hint}"));
673 }
674 if is_cacheable_mode(&resolved_mode) {
675 let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
676 cache.set_compressed(path, &cache_key, output.clone());
677 }
678 let output = if mode == "auto" {
682 let framed_tokens = count_tokens(&output);
683 cap_to_raw(
684 output,
685 framed_tokens,
686 &content,
687 store_result.original_tokens,
688 )
689 } else {
690 output
691 };
692 let output = crate::core::redaction::redact_text_if_enabled(&output);
693 let final_tokens = count_tokens(&output);
694 ReadOutput {
695 content: output,
696 resolved_mode,
697 output_tokens: final_tokens,
698 }
699}
700
701pub fn is_instruction_file(path: &str) -> bool {
702 let lower = path.to_lowercase();
703 let filename = std::path::Path::new(&lower)
704 .file_name()
705 .and_then(|f| f.to_str())
706 .unwrap_or("");
707
708 matches!(
709 filename,
710 "skill.md"
711 | "agents.md"
712 | "rules.md"
713 | ".cursorrules"
714 | ".clinerules"
715 | "lean-ctx.md"
716 | "lean-ctx.mdc"
717 ) || lower.contains("/skills/")
718 || lower.contains("/.cursor/rules/")
719 || lower.contains("/.claude/rules/")
720 || lower.contains("/agents.md")
721}
722
723fn cap_to_raw(
738 framed: String,
739 framed_tokens: usize,
740 raw_content: &str,
741 raw_tokens: usize,
742) -> String {
743 if raw_tokens > 0 && framed_tokens > raw_tokens {
744 raw_content.to_string()
745 } else {
746 framed
747 }
748}
749
750fn resolve_auto_mode(file_path: &str, original_tokens: usize, task: Option<&str>) -> String {
752 let ctx = crate::core::auto_mode_resolver::AutoModeContext {
753 path: file_path,
754 token_count: original_tokens,
755 task,
756 cache: None,
757 };
758 crate::core::auto_mode_resolver::resolve(&ctx).mode
759}
760
761fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
762 const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
763
764 if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
765 return None;
766 }
767
768 let cfg = crate::core::config::Config::load();
769 let profile = crate::core::config::MemoryProfile::effective(&cfg);
770 if !profile.semantic_cache_enabled() {
771 return None;
772 }
773
774 let project_root = detect_project_root(path);
775 let session_id = format!("{}", std::process::id());
776 let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
777
778 let similar = index.find_similar(content, 0.7);
779 let relevant: Vec<_> = similar
780 .into_iter()
781 .filter(|(p, _)| p != path)
782 .take(3)
783 .collect();
784
785 index.add_file(path, content, &session_id);
786 if let Err(e) = index.save(&project_root) {
787 tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
788 }
789
790 if relevant.is_empty() {
791 return None;
792 }
793
794 let hints: Vec<String> = relevant
795 .iter()
796 .map(|(p, score)| format!(" {p} ({:.0}% similar)", score * 100.0))
797 .collect();
798
799 Some(format!(
800 "[semantic: {} similar file(s) in cache]\n{}",
801 relevant.len(),
802 hints.join("\n")
803 ))
804}
805
806fn detect_project_root(path: &str) -> String {
807 crate::core::protocol::detect_project_root_or_cwd(path)
808}
809
810fn build_graph_related_hint(path: &str) -> Option<String> {
811 let project_root = detect_project_root(path);
812 crate::core::graph_context::build_related_hint(path, &project_root, 5)
813}
814
815const AUTO_DELTA_THRESHOLD: f64 = 0.6;
816
817fn handle_full_with_auto_delta(
819 cache: &mut SessionCache,
820 path: &str,
821 file_ref: &str,
822 short: &str,
823 ext: &str,
824 task: Option<&str>,
825) -> (String, usize) {
826 let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
827 let Ok(disk_content) = read_file_lossy(path) else {
828 cache.record_cache_hit(path);
829 if let Some(existing) = cache.get(path) {
830 if !crate::core::protocol::meta_visible() {
831 if let Some(cached) = existing.content() {
832 return format_full_output(
833 file_ref,
834 short,
835 ext,
836 &cached,
837 existing.original_tokens,
838 existing.line_count,
839 task,
840 );
841 }
842 }
843 let out = format!(
844 "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
845 existing.read_count(),
846 existing.line_count
847 );
848 let sent = count_tokens(&out);
849 return (out, sent);
850 }
851 let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
852 format!("[file read failed and no cached version available] {file_ref}={short}")
853 } else {
854 format!("[file read failed and no cached version available] {short}")
855 };
856 let sent = count_tokens(&out);
857 return (out, sent);
858 };
859
860 let no_deg = crate::core::config::Config::load().no_degrade_effective();
861 let prof = crate::core::profiles::active_profile();
862 let force_full = no_deg
863 || (prof.read.default_mode_effective() == "full"
864 && prof.compression.crp_mode_effective() == "off");
865
866 let old_content = cache
867 .get(path)
868 .and_then(crate::core::cache::CacheEntry::content)
869 .unwrap_or_default();
870 let store_result = cache.store(path, &disk_content);
871
872 if store_result.was_hit {
873 let policy_allows_stub =
874 crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
875 if policy_allows_stub && store_result.full_content_delivered {
876 let out = if crate::core::protocol::meta_visible() {
877 format!(
878 "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
879 store_result.line_count
880 )
881 } else {
882 let proof = cache_hit_proof_line(&disk_content, store_result.read_count);
883 let reads_note = if store_result.read_count > 3 {
884 format!(" (read {}x)", store_result.read_count)
885 } else {
886 String::new()
887 };
888 match proof {
889 Some(p) => format!(
890 "{file_ref}={short} [unchanged {}L{reads_note} | \"{p}\"]",
891 store_result.line_count
892 ),
893 None => format!(
894 "{file_ref}={short} [unchanged {}L{reads_note}]",
895 store_result.line_count
896 ),
897 }
898 };
899 let sent = count_tokens(&out);
900 return (out, sent);
901 }
902 cache.mark_full_delivered(path);
903 return format_full_output(
904 file_ref,
905 short,
906 ext,
907 &disk_content,
908 store_result.original_tokens,
909 store_result.line_count,
910 task,
911 );
912 }
913
914 let diff = compressor::diff_content(&old_content, &disk_content);
915 let diff_tokens = count_tokens(&diff);
916 let full_tokens = store_result.original_tokens;
917
918 if !force_full
919 && full_tokens > 0
920 && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
921 {
922 let savings = protocol::format_savings(full_tokens, diff_tokens);
923 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
924 format!("{file_ref}={short}")
925 } else {
926 short.to_string()
927 };
928 let out = format!(
929 "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
930 disk_content.lines().count()
931 );
932 return (out, diff_tokens);
933 }
934
935 format_full_output(
936 file_ref,
937 short,
938 ext,
939 &disk_content,
940 store_result.original_tokens,
941 store_result.line_count,
942 task,
943 )
944}
945
946fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
947 let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
948 let short = protocol::shorten_path(path);
949 let old_content = cache
950 .get(path)
951 .and_then(crate::core::cache::CacheEntry::content);
952
953 let new_content = match read_file_lossy(path) {
954 Ok(c) => c,
955 Err(e) => {
956 let msg = format!("ERROR: {e}");
957 let tokens = count_tokens(&msg);
958 return (msg, tokens);
959 }
960 };
961
962 let original_tokens = count_tokens(&new_content);
963
964 let diff_output = if let Some(old) = &old_content {
965 compressor::diff_content(old, &new_content)
966 } else {
967 cache.store(path, &new_content);
970 let msg = format!(
971 "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
972 );
973 let sent = count_tokens(&msg);
974 return (msg, sent);
975 };
976
977 cache.store(path, &new_content);
978
979 let sent = count_tokens(&diff_output);
980 let savings = protocol::format_savings(original_tokens, sent);
981 let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
982 format!("{file_ref}={short}")
983 } else {
984 short.clone()
985 };
986 (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
987}