Skip to main content

lean_ctx/tools/ctx_read/
mod.rs

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::protocol;
8use crate::core::signatures;
9use crate::core::symbol_map::{self, SymbolMap};
10use crate::core::tokens::count_tokens;
11use crate::tools::CrpMode;
12mod render;
13pub(crate) use render::*;
14#[cfg(test)]
15mod tests;
16
17/// Pre-counted read output carrying the output string, resolved mode,
18/// and token count computed during mode processing.
19pub struct ReadOutput {
20    pub content: String,
21    pub resolved_mode: String,
22    /// Approximate output token count from mode processing.
23    /// The dispatch layer recounts the final assembled string for accurate savings.
24    pub output_tokens: usize,
25}
26
27const COMPRESSED_HINT: &str = "[compressed — use mode=\"full\" for complete source]";
28
29const CACHEABLE_MODES: &[&str] = &["map", "signatures"];
30
31fn is_cacheable_mode(mode: &str) -> bool {
32    CACHEABLE_MODES.contains(&mode)
33}
34
35fn compressed_cache_key(mode: &str, crp_mode: CrpMode, task: Option<&str>) -> String {
36    // Bump when the rendered map/signatures body changes shape so stale
37    // pre-line-range entries are not served from an older session cache.
38    let versioned_mode = match mode {
39        "map" => "map:v2",
40        "signatures" => "signatures:v2",
41        _ => mode,
42    };
43    let base = if crp_mode.is_tdd() {
44        format!("{versioned_mode}:tdd")
45    } else {
46        versioned_mode.to_string()
47    };
48    // map/signatures output now embeds a task-relevant body, so task-aware and
49    // task-free variants must cache under distinct keys.
50    match task.map(str::trim).filter(|t| !t.is_empty()) {
51        Some(t) => {
52            use std::hash::{Hash, Hasher};
53            let mut h = std::collections::hash_map::DefaultHasher::new();
54            t.hash(&mut h);
55            format!("{base}:t{:x}", h.finish())
56        }
57        None => base,
58    }
59}
60
61/// Extracts a short proof-line from file content to include in cache-hit stubs.
62/// Returns the first non-empty line (truncated to 60 chars) as evidence the cache is valid.
63/// Only shown after 2+ reads to avoid noise on early interactions.
64fn cache_hit_proof_line(content: &str, read_count: u32) -> Option<String> {
65    if read_count < 2 {
66        return None;
67    }
68    let first_line = content.lines().find(|l| !l.trim().is_empty())?;
69    let trimmed = first_line.trim();
70    if trimmed.len() > 60 {
71        let mut end = 57;
72        while end > 0 && !trimmed.is_char_boundary(end) {
73            end -= 1;
74        }
75        Some(format!("{}...", &trimmed[..end]))
76    } else {
77        Some(trimmed.to_string())
78    }
79}
80
81fn append_compressed_hint(output: &str, file_path: &str) -> String {
82    if !crate::core::profiles::active_profile()
83        .output_hints
84        .compressed_hint()
85    {
86        return output.to_string();
87    }
88    format!(
89        "{output}\n{COMPRESSED_HINT}\n  ctx_read(\"{file_path}\", mode=\"full\") | ctx_retrieve(\"{file_path}\")"
90    )
91}
92
93/// Reads a file as UTF-8 with lossy fallback, enforcing binary detection and max read size limit.
94/// Defense-in-depth: verifies that the canonical path stays within the process's project root
95/// (if determinable) even though callers SHOULD have already jail-checked the path.
96pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
97    if crate::core::binary_detect::is_binary_file(path) {
98        let msg = crate::core::binary_detect::binary_file_message(path);
99        return Err(std::io::Error::other(msg));
100    }
101
102    {
103        let canonical =
104            crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
105        if let Ok(cwd) = std::env::current_dir() {
106            let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
107            if !canonical.starts_with(&root) {
108                let allow = crate::core::pathjail::allow_paths_from_env_and_config();
109                let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
110                    .ok()
111                    .is_some_and(|d| canonical.starts_with(d));
112                let tmp_ok = canonical.starts_with(std::env::temp_dir());
113                if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
114                    tracing::warn!(
115                        "defense-in-depth: path may escape project root: {}",
116                        canonical.display()
117                    );
118                }
119            }
120        }
121    }
122
123    let cap = crate::core::limits::max_read_bytes();
124
125    let file = open_with_retry(path)?;
126    let meta = file
127        .metadata()
128        .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
129    if meta.len() > cap as u64 {
130        return Err(std::io::Error::other(format!(
131            "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
132             Increase the limit or use a line-range read: mode=\"lines:1-100\"",
133            meta.len(),
134            cap
135        )));
136    }
137
138    use std::io::Read;
139    let mut bytes = Vec::with_capacity(meta.len() as usize);
140    std::io::BufReader::new(file).read_to_end(&mut bytes)?;
141    match String::from_utf8(bytes) {
142        Ok(s) => Ok(s),
143        Err(e) => Ok(String::from_utf8_lossy(e.as_bytes()).into_owned()),
144    }
145}
146
147/// Opens a file, retrying once after a brief pause on NotFound.
148/// Works around overlay/FUSE stat-cache races in container runtimes (Docker, Codex).
149/// Uses O_NOFOLLOW on Unix for TOCTOU symlink protection.
150fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
151    match open_nofollow(path) {
152        Ok(f) => Ok(f),
153        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
154            std::thread::sleep(std::time::Duration::from_millis(50));
155            open_nofollow(path).map_err(|e| {
156                if e.kind() == std::io::ErrorKind::NotFound {
157                    std::io::Error::other(format!(
158                        "file not found: {path} — verify the path with ctx_tree or ctx_search"
159                    ))
160                } else {
161                    e
162                }
163            })
164        }
165        Err(e) => Err(e),
166    }
167}
168
169#[cfg(unix)]
170fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
171    use std::os::unix::fs::OpenOptionsExt;
172    use std::path::Path;
173
174    let p = Path::new(path);
175    // Canonicalize the parent directory (resolving symlinks in the directory path)
176    // but apply O_NOFOLLOW only to the final file component. This prevents
177    // symlink-following attacks on the target file while allowing legitimate
178    // directory symlinks (e.g., /tmp → /private/tmp on macOS).
179    if let (Some(parent), Some(filename)) = (p.parent(), p.file_name()) {
180        if parent.exists() {
181            let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
182            let canonical_path = canonical_parent.join(filename);
183            return std::fs::OpenOptions::new()
184                .read(true)
185                .custom_flags(libc::O_NOFOLLOW)
186                .open(&canonical_path);
187        }
188    }
189
190    // Fallback: direct open with O_NOFOLLOW
191    std::fs::OpenOptions::new()
192        .read(true)
193        .custom_flags(libc::O_NOFOLLOW)
194        .open(path)
195}
196
197#[cfg(not(unix))]
198fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
199    std::fs::File::open(path)
200}
201
202/// Reads a file through the cache and applies the requested compression mode.
203pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
204    handle_with_options(cache, path, mode, false, crp_mode, None)
205}
206
207/// Like `handle`, but invalidates the cache first to force a fresh disk read.
208pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
209    handle_with_options(cache, path, mode, true, crp_mode, None)
210}
211
212/// Reads a file with task-aware filtering to prioritize task-relevant content.
213pub fn handle_with_task(
214    cache: &mut SessionCache,
215    path: &str,
216    mode: &str,
217    crp_mode: CrpMode,
218    task: Option<&str>,
219) -> String {
220    handle_with_options(cache, path, mode, false, crp_mode, task)
221}
222
223/// Like `handle_with_task`, also returns the resolved mode name and pre-counted tokens.
224pub fn handle_with_task_resolved(
225    cache: &mut SessionCache,
226    path: &str,
227    mode: &str,
228    crp_mode: CrpMode,
229    task: Option<&str>,
230) -> ReadOutput {
231    handle_with_options_resolved(cache, path, mode, false, crp_mode, task)
232}
233
234/// Fresh read with task-aware filtering (invalidates cache first).
235pub fn handle_fresh_with_task(
236    cache: &mut SessionCache,
237    path: &str,
238    mode: &str,
239    crp_mode: CrpMode,
240    task: Option<&str>,
241) -> String {
242    handle_with_options(cache, path, mode, true, crp_mode, task)
243}
244
245/// Fresh read with task-aware filtering, also returns the resolved mode name and pre-counted tokens.
246pub fn handle_fresh_with_task_resolved(
247    cache: &mut SessionCache,
248    path: &str,
249    mode: &str,
250    crp_mode: CrpMode,
251    task: Option<&str>,
252) -> ReadOutput {
253    handle_with_options_resolved(cache, path, mode, true, crp_mode, task)
254}
255
256fn handle_with_options(
257    cache: &mut SessionCache,
258    path: &str,
259    mode: &str,
260    fresh: bool,
261    crp_mode: CrpMode,
262    task: Option<&str>,
263) -> String {
264    handle_with_options_resolved(cache, path, mode, fresh, crp_mode, task).content
265}
266
267/// Detects if the current execution context is a subagent (forked agent).
268/// Subagents inherit stale parent caches, so force-fresh prevents VERIFY FAIL.
269fn is_subagent_context() -> bool {
270    static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
271    *IS_SUBAGENT.get_or_init(|| {
272        if std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true") {
273            return true;
274        }
275        std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
276    })
277}
278
279fn handle_with_options_resolved(
280    cache: &mut SessionCache,
281    path: &str,
282    mode: &str,
283    fresh: bool,
284    crp_mode: CrpMode,
285    task: Option<&str>,
286) -> ReadOutput {
287    let effective_fresh = fresh || is_subagent_context();
288
289    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
290        bt.next_seq();
291    }
292    let mut result = handle_with_options_inner(cache, path, mode, effective_fresh, crp_mode, task);
293
294    if let Some(entry) = cache.get_mut(path) {
295        entry.last_mode.clone_from(&result.resolved_mode);
296    }
297
298    let dedup_allowed = matches!(
299        result.resolved_mode.as_str(),
300        "map" | "signatures" | "aggressive" | "entropy" | "task"
301    );
302    if dedup_allowed {
303        if let Some(deduped) = cache.apply_dedup(path, &result.content) {
304            let new_tokens = count_tokens(&deduped);
305            if new_tokens < result.output_tokens {
306                result.content = deduped;
307                result.output_tokens = new_tokens;
308            }
309        }
310    }
311
312    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
313        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
314        bt.record_read(
315            path,
316            &result.resolved_mode,
317            result.output_tokens,
318            original_tokens,
319        );
320    }
321
322    result
323}
324
325/// Attempt to serve a `mode="full"` cache hit (`[unchanged …]`) using only a
326/// shared borrow of the cache.
327///
328/// Returns `None` when the file is not cached, was modified on disk, full
329/// content was never delivered, or the cache policy forbids stubbing — in those
330/// cases the caller must fall back to the write path.
331///
332/// This is the read-locked fast path: it needs no `&mut SessionCache`, so the
333/// dominant "re-read an unchanged file" case proceeds under a shared lock and
334/// parallel reads of distinct files no longer serialize on a global write lock.
335pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
336    let file_ref = cache.get_file_ref_readonly(path)?;
337    let (cached_mtime, read_count, line_count, content_opt) = {
338        let entry = cache.get(path)?;
339        (
340            entry.stored_mtime,
341            entry.read_count(),
342            entry.line_count,
343            entry.content(),
344        )
345    };
346
347    let no_deg = crate::core::config::Config::load().no_degrade_effective();
348    let prof = crate::core::profiles::active_profile();
349    let force_full = no_deg
350        || (prof.read.default_mode_effective() == "full"
351            && prof.compression.crp_mode_effective() == "off");
352    let policy_allows_stub =
353        crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
354    if !policy_allows_stub
355        || crate::core::cache::is_cache_entry_stale(path, cached_mtime)
356        || !cache.is_full_delivered(path)
357    {
358        return None;
359    }
360
361    cache.record_cache_hit(path);
362    let short = protocol::shorten_path(path);
363    let out = if crate::core::protocol::meta_visible() {
364        format!(
365            "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
366        )
367    } else {
368        let proof = content_opt
369            .as_deref()
370            .and_then(|c| cache_hit_proof_line(c, read_count));
371        let reads_note = if read_count > 3 {
372            format!(" (read {}x)", read_count + 1)
373        } else {
374            String::new()
375        };
376        match proof {
377            Some(p) => {
378                format!("{file_ref}={short} [unchanged {line_count}L{reads_note} | \"{p}\"]")
379            }
380            None => format!("{file_ref}={short} [unchanged {line_count}L{reads_note}]"),
381        }
382    };
383    let out = crate::core::redaction::redact_text_if_enabled(&out);
384    let sent = count_tokens(&out);
385    Some(ReadOutput {
386        content: out,
387        resolved_mode: "full".into(),
388        output_tokens: sent,
389    })
390}
391
392fn handle_with_options_inner(
393    cache: &mut SessionCache,
394    path: &str,
395    mode: &str,
396    fresh: bool,
397    crp_mode: CrpMode,
398    task: Option<&str>,
399) -> ReadOutput {
400    let file_ref = cache.get_file_ref(path);
401    let short = protocol::shorten_path(path);
402    let ext = Path::new(path)
403        .extension()
404        .and_then(|e| e.to_str())
405        .unwrap_or("");
406
407    if fresh {
408        if mode == "diff" {
409            let warning = "[warning] fresh+diff is redundant — fresh invalidates cache, no diff possible. Use mode=full with fresh=true instead.";
410            return ReadOutput {
411                content: warning.to_string(),
412                resolved_mode: "diff".into(),
413                output_tokens: count_tokens(warning),
414            };
415        }
416        cache.invalidate(path);
417    }
418
419    if mode == "diff" {
420        let (out, _) = handle_diff(cache, path, &file_ref);
421        let out = crate::core::redaction::redact_text_if_enabled(&out);
422        let sent = count_tokens(&out);
423        return ReadOutput {
424            content: out,
425            resolved_mode: "diff".into(),
426            output_tokens: sent,
427        };
428    }
429
430    if mode != "full" {
431        if let Some(existing) = cache.get(path) {
432            let stale = crate::core::cache::is_cache_entry_stale(path, existing.stored_mtime);
433            if stale {
434                cache.invalidate(path);
435            }
436        }
437    }
438
439    // Snapshot the minimal immutable data the miss paths need, then drop the
440    // borrow before any mutable operations (set_compressed, invalidate, store).
441    let cache_snapshot = cache
442        .get(path)
443        .map(|existing| (existing.original_tokens, existing.content()));
444
445    if let Some((original_tokens, content_opt)) = cache_snapshot {
446        if mode == "full" {
447            // Read-locked stub fast path (single source of truth, shared with
448            // the registered handler's concurrent read-lock attempt).
449            if let Some(out) = try_stub_hit_readonly(cache, path) {
450                return out;
451            }
452            let (out, _) = handle_full_with_auto_delta(cache, path, &file_ref, &short, ext, task);
453            let out = crate::core::redaction::redact_text_if_enabled(&out);
454            let sent = count_tokens(&out);
455            return ReadOutput {
456                content: out,
457                resolved_mode: "full".into(),
458                output_tokens: sent,
459            };
460        }
461
462        // Resolve mode first so we can check compressed output cache BEFORE
463        // decompressing the full content (avoids ~2-5ms zstd overhead on hits).
464        let resolved_mode = if mode == "auto" {
465            resolve_auto_mode(path, original_tokens, task)
466        } else {
467            mode.to_string()
468        };
469
470        if is_cacheable_mode(&resolved_mode) {
471            let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
472            let compressed_hit = cache.get_compressed(path, &cache_key).cloned();
473            if let Some(cached_output) = compressed_hit {
474                cache.record_cache_hit(path);
475                let out = crate::core::redaction::redact_text_if_enabled(&cached_output);
476                let sent = count_tokens(&out);
477                return ReadOutput {
478                    content: out,
479                    resolved_mode,
480                    output_tokens: sent,
481                };
482            }
483        }
484
485        if let Some(content) = content_opt {
486            let (out, _) = process_mode(
487                &content,
488                &resolved_mode,
489                &file_ref,
490                &short,
491                ext,
492                original_tokens,
493                crp_mode,
494                path,
495                task,
496            );
497            if is_cacheable_mode(&resolved_mode) {
498                let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
499                cache.set_compressed(path, &cache_key, out.clone());
500            }
501            let out = crate::core::redaction::redact_text_if_enabled(&out);
502            let sent = count_tokens(&out);
503            return ReadOutput {
504                content: out,
505                resolved_mode,
506                output_tokens: sent,
507            };
508        }
509        cache.invalidate(path);
510    }
511
512    let content = match read_file_lossy(path) {
513        Ok(c) => c,
514        Err(e) => {
515            let msg = format!("ERROR: {e}");
516            let tokens = count_tokens(&msg);
517            return ReadOutput {
518                content: msg,
519                resolved_mode: "error".into(),
520                output_tokens: tokens,
521            };
522        }
523    };
524
525    let store_result = cache.store(path, &content);
526
527    // Skip expensive hint computation for line-range reads and first reads.
528    // Hints are only useful from the 2nd read onwards when the file is contextually relevant.
529    let is_line_range = mode.starts_with("lines:");
530    let hints = crate::core::profiles::active_profile().output_hints;
531    let is_repeat_read = store_result.read_count > 1;
532    let similar_hint = if !is_line_range && is_repeat_read && hints.semantic_hint() {
533        find_similar_and_update_semantic_index(path, &content)
534    } else {
535        None
536    };
537    let graph_hint = if !is_line_range && is_repeat_read && hints.related_hint() {
538        build_graph_related_hint(path)
539    } else {
540        None
541    };
542
543    if mode == "full" {
544        cache.mark_full_delivered(path);
545        let (mut output, _) = format_full_output(
546            &file_ref,
547            &short,
548            ext,
549            &content,
550            store_result.original_tokens,
551            store_result.line_count,
552            task,
553        );
554        if let Some(hint) = &graph_hint {
555            output.push_str(&format!("\n{hint}"));
556        }
557        if let Some(hint) = similar_hint {
558            output.push_str(&format!("\n{hint}"));
559        }
560        let output = crate::core::redaction::redact_text_if_enabled(&output);
561        let sent = count_tokens(&output);
562        return ReadOutput {
563            content: output,
564            resolved_mode: "full".into(),
565            output_tokens: sent,
566        };
567    }
568
569    let resolved_mode = if mode == "auto" {
570        resolve_auto_mode(path, store_result.original_tokens, task)
571    } else {
572        mode.to_string()
573    };
574
575    let (mut output, _sent) = process_mode(
576        &content,
577        &resolved_mode,
578        &file_ref,
579        &short,
580        ext,
581        store_result.original_tokens,
582        crp_mode,
583        path,
584        task,
585    );
586    if let Some(hint) = &graph_hint {
587        output.push_str(&format!("\n{hint}"));
588    }
589    if let Some(hint) = similar_hint {
590        output.push_str(&format!("\n{hint}"));
591    }
592    if is_cacheable_mode(&resolved_mode) {
593        let cache_key = compressed_cache_key(&resolved_mode, crp_mode, task);
594        cache.set_compressed(path, &cache_key, output.clone());
595    }
596    let output = crate::core::redaction::redact_text_if_enabled(&output);
597    let final_tokens = count_tokens(&output);
598    ReadOutput {
599        content: output,
600        resolved_mode,
601        output_tokens: final_tokens,
602    }
603}
604
605pub fn is_instruction_file(path: &str) -> bool {
606    let lower = path.to_lowercase();
607    let filename = std::path::Path::new(&lower)
608        .file_name()
609        .and_then(|f| f.to_str())
610        .unwrap_or("");
611
612    matches!(
613        filename,
614        "skill.md"
615            | "agents.md"
616            | "rules.md"
617            | ".cursorrules"
618            | ".clinerules"
619            | "lean-ctx.md"
620            | "lean-ctx.mdc"
621    ) || lower.contains("/skills/")
622        || lower.contains("/.cursor/rules/")
623        || lower.contains("/.claude/rules/")
624        || lower.contains("/agents.md")
625}
626
627/// Delegates to the unified `auto_mode_resolver::resolve()`.
628fn resolve_auto_mode(file_path: &str, original_tokens: usize, task: Option<&str>) -> String {
629    let ctx = crate::core::auto_mode_resolver::AutoModeContext {
630        path: file_path,
631        token_count: original_tokens,
632        task,
633        cache: None,
634    };
635    crate::core::auto_mode_resolver::resolve(&ctx).mode
636}
637
638fn find_similar_and_update_semantic_index(path: &str, content: &str) -> Option<String> {
639    const MAX_CONTENT_BYTES_FOR_SEMANTIC: usize = 32_768;
640
641    if content.len() > MAX_CONTENT_BYTES_FOR_SEMANTIC {
642        return None;
643    }
644
645    let cfg = crate::core::config::Config::load();
646    let profile = crate::core::config::MemoryProfile::effective(&cfg);
647    if !profile.semantic_cache_enabled() {
648        return None;
649    }
650
651    let project_root = detect_project_root(path);
652    let session_id = format!("{}", std::process::id());
653    let mut index = crate::core::semantic_cache::SemanticCacheIndex::load_or_create(&project_root);
654
655    let similar = index.find_similar(content, 0.7);
656    let relevant: Vec<_> = similar
657        .into_iter()
658        .filter(|(p, _)| p != path)
659        .take(3)
660        .collect();
661
662    index.add_file(path, content, &session_id);
663    if let Err(e) = index.save(&project_root) {
664        tracing::warn!("lean-ctx: failed to persist semantic index: {e}");
665    }
666
667    if relevant.is_empty() {
668        return None;
669    }
670
671    let hints: Vec<String> = relevant
672        .iter()
673        .map(|(p, score)| format!("  {p} ({:.0}% similar)", score * 100.0))
674        .collect();
675
676    Some(format!(
677        "[semantic: {} similar file(s) in cache]\n{}",
678        relevant.len(),
679        hints.join("\n")
680    ))
681}
682
683fn detect_project_root(path: &str) -> String {
684    crate::core::protocol::detect_project_root_or_cwd(path)
685}
686
687fn build_graph_related_hint(path: &str) -> Option<String> {
688    let project_root = detect_project_root(path);
689    crate::core::graph_context::build_related_hint(path, &project_root, 5)
690}
691
692const AUTO_DELTA_THRESHOLD: f64 = 0.6;
693
694/// Re-reads from disk; if content changed and delta is compact, sends auto-delta.
695fn handle_full_with_auto_delta(
696    cache: &mut SessionCache,
697    path: &str,
698    file_ref: &str,
699    short: &str,
700    ext: &str,
701    task: Option<&str>,
702) -> (String, usize) {
703    let _mode_guard = crate::core::savings_footer::ModeGuard::new("full");
704    let Ok(disk_content) = read_file_lossy(path) else {
705        cache.record_cache_hit(path);
706        if let Some(existing) = cache.get(path) {
707            if !crate::core::protocol::meta_visible() {
708                if let Some(cached) = existing.content() {
709                    return format_full_output(
710                        file_ref,
711                        short,
712                        ext,
713                        &cached,
714                        existing.original_tokens,
715                        existing.line_count,
716                        task,
717                    );
718                }
719            }
720            let out = format!(
721                "[using cached version — file read failed]\n{file_ref}={short} cached {}t {}L",
722                existing.read_count(),
723                existing.line_count
724            );
725            let sent = count_tokens(&out);
726            return (out, sent);
727        }
728        let out = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
729            format!("[file read failed and no cached version available] {file_ref}={short}")
730        } else {
731            format!("[file read failed and no cached version available] {short}")
732        };
733        let sent = count_tokens(&out);
734        return (out, sent);
735    };
736
737    let no_deg = crate::core::config::Config::load().no_degrade_effective();
738    let prof = crate::core::profiles::active_profile();
739    let force_full = no_deg
740        || (prof.read.default_mode_effective() == "full"
741            && prof.compression.crp_mode_effective() == "off");
742
743    let old_content = cache
744        .get(path)
745        .and_then(crate::core::cache::CacheEntry::content)
746        .unwrap_or_default();
747    let store_result = cache.store(path, &disk_content);
748
749    if store_result.was_hit {
750        let policy_allows_stub =
751            crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
752        if policy_allows_stub && store_result.full_content_delivered {
753            let out = if crate::core::protocol::meta_visible() {
754                format!(
755                    "{file_ref}={short} [unchanged {}L]\nUnchanged on disk. Use fresh=true to force re-read.",
756                    store_result.line_count
757                )
758            } else {
759                let proof = cache_hit_proof_line(&disk_content, store_result.read_count);
760                let reads_note = if store_result.read_count > 3 {
761                    format!(" (read {}x)", store_result.read_count)
762                } else {
763                    String::new()
764                };
765                match proof {
766                    Some(p) => format!(
767                        "{file_ref}={short} [unchanged {}L{reads_note} | \"{p}\"]",
768                        store_result.line_count
769                    ),
770                    None => format!(
771                        "{file_ref}={short} [unchanged {}L{reads_note}]",
772                        store_result.line_count
773                    ),
774                }
775            };
776            let sent = count_tokens(&out);
777            return (out, sent);
778        }
779        cache.mark_full_delivered(path);
780        return format_full_output(
781            file_ref,
782            short,
783            ext,
784            &disk_content,
785            store_result.original_tokens,
786            store_result.line_count,
787            task,
788        );
789    }
790
791    let diff = compressor::diff_content(&old_content, &disk_content);
792    let diff_tokens = count_tokens(&diff);
793    let full_tokens = store_result.original_tokens;
794
795    if !force_full
796        && full_tokens > 0
797        && (diff_tokens as f64) < (full_tokens as f64 * AUTO_DELTA_THRESHOLD)
798    {
799        let savings = protocol::format_savings(full_tokens, diff_tokens);
800        let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
801            format!("{file_ref}={short}")
802        } else {
803            short.to_string()
804        };
805        let out = format!(
806            "{head} [auto-delta] ∆{}L\n{diff}\n{savings}",
807            disk_content.lines().count()
808        );
809        return (out, diff_tokens);
810    }
811
812    format_full_output(
813        file_ref,
814        short,
815        ext,
816        &disk_content,
817        store_result.original_tokens,
818        store_result.line_count,
819        task,
820    )
821}
822
823fn handle_diff(cache: &mut SessionCache, path: &str, file_ref: &str) -> (String, usize) {
824    let _mode_guard = crate::core::savings_footer::ModeGuard::new("diff");
825    let short = protocol::shorten_path(path);
826    let old_content = cache
827        .get(path)
828        .and_then(crate::core::cache::CacheEntry::content);
829
830    let new_content = match read_file_lossy(path) {
831        Ok(c) => c,
832        Err(e) => {
833            let msg = format!("ERROR: {e}");
834            let tokens = count_tokens(&msg);
835            return (msg, tokens);
836        }
837    };
838
839    let original_tokens = count_tokens(&new_content);
840
841    let diff_output = if let Some(old) = &old_content {
842        compressor::diff_content(old, &new_content)
843    } else {
844        // No previous version cached — store content for future diffs but
845        // return a short guidance message instead of dumping the full file.
846        cache.store(path, &new_content);
847        let msg = format!(
848            "{file_ref}={short} [no cached version for diff — use mode=full first, then diff on re-read]"
849        );
850        let sent = count_tokens(&msg);
851        return (msg, sent);
852    };
853
854    cache.store(path, &new_content);
855
856    let sent = count_tokens(&diff_output);
857    let savings = protocol::format_savings(original_tokens, sent);
858    let head = if crate::core::protocol::meta_visible() && !file_ref.is_empty() {
859        format!("{file_ref}={short}")
860    } else {
861        short.clone()
862    };
863    (format!("{head} [diff]\n{diff_output}\n{savings}"), sent)
864}