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
17pub struct ReadOutput {
20 pub content: String,
21 pub resolved_mode: String,
22 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 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 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
61fn 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
93pub 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
147fn 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 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 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
202pub 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
207pub 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
212pub 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
223pub 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
234pub 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
245pub 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
267fn 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
325pub 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 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 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 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 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
627fn 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
694fn 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 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}