Skip to main content

lean_ctx_server/tools/ctx_read/
render.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Output rendering: full-output framing, header building, per-mode
3//! processing, task-relevant filtering and line-range extraction.
4//! Split out of `ctx_read/mod.rs`.
5
6use super::{
7    CrpMode, SymbolMap, append_compressed_hint, compressor, count_tokens, deps,
8    detect_project_root, entropy, protocol, resolve_auto_mode, signatures, symbol_map,
9};
10use lean_ctx::core::aggressiveness::AggressivenessProfile;
11
12#[cfg(test)]
13pub(crate) use super::fallback_banner::NO_COMPRESSION_BANNER_MIN_TOKENS;
14pub(crate) use super::fallback_banner::{AutoRequestGuard, no_compression_banner};
15
16fn monotonic_check(original: usize, compressed: usize) -> bool {
17    compressed < original
18}
19
20fn raw_fallback(
21    path: &str,
22    content: &str,
23    original_tokens: usize,
24    compressed: usize,
25) -> (String, usize) {
26    tracing::debug!(
27        "monotonic guard: {path} compressed {compressed} >= original {original_tokens}, using raw"
28    );
29    let mode =
30        lean_ctx::core::savings_footer::current_mode().unwrap_or_else(|| "compressed".into());
31    match no_compression_banner(&mode, original_tokens) {
32        Some(banner) => {
33            let out = format!("{banner}\n{content}");
34            let sent = count_tokens(&out);
35            (out, sent)
36        }
37        None => (content.to_string(), original_tokens),
38    }
39}
40
41/// Per-read tuning threaded into the per-mode renderers. `Default` reproduces
42/// the behaviour from before the aggressiveness knob existed (no override), so
43/// every existing caller and test keeps its exact byte output (#498).
44#[derive(Clone, Copy, Default)]
45pub(crate) struct ReadTuning<'a> {
46    /// Resolved 0.0–1.0 compression intensity, or `None` to use each mode's
47    /// built-in default. Already resolved via `aggressiveness::effective` at the
48    /// read boundary, so the renderer treats it as authoritative.
49    pub aggressiveness: Option<f64>,
50    /// Explicit `protect` tokens (#709): every line containing one of these
51    /// survives the line-based lossy filters (entropy / information-bottleneck)
52    /// verbatim. Empty slice reproduces the pre-protect byte output (#498).
53    /// Borrowed from the read boundary for the duration of the render call.
54    pub protect: &'a [String],
55}
56
57impl<'a> ReadTuning<'a> {
58    /// Resolves the effective tuning from an explicit per-call aggressiveness
59    /// (falling back to the `LEAN_CTX_AGGRESSIVENESS` env var / config field) and
60    /// the explicit `protect` token list.
61    pub(crate) fn resolve(explicit_aggressiveness: Option<f64>, protect: &'a [String]) -> Self {
62        Self {
63            aggressiveness: lean_ctx::core::aggressiveness::effective(explicit_aggressiveness),
64            protect,
65        }
66    }
67
68    /// For an `auto` read, the `density:` mode an aggressiveness setting maps to
69    /// (so one knob drives whole-file intensity via the proven density path).
70    pub(crate) fn auto_density_mode(self) -> Option<String> {
71        self.aggressiveness.map(|a| {
72            format!(
73                "density:{:.2}",
74                AggressivenessProfile::from_level(a).density_target
75            )
76        })
77    }
78}
79
80/// Render a trailing ` [a, b]` techniques tag, or `""` when no compression
81/// technique fired. Avoids the empty ` []` metadata field a bare `join` would
82/// leave on an incompressible file (#509 output-waste audit, same class as the
83/// `ctx_semantic_search` `(rrf: X, )` fix in #511).
84fn techniques_tag(techniques: &[String]) -> String {
85    if techniques.is_empty() {
86        String::new()
87    } else {
88        format!(" [{}]", techniques.join(", "))
89    }
90}
91
92/// Code-health annotations (function name → note like `cc=18`) for the
93/// over-threshold functions in `content`, honoring `[code_health].annotate_reads`.
94/// Empty when disabled, unsupported, or nothing qualifies. Deterministic
95/// (#498-safe): a pure function of content + the active threshold.
96fn health_annotations(content: &str, ext: &str) -> std::collections::HashMap<String, String> {
97    let cfg = lean_ctx::core::config::Config::load();
98    if !cfg.code_health.annotate_reads {
99        return std::collections::HashMap::new();
100    }
101    let annotations = lean_ctx::core::code_health::annotate::annotations_for_file(
102        content,
103        ext,
104        cfg.code_health.cognitive_threshold,
105    );
106    lean_ctx::core::code_health::annotate::by_name(&annotations)
107}
108
109pub(crate) fn format_full_output(
110    file_ref: &str,
111    short: &str,
112    ext: &str,
113    content: &str,
114    original_tokens: usize,
115    line_count: usize,
116    _task: Option<&str>,
117) -> (String, usize) {
118    let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("full");
119    let tokens = original_tokens;
120    let metadata = build_header(file_ref, short, ext, content, line_count, true);
121
122    let output = format!("{metadata}\n{content}");
123    let sent = count_tokens(&output);
124    (protocol::append_savings(&output, tokens, sent), sent)
125}
126
127/// Headerless, trailing-whitespace-stripped output for the Read redirect path.
128///
129/// The hook redirect writes this into a temp file the host reads *as* the real
130/// file's content. No framing header (fixes offset/limit correctness, #1021
131/// follow-up) and trailing whitespace is stripped per line for modest
132/// compression without breaking line structure or edit round-trips.
133pub(crate) fn format_full_compact_output(content: &str) -> (String, usize) {
134    let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("full-compact");
135    let output: String = content
136        .lines()
137        .map(str::trim_end)
138        .collect::<Vec<_>>()
139        .join("\n");
140    let sent = count_tokens(&output);
141    (output, sent)
142}
143
144/// Render `content` with per-line `N:hh|` hash anchors for `ctx_patch`
145/// (mode=anchored, epic #1008). The body is verbatim source prefixed with a
146/// stable, self-describing one-line legend so a vanilla agent can read the
147/// format without prior knowledge (GL#580 self-describing-output philosophy).
148///
149/// #498 determinism: the output is a pure function of `(file_ref, short,
150/// content, line_count)` — no savings footer, timestamps or counters — so
151/// identical re-reads stay byte-stable and provider prompt caching applies. No
152/// `append_savings`: anchors are lossless *additions*, so a savings line would
153/// always be negative and misleading.
154pub(crate) fn format_anchored_output(
155    file_ref: &str,
156    short: &str,
157    content: &str,
158    line_count: usize,
159) -> (String, usize) {
160    format_anchored_output_window(file_ref, short, content, line_count, None)
161}
162
163/// Windowed variant of [`format_anchored_output`] (#811): when `window` is
164/// `Some((start, end))`, `content` is sliced to that 1-based inclusive span
165/// *before* anchoring, so a bounded anchored read never has to hash/render
166/// lines outside the requested window — only the slice is annotated, numbered
167/// from its true position in the file so anchors still line up with
168/// `ctx_patch`. `None` reproduces the whole-file behaviour above.
169pub(crate) fn format_anchored_output_window(
170    file_ref: &str,
171    short: &str,
172    body: &str,
173    line_count: usize,
174    window: Option<(usize, usize)>,
175) -> (String, usize) {
176    let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("anchored");
177    let (start_line, range_suffix) = match window {
178        Some((start, end)) => (start, format!(" lines:{start}-{end}")),
179        None => (1, String::new()),
180    };
181    let header = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
182        format!(
183            "{file_ref}={short} {line_count}L{range_suffix} [anchored: N:hh|line → edit via ctx_patch]"
184        )
185    } else {
186        format!("{short} {line_count}L{range_suffix} [anchored: N:hh|line → edit via ctx_patch]")
187    };
188    let annotated = lean_ctx::core::anchor::annotate(body, start_line);
189    let output = format!("{header}\n{annotated}");
190    let sent = count_tokens(&output);
191    (output, sent)
192}
193
194pub(crate) fn build_header(
195    file_ref: &str,
196    short: &str,
197    ext: &str,
198    content: &str,
199    line_count: usize,
200    include_deps: bool,
201) -> String {
202    let mut header = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
203        format!("{file_ref}={short} {line_count}L")
204    } else {
205        format!("{short} {line_count}L")
206    };
207
208    if include_deps {
209        let dep_info = deps::extract_deps(content, ext);
210        if !dep_info.imports.is_empty() {
211            let imports_str: Vec<&str> = dep_info
212                .imports
213                .iter()
214                .take(8)
215                .map(std::string::String::as_str)
216                .collect();
217            header.push_str(&format!("\n deps {}", imports_str.join(",")));
218        }
219        if !dep_info.exports.is_empty() {
220            let exports_str: Vec<&str> = dep_info
221                .exports
222                .iter()
223                .take(8)
224                .map(std::string::String::as_str)
225                .collect();
226            header.push_str(&format!("\n exports {}", exports_str.join(",")));
227        }
228    }
229
230    header
231}
232
233#[allow(clippy::too_many_arguments)]
234pub fn process_mode(
235    content: &str,
236    mode: &str,
237    file_ref: &str,
238    short: &str,
239    ext: &str,
240    original_tokens: usize,
241    crp_mode: CrpMode,
242    file_path: &str,
243    task: Option<&str>,
244) -> (String, usize) {
245    process_mode_tuned(
246        content,
247        mode,
248        file_ref,
249        short,
250        ext,
251        original_tokens,
252        crp_mode,
253        file_path,
254        task,
255        ReadTuning::default(),
256    )
257}
258
259/// Hand [`process_mode`] to the engine's conformance scorecard
260/// (`core::conformance`), whose read-mode accuracy checks measure it. The CLI
261/// calls this at start-up; the first registration wins.
262pub fn register_conformance_renderer() {
263    lean_ctx::core::conformance::register_read_mode_renderer(process_mode);
264}
265
266/// Renders `content` for `mode`, honouring the aggressiveness knob carried in
267/// `tuning`. `process_mode` is the unchanged-behaviour wrapper (`ReadTuning::
268/// default()`); the real read path threads a resolved `tuning` through here.
269#[allow(clippy::too_many_arguments)]
270pub(crate) fn process_mode_tuned(
271    content: &str,
272    mode: &str,
273    file_ref: &str,
274    short: &str,
275    ext: &str,
276    original_tokens: usize,
277    crp_mode: CrpMode,
278    file_path: &str,
279    task: Option<&str>,
280    tuning: ReadTuning<'_>,
281) -> (String, usize) {
282    let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new(mode);
283    let line_count = content.lines().count();
284    let ctx = RenderCtx {
285        file_ref,
286        short,
287        ext,
288        file_path,
289        original_tokens,
290        crp_mode,
291        line_count,
292        task,
293    };
294
295    match mode {
296        "raw" => {
297            let sent = count_tokens(content);
298            (content.to_string(), sent)
299        }
300        "auto" => {
301            // The aggressiveness knob routes `auto` through the density path so a
302            // single number drives whole-file intensity; otherwise the learned
303            // auto-resolver picks the mode.
304            let chosen = tuning.auto_density_mode().unwrap_or_else(|| {
305                let lc = content.lines().count();
306                resolve_auto_mode(None, file_path, original_tokens, Some(lc), task)
307            });
308            process_mode_tuned(
309                content,
310                &chosen,
311                file_ref,
312                short,
313                ext,
314                original_tokens,
315                crp_mode,
316                file_path,
317                task,
318                tuning,
319            )
320        }
321        "full" => format_full_output(
322            file_ref,
323            short,
324            ext,
325            content,
326            original_tokens,
327            line_count,
328            task,
329        ),
330        "full-compact" => format_full_compact_output(content),
331        "anchored" => format_anchored_output(file_ref, short, content, line_count),
332        mode if mode.starts_with("anchored:") => {
333            let range_str = &mode[9..];
334            let (start, end) = parse_anchored_range(range_str, line_count);
335            let (body, start, end) = slice_window(content, start, end);
336            format_anchored_output_window(file_ref, short, &body, line_count, Some((start, end)))
337        }
338        "signatures" => render_signatures(content, ctx),
339        // `cognitive` and `mdl`: former science views, retired 2026-10-16
340        // without a measured retrieval gain; they render the map view.
341        "map" | "cognitive" | "mdl" => render_map(content, ctx),
342        "aggressive" => render_aggressive(content, ctx),
343        "entropy" => render_entropy(content, ctx, &tuning),
344        "task" => render_task_mode(content, ctx, &tuning),
345        "reference" => {
346            let tok = count_tokens(content);
347            let output = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
348                format!("{file_ref}={short}: {line_count} lines, {tok} tok ({ext})")
349            } else {
350                format!("{short}: {line_count} lines, {tok} tok ({ext})")
351            };
352            let sent = count_tokens(&output);
353            let savings = protocol::format_savings(original_tokens, sent);
354            (format!("{output}\n{savings}"), sent)
355        }
356        mode if mode.starts_with("lines:") => {
357            let range_str = &mode[6..];
358            let extracted = extract_line_range(content, range_str);
359            let header = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
360                format!("{file_ref}={short} {line_count}L lines:{range_str}")
361            } else {
362                format!("{short} {line_count}L lines:{range_str}")
363            };
364            let multi_hint = if range_str.contains(',') {
365                LINES_COMMA_HINT
366            } else {
367                ""
368            };
369            let sent = count_tokens(&extracted);
370            let savings = protocol::format_savings(original_tokens, sent);
371            (
372                format!("{header}\n{extracted}{multi_hint}\n{savings}"),
373                sent,
374            )
375        }
376        mode if mode.starts_with("density:") => {
377            // SDE target-density mode: compress to a token budget instead of
378            // maximum compression. `density:0.4` ≈ 40% of original tokens. A bare
379            // `density:` falls back to the aggressiveness target (else 0.5).
380            let aggr_target = tuning
381                .aggressiveness
382                .map(|a| AggressivenessProfile::from_level(a).density_target);
383            let target: f64 = mode[8..].parse().ok().or(aggr_target).unwrap_or(0.5);
384            let result = entropy::entropy_compress_to_density_for(content, target, ext);
385            // #798: Quality gate — reject density output that breaks AST/symbols.
386            // #940: use density-specific guard that only checks AST/identifiers,
387            // not line count — density mode *intentionally* reduces lines.
388            let (guarded_output, _q) =
389                lean_ctx::core::quality::guard_density(content, &result.output, ext, target);
390            let guarded_tokens = count_tokens(&guarded_output);
391            let actual = if result.original_tokens > 0 {
392                guarded_tokens as f64 / result.original_tokens as f64
393            } else {
394                0.0
395            };
396            let techs = techniques_tag(&result.techniques);
397            let target_clamped = target.clamp(0.05, 1.0);
398            let header = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
399                format!(
400                    "{file_ref}={short} {line_count}L density target={target_clamped:.2} actual={actual:.2}{techs}"
401                )
402            } else {
403                format!(
404                    "{short} {line_count}L density target={target_clamped:.2} actual={actual:.2}{techs}"
405                )
406            };
407            let output = format!("{header}\n{guarded_output}");
408            finish_lossy_view(&output, content, original_tokens, file_path)
409        }
410        // `diff` is a delta view rendered against the session cache by
411        // `core_logic::handle_diff`, not a whole-file render — there is no
412        // content-only way to produce it. Reaching the generic renderer means a
413        // caller bypassed the cache-aware path; answer in one bounded line
414        // instead of falling through to `unknown` and dumping the whole file
415        // under a warning the caller pays full tokens for (#1584).
416        "diff" => {
417            let msg = format!(
418                "{short}: mode=diff renders against the session cache and cannot be produced here. \
419                 Read the file once with mode=full, then request mode=diff on the re-read."
420            );
421            let sent = count_tokens(&msg);
422            (msg, sent)
423        }
424        unknown => {
425            let header = build_header(file_ref, short, ext, content, line_count, true);
426            let out = format!(
427                "[WARNING: unknown mode '{unknown}', falling back to full — valid modes: {}]\n{header}\n{content}",
428                lean_ctx::core::mcp_manifest::READ_MODES.join(", ")
429            );
430            let sent = count_tokens(&out);
431            (out, sent)
432        }
433    }
434}
435
436/// When a task is active, find the symbol whose name best matches a task
437/// keyword and return its body as numbered source lines (capped).
438///
439/// `map`/`signatures` stay compact but include the one symbol body the agent is
440/// most likely about to read, avoiding a follow-up full read. Uses the
441/// tree-sitter chunk extractor (which carries spans + body across languages); a
442/// no-op when tree-sitter is unavailable.
443#[cfg(not(feature = "tree-sitter"))]
444pub(crate) fn task_relevant_body(
445    _content: &str,
446    _file_path: &str,
447    _ext: &str,
448    _task: Option<&str>,
449) -> Option<String> {
450    None
451}
452
453#[cfg(feature = "tree-sitter")]
454pub(crate) fn task_relevant_body(
455    content: &str,
456    file_path: &str,
457    ext: &str,
458    task: Option<&str>,
459) -> Option<String> {
460    const MAX_BODY_LINES: usize = 80;
461
462    let task = task.map(str::trim).filter(|t| !t.is_empty())?;
463    let (_files, keywords) = lean_ctx::core::task_relevance::parse_task_hints(task);
464    if keywords.is_empty() {
465        return None;
466    }
467    let kw_lower: Vec<String> = keywords.iter().map(|k| k.to_lowercase()).collect();
468
469    let chunks = lean_ctx::core::chunks_ts::extract_chunks_ts(file_path, content, ext)?;
470
471    // Score: exact name match (2) beats substring overlap (1).
472    let mut best_idx: Option<usize> = None;
473    let mut best_score = 0u8;
474    for (i, ch) in chunks.iter().enumerate() {
475        if ch.symbol_name.is_empty() {
476            continue;
477        }
478        let name_l = ch.symbol_name.to_lowercase();
479        let substr = kw_lower
480            .iter()
481            .any(|k| k.len() >= 3 && (name_l.contains(k.as_str()) || k.contains(name_l.as_str())));
482        let score = if kw_lower.contains(&name_l) {
483            2
484        } else {
485            u8::from(substr)
486        };
487        if score > best_score {
488            best_score = score;
489            best_idx = Some(i);
490        }
491    }
492
493    let ch = &chunks[best_idx?];
494    let body_lines: Vec<&str> = ch.content.lines().collect();
495    let total = body_lines.len();
496    let shown = total.min(MAX_BODY_LINES);
497    let body: String = body_lines[..shown]
498        .iter()
499        .enumerate()
500        .map(|(i, l)| format!("{:>4}|{l}", ch.start_line + i))
501        .collect::<Vec<_>>()
502        .join("\n");
503    let truncated = if shown < total {
504        format!(
505            "\n  … +{} lines — ctx_read(mode=\"lines:{}-{}\")",
506            total - shown,
507            ch.start_line + shown,
508            ch.end_line
509        )
510    } else {
511        String::new()
512    };
513    Some(format!(
514        "  ▸ body {} L{}-{}:\n{body}{truncated}",
515        ch.symbol_name, ch.start_line, ch.end_line
516    ))
517}
518
519/// One-line explainer appended whenever a `lines:` payload uses a comma —
520/// comma means multi-select, and a caller who meant a `N-M` span must be able
521/// to see that from the output (limitations #7). Shared with the CLI arm.
522pub const LINES_COMMA_HINT: &str = "\n[lines: comma = multi-select (e.g. 5,10-20 picks line 5 and lines 10-20); use N-M for one span]";
523
524/// Marker for a map/signatures view that extracted nothing — a language with
525/// no grammar/regex coverage must be distinguishable from a file with no API
526/// (limitations audit, #4). Shared with the CLI arms.
527pub fn no_structure_marker(ext: &str) -> String {
528    format!(
529        "\n  [no extractable structure for .{ext} — header only; use mode=\"lines:N-M\" or \"full\"]"
530    )
531}
532
533pub fn extract_line_range(content: &str, range_str: &str) -> String {
534    let lines: Vec<&str> = content.lines().collect();
535    let total = lines.len();
536    let mut selected = Vec::new();
537
538    for part in range_str.split(',') {
539        let part = part.trim();
540        if let Some(count_s) = part.strip_prefix('-') {
541            // #1759: `-N` is a tail window — the last N lines. This arm must
542            // precede `split_once('-')`, which would otherwise read `-3` as
543            // the span `""-"3"` and print the FIRST three lines instead.
544            if let Ok(count) = count_s.trim().parse::<usize>()
545                && count >= 1
546            {
547                let start = total.saturating_sub(count) + 1;
548                for i in start..=total {
549                    selected.push(format!("{i:>4}| {}", lines[i - 1]));
550                }
551            }
552        } else if let Some((start_s, end_s)) = part.split_once('-') {
553            let start = start_s.trim().parse::<usize>().unwrap_or(1).max(1);
554            let end = end_s.trim().parse::<usize>().unwrap_or(total).min(total);
555            for i in start..=end {
556                if i >= 1 && i <= total {
557                    selected.push(format!("{i:>4}| {}", lines[i - 1]));
558                }
559            }
560        } else if let Ok(n) = part.parse::<usize>()
561            && n >= 1
562            && n <= total
563        {
564            selected.push(format!("{n:>4}| {}", lines[n - 1]));
565        }
566    }
567
568    if selected.is_empty() {
569        "No lines matched the range.".to_string()
570    } else {
571        selected.join("\n")
572    }
573}
574
575/// Parses a single `"start-end"` (or bare `"start"`, meaning "to EOF") window
576/// payload for `anchored:N-M` (#811). Single-span only — unlike `lines:`,
577/// an anchored window feeds `ctx_patch`, which edits one contiguous region at
578/// a time, so comma multi-select isn't needed here.
579fn parse_anchored_range(range_str: &str, total: usize) -> (usize, usize) {
580    if let Some((s, e)) = range_str.split_once('-') {
581        let start = s.trim().parse::<usize>().unwrap_or(1).max(1);
582        let end = e.trim().parse::<usize>().unwrap_or(total).min(total);
583        (start, end)
584    } else {
585        let start = range_str.trim().parse::<usize>().unwrap_or(1).max(1);
586        (start, total)
587    }
588}
589
590/// Slices `content` to 1-based inclusive `[start, end]`, clamped to the
591/// file's real bounds. Returns the joined body and the clamped `(start, end)`
592/// actually served — shared by the in-memory `anchored:N-M` render path.
593fn slice_window(content: &str, start: usize, end: usize) -> (String, usize, usize) {
594    let lines: Vec<&str> = content.lines().collect();
595    let total = lines.len();
596    let start = start.max(1).min(total.max(1));
597    let end = end.min(total);
598    let body = if total > 0 && end >= start {
599        lines[start - 1..end].join("\n")
600    } else {
601        String::new()
602    };
603    (body, start, end)
604}
605
606#[cfg(test)]
607mod render_tests {
608    use super::techniques_tag;
609
610    /// The full conformance scorecard, read-mode accuracy included, passes
611    /// with this renderer registered.
612    #[test]
613    fn conformance_suite_passes_with_the_read_renderer() {
614        super::register_conformance_renderer();
615        let card = lean_ctx::core::conformance::run();
616        assert!(
617            card.all_passed(),
618            "conformance failures: {:?}",
619            card.failures()
620        );
621        assert!(
622            card.checks.iter().any(|c| c.category == "accuracy"
623                && c.name.starts_with("read_mode_keeps_symbols:")),
624            "the read-mode accuracy checks ran"
625        );
626    }
627
628    #[test]
629    fn techniques_tag_omits_empty_brackets() {
630        // An incompressible file leaves no techniques — the header must not
631        // carry an empty ` []` field (#509 output-waste audit).
632        assert_eq!(techniques_tag(&[]), "");
633    }
634
635    #[test]
636    fn techniques_tag_wraps_nonempty_with_leading_space() {
637        assert_eq!(
638            techniques_tag(&["⊘ 3 low-entropy lines".to_string(), "⊘ 2 dups".to_string()]),
639            " [⊘ 3 low-entropy lines, ⊘ 2 dups]"
640        );
641    }
642
643    #[test]
644    fn techniques_tag_single() {
645        assert_eq!(
646            techniques_tag(&["density target=0.40".to_string()]),
647            " [density target=0.40]"
648        );
649    }
650
651    /// #1584: `diff` reaching the whole-file dispatcher used to be reported as
652    /// an *unknown* mode and answered with the entire file under a warning —
653    /// the caller paid full tokens for a delta they never got. It now answers
654    /// in one bounded line that names the way to get a real delta.
655    #[test]
656    fn diff_mode_never_falls_through_to_the_unknown_mode_dump() {
657        let content = "fn a() {}\nfn b() {}\nfn c() {}\n";
658        let (out, _) = super::process_mode_tuned(
659            content,
660            "diff",
661            "",
662            "sample.rs",
663            "rs",
664            32,
665            crate::tools::CrpMode::Off,
666            "sample.rs",
667            None,
668            super::ReadTuning {
669                aggressiveness: None,
670                protect: &[],
671            },
672        );
673        assert!(
674            !out.contains("unknown mode"),
675            "diff is a documented mode, not an unknown one: {out}"
676        );
677        assert!(
678            !out.contains("fn b()"),
679            "the fallback must not dump the file the caller did not ask for: {out}"
680        );
681        assert!(
682            out.contains("mode=full"),
683            "it must name the way forward: {out}"
684        );
685    }
686
687    /// #1584: when a mode really is unknown, the warning names what is valid
688    /// instead of leaving the caller to guess — the schema list and the runtime
689    /// now come from one constant, which is what let `diff` drift apart.
690    #[test]
691    fn unknown_mode_warning_names_the_valid_modes() {
692        let (out, _) = super::process_mode_tuned(
693            "fn a() {}\n",
694            "definitely-not-a-mode",
695            "",
696            "sample.rs",
697            "rs",
698            8,
699            crate::tools::CrpMode::Off,
700            "sample.rs",
701            None,
702            super::ReadTuning {
703                aggressiveness: None,
704                protect: &[],
705            },
706        );
707        assert!(
708            out.contains("unknown mode 'definitely-not-a-mode'"),
709            "{out}"
710        );
711        for mode in lean_ctx::core::mcp_manifest::READ_MODES {
712            assert!(out.contains(mode), "warning must list `{mode}`: {out}");
713        }
714    }
715}
716
717/// Shared, `Copy` bundle of the per-call rendering context threaded to the
718/// per-mode `render_*` helpers extracted from `process_mode_tuned` (keeps each
719/// mode arm testable and the dispatcher a thin `match`).
720#[derive(Clone, Copy)]
721struct RenderCtx<'a> {
722    file_ref: &'a str,
723    short: &'a str,
724    ext: &'a str,
725    file_path: &'a str,
726    original_tokens: usize,
727    crp_mode: CrpMode,
728    line_count: usize,
729    task: Option<&'a str>,
730}
731
732fn render_signatures(content: &str, ctx: RenderCtx<'_>) -> (String, usize) {
733    let RenderCtx {
734        file_ref,
735        short,
736        ext,
737        file_path,
738        original_tokens,
739        crp_mode,
740        line_count,
741        task,
742    } = ctx;
743    let sigs = signatures::extract_signatures(content, ext);
744    let dep_info = deps::extract_deps(content, ext);
745
746    let mut output = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
747        format!("{file_ref}={short} {line_count}L")
748    } else {
749        format!("{short} {line_count}L")
750    };
751    if !dep_info.imports.is_empty() {
752        let imports_str: Vec<&str> = dep_info
753            .imports
754            .iter()
755            .take(8)
756            .map(std::string::String::as_str)
757            .collect();
758        output.push_str(&format!("\n deps {}", imports_str.join(",")));
759    }
760    // Self-describing outputs (GL #580): symbol notation always ships
761    // its own one-line legend so vanilla agents can read it.
762    if crp_mode.is_tdd() {
763        let refs: Vec<&signatures::Signature> = sigs.iter().collect();
764        let legend = signatures::tdd_legend(&refs);
765        if !legend.is_empty() {
766            output.push('\n');
767            output.push_str(&legend);
768        }
769    }
770    let health = health_annotations(content, ext);
771    let refs: Vec<&signatures::Signature> = sigs.iter().collect();
772    let (lines, budget_note) =
773        super::outline_budget::outline_lines(&refs, crp_mode.is_tdd(), &health);
774    for line in lines.iter().chain(budget_note.iter()) {
775        output.push('\n');
776        output.push_str(line);
777    }
778    // Same honesty rule as map: an empty signature view for a language
779    // without an extractor must be labeled (limitations audit, #4).
780    if sigs.is_empty() && dep_info.imports.is_empty() {
781        output.push_str(&no_structure_marker(ext));
782    }
783    if let Some(body) = task_relevant_body(content, file_path, ext, task) {
784        output.push('\n');
785        output.push_str(&body);
786    }
787    // JIT disclosure (GL#447): signatures carry L-spans, so point at the
788    // targeted range expansion before the full-read escalation.
789    if lean_ctx::core::profiles::active_profile()
790        .output_hints
791        .compressed_hint()
792        && !sigs.is_empty()
793    {
794        output.push_str(&format!(
795                    "\n  ↳ expand a symbol: ctx_read(\"{file_path}\", mode=\"lines:N-M\") using the spans above"
796                ));
797        // Located symbols are addressable as stable handles (#607).
798        output.push_str(&format!("\n  {}", lean_ctx::core::handle::USAGE_HINT));
799    }
800    let sent = count_tokens(&output);
801    (
802        append_compressed_hint(
803            &protocol::append_savings(&output, original_tokens, sent),
804            file_path,
805        ),
806        sent,
807    )
808}
809
810fn render_map(content: &str, ctx: RenderCtx<'_>) -> (String, usize) {
811    let RenderCtx {
812        file_ref,
813        short,
814        ext,
815        file_path,
816        original_tokens,
817        crp_mode,
818        line_count,
819        task,
820    } = ctx;
821    if ext == "php"
822        && let Some(php_map) = lean_ctx::core::patterns::php::compress_php_map(content, short)
823    {
824        let output = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
825            format!("{file_ref}={short} {line_count}L\n{php_map}")
826        } else {
827            format!("{short} {line_count}L\n{php_map}")
828        };
829        let sent = count_tokens(&output);
830        let output = protocol::append_savings(&output, original_tokens, sent);
831        return (append_compressed_hint(&output, file_path), sent);
832    }
833
834    let structured = match ext {
835        "md" | "mdx" | "rst" => lean_ctx::core::structured_read::extract_markdown_outline(content),
836        "json" => lean_ctx::core::structured_read::extract_json_structure(content),
837        "yaml" | "yml" => lean_ctx::core::structured_read::extract_yaml_structure(content),
838        "toml" => lean_ctx::core::structured_read::extract_toml_structure(content),
839        _ if file_path.to_lowercase().ends_with(".lock")
840            || file_path.to_lowercase().ends_with("go.sum") =>
841        {
842            lean_ctx::core::structured_read::extract_lock_summary(content, file_path)
843        }
844        _ => String::new(),
845    };
846
847    if !structured.is_empty() {
848        let mut output = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
849            format!("{file_ref}={short} {line_count}L\n{structured}")
850        } else {
851            format!("{short} {line_count}L\n{structured}")
852        };
853        let sent = count_tokens(&output);
854        output = protocol::append_savings(&output, original_tokens, sent);
855        return (append_compressed_hint(&output, file_path), sent);
856    }
857
858    let sigs = signatures::extract_signatures(content, ext);
859    let dep_info = deps::extract_deps(content, ext);
860
861    let mut output = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
862        format!("{file_ref}={short} {line_count}L")
863    } else {
864        format!("{short} {line_count}L")
865    };
866
867    if !dep_info.imports.is_empty() {
868        output.push_str("\n  deps: ");
869        output.push_str(&dep_info.imports.join(", "));
870    }
871
872    let key_sigs: Vec<&signatures::Signature> = sigs
873        .iter()
874        .filter(|s| s.is_exported || s.indent == 0)
875        .collect();
876
877    // Drop exports the API section already lists with full signatures
878    // (pure redundant tokens in map mode, #361).
879    let extra_exports = signatures::exports_not_in_signatures(&dep_info.exports, &key_sigs);
880    if !extra_exports.is_empty() {
881        output.push_str("\n  exports: ");
882        output.push_str(&extra_exports.join(", "));
883    }
884
885    if !key_sigs.is_empty() {
886        output.push_str("\n  API:");
887        // Self-describing outputs (GL #580): legend precedes symbols.
888        if crp_mode.is_tdd() {
889            let legend = signatures::tdd_legend(&key_sigs);
890            if !legend.is_empty() {
891                output.push_str(&format!(" {legend}"));
892            }
893        }
894        let health = health_annotations(content, ext);
895        let (lines, budget_note) =
896            super::outline_budget::outline_lines(&key_sigs, crp_mode.is_tdd(), &health);
897        for line in &lines {
898            output.push_str("\n    ");
899            output.push_str(line);
900        }
901        if let Some(note) = budget_note {
902            output.push_str("\n  ");
903            output.push_str(&note);
904        }
905    }
906
907    // Nothing extractable (no grammar/regex coverage for this language):
908    // an information-free map must say so, or the caller reads the bare
909    // header as "this file has no API" (limitations audit, #4 residual).
910    if key_sigs.is_empty() && dep_info.imports.is_empty() && extra_exports.is_empty() {
911        output.push_str(&no_structure_marker(ext));
912    }
913
914    if let Some(body) = task_relevant_body(content, file_path, ext, task) {
915        output.push('\n');
916        output.push_str(&body);
917    }
918    // Located symbols are addressable as stable handles (#607).
919    if lean_ctx::core::profiles::active_profile()
920        .output_hints
921        .compressed_hint()
922        && !key_sigs.is_empty()
923    {
924        output.push_str(&format!("\n  {}", lean_ctx::core::handle::USAGE_HINT));
925    }
926
927    let sent = count_tokens(&output);
928    (
929        append_compressed_hint(
930            &protocol::append_savings(&output, original_tokens, sent),
931            file_path,
932        ),
933        sent,
934    )
935}
936
937fn render_aggressive(content: &str, ctx: RenderCtx<'_>) -> (String, usize) {
938    let RenderCtx {
939        file_ref,
940        short,
941        ext,
942        file_path,
943        original_tokens,
944        line_count,
945        ..
946    } = ctx;
947    // Structured JSON (#936): a redundant array-of-objects compacts far
948    // better — and losslessly — through the shared `json_crush` core than
949    // generic text pruning, which mangles structure. Fires only when it
950    // at least halves the file and shrinks the token count; the exact
951    // bytes stay recoverable via a `full`/`raw` re-read.
952    if ext == "json"
953        && let Some(crushed) = lean_ctx::core::json_crush::crush_text_if_beneficial(content)
954    {
955        let header = build_header(file_ref, short, ext, content, line_count, true);
956        let body = format!("{header}\n{crushed}");
957        let sent = count_tokens(&body);
958        if monotonic_check(original_tokens, sent) {
959            let savings = protocol::format_savings(original_tokens, sent);
960            return (
961                append_compressed_hint(&format!("{body}\n{savings}"), file_path),
962                sent,
963            );
964        }
965        return raw_fallback(file_path, content, original_tokens, sent);
966    }
967
968    // Tabular data (CSV/TSV, #982): a redundant table hoists its constant
969    // columns once through the columnar crusher (lossless); the exact
970    // bytes stay recoverable via a `full`/`raw` re-read.
971    if let Some(delim) = compressor::tabular_delimiter(Some(ext))
972        && let Some(crushed) =
973            lean_ctx::core::tabular_crush::crush_text_if_beneficial(content, delim)
974    {
975        let header = build_header(file_ref, short, ext, content, line_count, true);
976        let body = format!("{header}\n{crushed}");
977        let sent = count_tokens(&body);
978        if monotonic_check(original_tokens, sent) {
979            let savings = protocol::format_savings(original_tokens, sent);
980            return (
981                append_compressed_hint(&format!("{body}\n{savings}"), file_path),
982                sent,
983            );
984        }
985        return raw_fallback(file_path, content, original_tokens, sent);
986    }
987
988    // YAML (#985): a verbose document compacts losslessly to compact JSON
989    // through the shared crusher (formatting dropped, redundant
990    // `items`/`list` arrays factored); the exact bytes stay recoverable
991    // via a `full`/`raw` re-read.
992    if compressor::is_yaml_ext(Some(ext))
993        && let Some(crushed) = lean_ctx::core::yaml_crush::crush_text_if_beneficial(content)
994    {
995        let header = build_header(file_ref, short, ext, content, line_count, true);
996        let body = format!("{header}\n{crushed}");
997        let sent = count_tokens(&body);
998        if monotonic_check(original_tokens, sent) {
999            let savings = protocol::format_savings(original_tokens, sent);
1000            return (
1001                append_compressed_hint(&format!("{body}\n{savings}"), file_path),
1002                sent,
1003            );
1004        }
1005        return raw_fallback(file_path, content, original_tokens, sent);
1006    }
1007
1008    #[cfg(feature = "tree-sitter")]
1009    let ast_pruned = lean_ctx::core::signatures_ts::ast_prune(content, ext);
1010    #[cfg(not(feature = "tree-sitter"))]
1011    let ast_pruned: Option<String> = None;
1012
1013    let base = ast_pruned.as_deref().unwrap_or(content);
1014
1015    let session_intent =
1016        lean_ctx::core::session::caller::with(|s| s.active_structured_intent.clone()).flatten();
1017    let raw = if let Some(ref intent) = session_intent {
1018        compressor::task_aware_compress(base, Some(ext), intent)
1019    } else {
1020        compressor::aggressive_compress(base, Some(ext))
1021    };
1022    let compressed = compressor::safeguard_ratio(content, &raw);
1023    let header = build_header(file_ref, short, ext, content, line_count, true);
1024
1025    let mut sym = SymbolMap::new();
1026    let idents = symbol_map::extract_identifiers(&compressed, &[ext]);
1027    for ident in &idents {
1028        sym.register(ident);
1029    }
1030
1031    if symbol_map::substitution_enabled() && sym.len() >= 3 {
1032        let sym_table = sym.format_table();
1033        let sym_applied = sym.apply(&compressed);
1034        let orig_tok = count_tokens(&compressed);
1035        let comp_tok = count_tokens(&sym_applied) + count_tokens(&sym_table);
1036        let net = orig_tok.saturating_sub(comp_tok);
1037        if orig_tok > 0 && net * 100 / orig_tok >= 5 {
1038            if !monotonic_check(original_tokens, comp_tok) {
1039                return raw_fallback(file_path, content, original_tokens, comp_tok);
1040            }
1041            let savings = protocol::format_savings(original_tokens, comp_tok);
1042            return (
1043                append_compressed_hint(
1044                    &format!("{header}\n{sym_applied}{sym_table}\n{savings}"),
1045                    file_path,
1046                ),
1047                comp_tok,
1048            );
1049        }
1050        if !monotonic_check(original_tokens, orig_tok) {
1051            return raw_fallback(file_path, content, original_tokens, orig_tok);
1052        }
1053        let savings = protocol::format_savings(original_tokens, orig_tok);
1054        return (
1055            append_compressed_hint(&format!("{header}\n{compressed}\n{savings}"), file_path),
1056            orig_tok,
1057        );
1058    }
1059
1060    let sent = count_tokens(&compressed);
1061    if !monotonic_check(original_tokens, sent) {
1062        return raw_fallback(file_path, content, original_tokens, sent);
1063    }
1064    let savings = protocol::format_savings(original_tokens, sent);
1065    (
1066        append_compressed_hint(&format!("{header}\n{compressed}\n{savings}"), file_path),
1067        sent,
1068    )
1069}
1070
1071fn render_entropy(content: &str, ctx: RenderCtx<'_>, tuning: &ReadTuning<'_>) -> (String, usize) {
1072    let RenderCtx {
1073        file_ref,
1074        short,
1075        ext,
1076        file_path,
1077        original_tokens,
1078        line_count,
1079        task,
1080        ..
1081    } = ctx;
1082    // Query-conditioned IB (#542) — relevance source chain: explicit
1083    // task param > active session intent > last semantic-search query.
1084    let task_kws: Vec<String> = task
1085        .filter(|t| !t.trim().is_empty())
1086        .map(|t| lean_ctx::core::task_relevance::parse_task_hints(t).1)
1087        .filter(|kws| !kws.is_empty())
1088        .or_else(|| {
1089            // The caller's own session, never another agent's (I07/S03).
1090            lean_ctx::core::session::caller::with(|session| {
1091                if let Some(intent) = &session.active_structured_intent
1092                    && !intent.keywords.is_empty()
1093                {
1094                    return Some(intent.keywords.clone());
1095                }
1096                let q = session.last_semantic_query.as_deref()?;
1097                let kws = lean_ctx::core::task_relevance::parse_task_hints(q).1;
1098                (!kws.is_empty()).then_some(kws)
1099            })
1100            .flatten()
1101        })
1102        .unwrap_or_default();
1103    let result = match (task_kws.is_empty(), tuning.aggressiveness) {
1104        // Aggressiveness overrides the file-adaptive BPE-entropy threshold for
1105        // the plain (no task keywords) path; task-conditioned entropy
1106        // keeps its own relevance-aware thresholds.
1107        (true, Some(a)) => entropy::entropy_compress_with_threshold(
1108            content,
1109            file_path,
1110            AggressivenessProfile::from_level(a).bpe_entropy,
1111            tuning.protect,
1112        ),
1113        (true, None) => entropy::entropy_compress_adaptive(content, file_path, tuning.protect),
1114        (false, _) => entropy::entropy_compress_task_conditioned(
1115            content,
1116            file_path,
1117            &task_kws,
1118            tuning.protect,
1119        ),
1120    };
1121    let avg_h = entropy::analyze_entropy(content).avg_entropy;
1122    let header = build_header(file_ref, short, ext, content, line_count, false);
1123    let output = format!(
1124        "{header} H̄={avg_h:.1}{}\n{}",
1125        techniques_tag(&result.techniques),
1126        result.output
1127    );
1128    finish_lossy_view(&output, content, original_tokens, file_path)
1129}
1130
1131/// Appends the savings line and recovery hint to a lossy view, and keeps the
1132/// view only when what is *delivered* still beats the raw file (#361). The
1133/// footer counts: an entropy view a few tokens under raw on a CRLF checkout
1134/// went over it once the footer was added, with no "no compression" notice.
1135fn finish_lossy_view(
1136    output: &str,
1137    content: &str,
1138    original_tokens: usize,
1139    file_path: &str,
1140) -> (String, usize) {
1141    let sent = count_tokens(output);
1142    if !monotonic_check(original_tokens, sent) {
1143        return raw_fallback(file_path, content, original_tokens, sent);
1144    }
1145    let savings = protocol::format_savings(original_tokens, sent);
1146    let delivered = append_compressed_hint(&format!("{output}\n{savings}"), file_path);
1147    if !monotonic_check(original_tokens, count_tokens(&delivered)) {
1148        return raw_fallback(file_path, content, original_tokens, sent);
1149    }
1150    (delivered, sent)
1151}
1152
1153/// Renders a task-mode selection as numbered lines with explicit gap markers.
1154///
1155/// Task mode returns *fragments*. Delivered bare they read like a small,
1156/// complete file: nothing says where a fragment sits or that anything was
1157/// dropped between two adjacent lines, so the view gives a false picture of the
1158/// source (#1589). `N|` matches the numbering `lines:N-M` and `anchored` use,
1159/// so the numbers double as coordinates for the follow-up read or patch.
1160fn render_task_selection(keywords: &[String], selected: &[(usize, &str)]) -> String {
1161    let mut out = format!("[task: {}]", keywords.join(", "));
1162    let mut prev_line: Option<usize> = None;
1163    for (idx, line) in selected {
1164        let lineno = idx + 1;
1165        if let Some(prev) = prev_line
1166            && lineno > prev + 1
1167        {
1168            let skipped = lineno - prev - 1;
1169            out.push_str(&format!("\n… {skipped}L"));
1170        }
1171        out.push_str(&format!("\n{lineno}|{line}"));
1172        prev_line = Some(lineno);
1173    }
1174    out
1175}
1176
1177fn render_task_mode(content: &str, ctx: RenderCtx<'_>, tuning: &ReadTuning<'_>) -> (String, usize) {
1178    let RenderCtx {
1179        file_ref,
1180        short,
1181        ext,
1182        file_path,
1183        original_tokens,
1184        line_count,
1185        task,
1186        ..
1187    } = ctx;
1188    let task_str = task.unwrap_or("");
1189    if task_str.is_empty() {
1190        let header = build_header(file_ref, short, ext, content, line_count, true);
1191        let out = format!("{header}\n{content}\n[task mode: no task set — returned full]");
1192        let sent = count_tokens(&out);
1193        return (out, sent);
1194    }
1195    let (_files, keywords) = lean_ctx::core::task_relevance::parse_task_hints(task_str);
1196    if keywords.is_empty() {
1197        let header = build_header(file_ref, short, ext, content, line_count, true);
1198        let out =
1199            format!("{header}\n{content}\n[task mode: no keywords extracted — returned full]");
1200        let sent = count_tokens(&out);
1201        return (out, sent);
1202    }
1203    // #840: small files save too few tokens to justify the risk of dropping
1204    // relevant content. Degrade to full — the agent gets the complete file
1205    // for a negligible token cost and avoids the "had to retry with mode=full"
1206    // round-trip that the issue reported.
1207    const TASK_MIN_LINES: usize = 250;
1208    if line_count < TASK_MIN_LINES {
1209        let header = build_header(file_ref, short, ext, content, line_count, true);
1210        let out = format!(
1211            "{header}\n{content}\n[task mode: file below {TASK_MIN_LINES}L threshold — returned full]"
1212        );
1213        let sent = count_tokens(&out);
1214        return (out, sent);
1215    }
1216    // Aggressiveness tightens the IB keep-budget; default 0.3 preserved
1217    // when the knob is unset.
1218    let ib_budget = tuning.aggressiveness.map_or(0.3, |a| {
1219        AggressivenessProfile::from_level(a).ib_budget_ratio
1220    });
1221    let is_markdown = matches!(ext, "md" | "markdown" | "mdx" | "rst");
1222    let filtered = if is_markdown {
1223        lean_ctx::core::task_relevance::information_bottleneck_filter_with_headers(
1224            content,
1225            &keywords,
1226            ib_budget,
1227            tuning.protect,
1228            true,
1229        )
1230    } else {
1231        // #1589: code fragments need coordinates. Prose does not — markdown
1232        // keeps its section-header reconstruction instead.
1233        let selected = lean_ctx::core::task_relevance::ib_select(
1234            content,
1235            &keywords,
1236            ib_budget,
1237            None,
1238            tuning.protect,
1239        );
1240        render_task_selection(&keywords, &selected)
1241    };
1242    let filtered_lines = filtered.lines().count();
1243    let header = if lean_ctx::core::protocol::meta_visible() && !file_ref.is_empty() {
1244        format!("{file_ref}={short} {line_count}L [task-filtered: {line_count}→{filtered_lines}]")
1245    } else {
1246        format!("{short} {line_count}L [task-filtered: {line_count}→{filtered_lines}]")
1247    };
1248    let graph_ctx = if lean_ctx::core::profiles::active_profile()
1249        .output_hints
1250        .graph_context_block()
1251    {
1252        let project_root = detect_project_root(file_path);
1253        lean_ctx::core::graph_context::build_graph_context(
1254            file_path,
1255            &project_root,
1256            Some(lean_ctx::core::graph_context::GraphContextOptions::default()),
1257        )
1258        .map(|c| lean_ctx::core::graph_context::format_graph_context(&c))
1259        .unwrap_or_default()
1260    } else {
1261        String::new()
1262    };
1263
1264    let sent = count_tokens(&filtered) + count_tokens(&header) + count_tokens(&graph_ctx);
1265    if !monotonic_check(original_tokens, sent) {
1266        return raw_fallback(file_path, content, original_tokens, sent);
1267    }
1268    let savings = protocol::format_savings(original_tokens, sent);
1269    (
1270        append_compressed_hint(
1271            &format!("{header}\n{filtered}{graph_ctx}\n{savings}"),
1272            file_path,
1273        ),
1274        sent,
1275    )
1276}