Skip to main content

lean_ctx/core/web/
mod.rs

1//! Web & research context layer.
2//!
3//! Turns an arbitrary URL (web page or YouTube video) into compressed,
4//! citation-backed context for an agent. The flow is:
5//!
6//! 1. [`url_guard`] validates the URL and blocks SSRF targets.
7//! 2. [`fetch`] downloads it (bounded, manual-redirect, SSRF-revalidated) — or
8//!    [`youtube`] pulls a transcript for video URLs.
9//! 3. [`html_to_text`] renders HTML to clean Markdown.
10//! 4. [`distill`] applies the requested research-compression mode.
11//! 5. [`citation`] attaches source attribution.
12//!
13//! The single entry point is [`read_url`]; the `tools::registered::ctx_url_read`
14//! MCP tool is a thin wrapper over it.
15
16pub mod citation;
17pub mod distill;
18pub mod feed;
19pub mod fetch;
20pub mod html_to_text;
21pub mod pdf;
22pub mod rewrite;
23pub mod url_guard;
24pub mod youtube;
25
26use crate::core::evidence::Claim;
27
28use citation::Citation;
29
30/// Default token budget for returned content.
31pub const DEFAULT_MAX_TOKENS: usize = 6000;
32/// Default number of items for `facts` / `quotes` modes.
33pub const DEFAULT_MAX_ITEMS: usize = 12;
34const MAX_LINKS: usize = 100;
35
36/// How fetched content should be distilled before returning.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ReadMode {
39    /// Pick a sensible mode from the content type (Markdown for pages,
40    /// transcript summary for videos).
41    Auto,
42    /// Clean Markdown of the main content.
43    Markdown,
44    /// Plain text (Markdown decorations stripped).
45    Text,
46    /// Extracted hyperlinks.
47    Links,
48    /// Sentences carrying factual signals.
49    Facts,
50    /// Central / query-relevant sentences as evidence.
51    Quotes,
52    /// De-duplicated, filler-stripped transcript (best for videos).
53    Transcript,
54}
55
56impl ReadMode {
57    pub fn parse(s: &str) -> Option<Self> {
58        match s.trim().to_ascii_lowercase().as_str() {
59            "auto" => Some(Self::Auto),
60            "markdown" | "md" => Some(Self::Markdown),
61            "text" | "plain" => Some(Self::Text),
62            "links" => Some(Self::Links),
63            "facts" => Some(Self::Facts),
64            "quotes" => Some(Self::Quotes),
65            "transcript" | "summary" => Some(Self::Transcript),
66            _ => None,
67        }
68    }
69
70    pub fn label(self) -> &'static str {
71        match self {
72            Self::Auto => "auto",
73            Self::Markdown => "markdown",
74            Self::Text => "text",
75            Self::Links => "links",
76            Self::Facts => "facts",
77            Self::Quotes => "quotes",
78            Self::Transcript => "transcript",
79        }
80    }
81}
82
83/// Parameters for [`read_url`].
84pub struct ReadOptions<'a> {
85    pub url: &'a str,
86    pub mode: ReadMode,
87    pub query: Option<&'a str>,
88    pub max_tokens: usize,
89    pub max_items: usize,
90    pub timeout_secs: u64,
91}
92
93impl<'a> ReadOptions<'a> {
94    pub fn new(url: &'a str) -> Self {
95        Self {
96            url,
97            mode: ReadMode::Auto,
98            query: None,
99            max_tokens: DEFAULT_MAX_TOKENS,
100            max_items: DEFAULT_MAX_ITEMS,
101            timeout_secs: fetch::DEFAULT_TIMEOUT_SECS,
102        }
103    }
104}
105
106/// Result of a successful [`read_url`].
107pub struct ReadResult {
108    /// Distilled content with the citation footer already appended.
109    pub content: String,
110    /// Effective mode after `Auto` resolution.
111    pub mode: ReadMode,
112    /// Token count of the raw fetched payload (for savings accounting).
113    pub original_tokens: usize,
114    pub final_url: String,
115}
116
117/// Admission of extracted source text: returns the text that may be used, or
118/// a content-free reason why none of it may. See [`read_url_admitted`].
119pub type AdmitText<'a> = &'a dyn Fn(&str) -> Result<String, String>;
120
121/// Fetch and distill a URL into citation-backed context.
122///
123/// No admission is applied to the extracted text; an agent-facing caller
124/// uses [`read_url_admitted`].
125pub fn read_url(opts: &ReadOptions) -> Result<ReadResult, String> {
126    read_url_admitted(opts, &|text| Ok(text.to_string()))
127}
128
129/// [`read_url`] with admission BEFORE distillation.
130///
131/// `admit` is applied to the extracted source text — the page's Markdown, a
132/// PDF's text, a transcript — before any mode renders, ranks or summarizes
133/// it, and to every other piece of page content that reaches the result (the
134/// rendered link list, the title in the citation footer). Facts, quotes,
135/// summaries and plain text are therefore derived only from admitted text: a
136/// marking on the page cannot be dropped by the distillation before it is
137/// seen, and a masked value cannot reappear in a derived line. A refusal ends
138/// the read with `admit`'s reason; nothing of the page is returned. The token
139/// count of the raw payload stays a number only.
140pub fn read_url_admitted(opts: &ReadOptions, admit: AdmitText<'_>) -> Result<ReadResult, String> {
141    // Rewrite known page URLs (e.g. GitHub blob → raw) to their clean-content
142    // equivalent before fetching, so the agent gets the file instead of chrome.
143    let rewritten = rewrite::rewrite_url(opts.url);
144    let url = rewritten.as_deref().unwrap_or(opts.url);
145
146    if let Some(id) = youtube::video_id(url) {
147        let transcript = youtube::fetch_transcript(&id, opts.timeout_secs)?;
148        return distil_transcript(
149            &transcript.full_text,
150            transcript.title,
151            transcript.source_url,
152            opts,
153            admit,
154        );
155    }
156
157    let effective = ReadOptions {
158        url,
159        mode: opts.mode,
160        query: opts.query,
161        max_tokens: opts.max_tokens,
162        max_items: opts.max_items,
163        timeout_secs: opts.timeout_secs,
164    };
165    read_web(&effective, admit)
166}
167
168/// What extraction produced from one fetched document, before any admission.
169struct Extracted {
170    title: Option<String>,
171    markdown: String,
172    links: Vec<html_to_text::Link>,
173    original_tokens: usize,
174}
175
176fn read_web(opts: &ReadOptions, admit: AdmitText<'_>) -> Result<ReadResult, String> {
177    let doc = fetch::fetch(opts.url, fetch::DEFAULT_MAX_BYTES, opts.timeout_secs)?;
178    if doc.status >= 400 {
179        return Err(format!("HTTP {} from {}", doc.status, doc.final_url));
180    }
181
182    let is_pdf = doc.content_type.contains("pdf")
183        || (doc.content_type.is_empty() && pdf::looks_like_pdf(&doc.bytes));
184
185    let (title, markdown, links, original_tokens) = if is_pdf {
186        let text = pdf::extract_text(&doc.bytes)?;
187        let tokens = crate::core::tokens::count_tokens(&text);
188        (None, text, Vec::new(), tokens)
189    } else {
190        let body = doc.body_text();
191        let tokens = crate::core::tokens::count_tokens(&body);
192        let looks_html = body.trim_start().starts_with('<');
193        // RSS/Atom feeds are XML, so check them before the HTML branch (which
194        // would otherwise flatten a feed into unreadable text — GH #feedback).
195        if feed::looks_like_feed(&doc.content_type, &body) {
196            let parsed = feed::parse(&body, &doc.final_url);
197            (parsed.title, parsed.markdown, Vec::new(), tokens)
198        } else if is_html(&doc.content_type) || (doc.content_type.is_empty() && looks_html) {
199            let parsed = html_to_text::parse(&body);
200            (parsed.title, parsed.markdown, parsed.links, tokens)
201        } else if is_textual(&doc.content_type) {
202            (None, body, Vec::new(), tokens)
203        } else {
204            return Err(format!(
205                "unsupported content type '{}' for {} (extractable: HTML, PDF, plain text)",
206                doc.content_type, doc.final_url
207            ));
208        }
209    };
210
211    distil_page(
212        Extracted {
213            title,
214            markdown,
215            links,
216            original_tokens,
217        },
218        doc.final_url,
219        opts,
220        admit,
221    )
222}
223
224/// A title is page content too. One that is not admitted is left out of the
225/// citation; the body's own admission has already decided about the page.
226fn admitted_title(title: Option<String>, admit: AdmitText<'_>) -> Option<String> {
227    title.and_then(|title| admit(&title).ok())
228}
229
230/// Admit an extracted page, then distil it. Everything a mode renders is
231/// derived from the admitted Markdown; the link list, which extraction took
232/// from the raw document, is admitted as rendered.
233fn distil_page(
234    extracted: Extracted,
235    final_url: String,
236    opts: &ReadOptions,
237    admit: AdmitText<'_>,
238) -> Result<ReadResult, String> {
239    let markdown = admit(&extracted.markdown)?;
240
241    let effective = match opts.mode {
242        ReadMode::Auto => ReadMode::Markdown,
243        other => other,
244    };
245
246    let body = render_mode(effective, &markdown, &extracted.links, &final_url, opts);
247    let body = if effective == ReadMode::Links {
248        admit(&body)?
249    } else {
250        body
251    };
252    let body = apply_persona_compressor(body, effective);
253    let trimmed = enforce_budget(&body, opts.max_tokens);
254    let citation = Citation::new(&final_url, admitted_title(extracted.title, admit));
255
256    Ok(ReadResult {
257        content: format!("{trimmed}{}", citation.footer()),
258        mode: effective,
259        original_tokens: extracted.original_tokens,
260        final_url,
261    })
262}
263
264/// Admit a transcript, then distil it: facts, quotes and the summary are
265/// derived from the admitted text only.
266fn distil_transcript(
267    full_text: &str,
268    title: Option<String>,
269    source_url: String,
270    opts: &ReadOptions,
271    admit: AdmitText<'_>,
272) -> Result<ReadResult, String> {
273    let original_tokens = crate::core::tokens::count_tokens(full_text);
274    let full_text = admit(full_text)?;
275
276    let effective = match opts.mode {
277        ReadMode::Auto => ReadMode::Transcript,
278        other => other,
279    };
280
281    let body = match effective {
282        ReadMode::Facts => render_facts(&claims_from(
283            distill::facts_scored(&full_text, opts.query, opts.max_items),
284            &source_url,
285        )),
286        ReadMode::Quotes => render_quotes(&claims_from(
287            distill::quotes_scored(&full_text, opts.query, opts.max_items),
288            &source_url,
289        )),
290        ReadMode::Links => "Links are not available for video transcripts.".to_string(),
291        _ => distill::summarize_prose(&full_text, opts.max_tokens.saturating_mul(4), opts.query),
292    };
293
294    let body = apply_persona_compressor(body, effective);
295    let trimmed = enforce_budget(&body, opts.max_tokens);
296    let citation = Citation::new(&source_url, admitted_title(title, admit));
297
298    Ok(ReadResult {
299        content: format!("{trimmed}{}", citation.footer()),
300        mode: effective,
301        original_tokens,
302        final_url: source_url,
303    })
304}
305
306fn render_mode(
307    mode: ReadMode,
308    markdown: &str,
309    links: &[html_to_text::Link],
310    base_url: &str,
311    opts: &ReadOptions,
312) -> String {
313    match mode {
314        ReadMode::Markdown | ReadMode::Auto => markdown.to_string(),
315        ReadMode::Text => html_to_text::markdown_to_text(markdown),
316        ReadMode::Links => render_links(links, base_url),
317        ReadMode::Facts => {
318            let plain = html_to_text::markdown_to_text(markdown);
319            let claims = claims_from(
320                distill::facts_scored(&plain, opts.query, opts.max_items),
321                base_url,
322            );
323            render_facts(&claims)
324        }
325        ReadMode::Quotes => {
326            let plain = html_to_text::markdown_to_text(markdown);
327            let claims = claims_from(
328                distill::quotes_scored(&plain, opts.query, opts.max_items),
329                base_url,
330            );
331            render_quotes(&claims)
332        }
333        ReadMode::Transcript => {
334            let plain = html_to_text::markdown_to_text(markdown);
335            distill::summarize_prose(&plain, opts.max_tokens.saturating_mul(4), opts.query)
336        }
337    }
338}
339
340fn render_links(links: &[html_to_text::Link], base_url: &str) -> String {
341    if links.is_empty() {
342        return "No links found.".to_string();
343    }
344    let base = url_guard::validate(base_url).ok();
345    let mut seen = std::collections::HashSet::new();
346    let mut out = Vec::new();
347    for link in links {
348        let abs = absolutize(&link.href, base.as_ref());
349        if seen.insert(abs.clone()) {
350            out.push(format!("- [{}]({abs})", link.text));
351            if out.len() >= MAX_LINKS {
352                break;
353            }
354        }
355    }
356    out.join("\n")
357}
358
359fn absolutize(href: &str, base: Option<&url_guard::SafeUrl>) -> String {
360    if href.starts_with("http://") || href.starts_with("https://") {
361        return href.to_string();
362    }
363    match base {
364        Some(b) => fetch::resolve_redirect(b, href),
365        None => href.to_string(),
366    }
367}
368
369/// Build attributable claims from scored sentences, tagging each with `source`.
370fn claims_from(scored: Vec<(String, f32)>, source: &str) -> Vec<Claim> {
371    scored
372        .into_iter()
373        .map(|(text, conf)| Claim::new(text, conf).with_source(source))
374        .collect()
375}
376
377/// Render facts as a confidence-prefixed bullet list. The shared source lives in
378/// the citation footer, so it is not repeated per line (token-lean).
379fn render_facts(claims: &[Claim]) -> String {
380    if claims.is_empty() {
381        return "No matching content found.".to_string();
382    }
383    claims
384        .iter()
385        .map(|c| format!("- ({:.2}) {}", c.confidence, c.text))
386        .collect::<Vec<_>>()
387        .join("\n")
388}
389
390fn render_quotes(claims: &[Claim]) -> String {
391    if claims.is_empty() {
392        return "No quotable content found.".to_string();
393    }
394    claims
395        .iter()
396        .map(|c| format!("> ({:.2}) {}", c.confidence, c.text))
397        .collect::<Vec<_>>()
398        .join("\n\n")
399}
400
401/// persona-spec-v1 — apply the active persona's registry compressor to
402/// flowing-text modes (`research` → `markdown`, `support`/`lead-gen` →
403/// `prose`). Extractive modes (facts/quotes/links) stay verbatim: their lines
404/// are claims and citations whose wording must not be rewritten. `identity`
405/// (the `coding` default) is a guaranteed no-op and skips the registry lookup.
406fn apply_persona_compressor(body: String, mode: ReadMode) -> String {
407    if !matches!(
408        mode,
409        ReadMode::Auto | ReadMode::Markdown | ReadMode::Text | ReadMode::Transcript
410    ) {
411        return body;
412    }
413    let name = crate::core::persona::active().compressor;
414    if name == "identity" {
415        return body;
416    }
417    let Some(compressor) = crate::core::extension_registry::global()
418        .read()
419        .ok()
420        .and_then(|reg| reg.compressor(&name))
421    else {
422        tracing::debug!("persona compressor '{name}' not registered; passing through");
423        return body;
424    };
425    compressor.compress(&body, None)
426}
427
428/// The budgeted body; a cut names the archived original (K25).
429fn enforce_budget(content: &str, max_tokens: usize) -> String {
430    let cut = cut_to_budget(content, max_tokens);
431    if cut == content {
432        return cut;
433    }
434    crate::core::recovery::with_original_pointer("ctx_url_read", "budget", content, cut)
435}
436
437fn cut_to_budget(content: &str, max_tokens: usize) -> String {
438    let tokens = crate::core::tokens::count_tokens(content);
439    if tokens <= max_tokens {
440        return content.to_string();
441    }
442    // persona-spec-v1 — cut at the persona chunker's boundaries (paragraphs
443    // for research/support/lead-gen, line windows for coding/data-analysis)
444    // so the truncation lands between units of meaning, not mid-sentence.
445    if let Some(trimmed) = trim_at_chunk_boundaries(content, max_tokens) {
446        return trimmed;
447    }
448    // Fallback: proportional character cut (chunker unavailable, content is a
449    // single chunk, or the first chunk alone exceeds the budget).
450    let total_chars = content.chars().count();
451    let ratio = max_tokens as f64 / tokens as f64;
452    let keep = ((total_chars as f64 * ratio) as usize).max(1);
453    let truncated: String = content.chars().take(keep).collect();
454    format!("{truncated}\n\n…[truncated to fit ~{max_tokens} token budget]")
455}
456
457/// Trim `content` to whole persona-chunker chunks within `max_tokens`.
458///
459/// Each kept chunk is located back in the original text (chunkers may trim
460/// separators), so the cut always lands on real source bytes. Returns `None`
461/// when the chunker yields fewer than two chunks, a chunk cannot be located
462/// verbatim (e.g. a transforming format chunker), or not even the first chunk
463/// fits — callers then fall back to the proportional character cut.
464fn trim_at_chunk_boundaries(content: &str, max_tokens: usize) -> Option<String> {
465    use crate::core::tokens::count_tokens;
466    let name = crate::core::persona::active().chunker;
467    let chunker = crate::core::extension_registry::global()
468        .read()
469        .ok()?
470        .chunker(&name)?;
471    let chunks = chunker.chunk(content);
472    if chunks.len() < 2 {
473        return None;
474    }
475    let mut cut = 0usize;
476    let mut used = 0usize;
477    let mut search_from = 0usize;
478    for chunk in &chunks {
479        let chunk_tokens = count_tokens(chunk);
480        if used + chunk_tokens > max_tokens {
481            break;
482        }
483        let rel = content.get(search_from..)?.find(chunk.as_str())?;
484        let end = search_from + rel + chunk.len();
485        used += chunk_tokens;
486        cut = end;
487        search_from = end;
488    }
489    if cut == 0 {
490        return None;
491    }
492    Some(format!(
493        "{}\n\n…[truncated to fit ~{max_tokens} token budget]",
494        content[..cut].trim_end()
495    ))
496}
497
498fn is_html(content_type: &str) -> bool {
499    content_type.contains("html") || content_type.contains("xml")
500}
501
502fn is_textual(content_type: &str) -> bool {
503    content_type.starts_with("text/")
504        || content_type.contains("json")
505        || content_type.contains("markdown")
506        || content_type.contains("plain")
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    fn page(markdown: &str) -> Extracted {
514        Extracted {
515            title: Some("Launch plan".to_string()),
516            markdown: markdown.to_string(),
517            links: vec![html_to_text::Link {
518                text: "internal roadmap".into(),
519                href: "https://intra.example/roadmap".into(),
520            }],
521            original_tokens: 100,
522        }
523    }
524
525    fn options(mode: ReadMode) -> ReadOptions<'static> {
526        ReadOptions {
527            mode,
528            ..ReadOptions::new("https://docs.example/page")
529        }
530    }
531
532    const ALL_MODES: [ReadMode; 7] = [
533        ReadMode::Auto,
534        ReadMode::Markdown,
535        ReadMode::Text,
536        ReadMode::Links,
537        ReadMode::Facts,
538        ReadMode::Quotes,
539        ReadMode::Transcript,
540    ];
541
542    /// G01: distillation used to run on the raw extracted text, so a mode
543    /// that drops lines (facts, quotes, summary, links) could drop the very
544    /// marking an admission after it would have needed. Admission now runs
545    /// first, in every mode, on pages and on transcripts.
546    #[test]
547    fn a_refused_source_yields_nothing_in_any_mode() {
548        let refuse = |text: &str| -> Result<String, String> {
549            if text.contains("CONFIDENTIAL") {
550                Err("source content withheld by policy".to_string())
551            } else {
552                Ok(text.to_string())
553            }
554        };
555        let source = "CONFIDENTIAL\n\nThe launch moves to 14 March 2027. Revenue grew 40% in Q3.";
556        for mode in ALL_MODES {
557            let Err(reason) = distil_page(
558                page(source),
559                "https://docs.example/page".to_string(),
560                &options(mode),
561                &refuse,
562            ) else {
563                panic!("mode {} delivered a refused page", mode.label());
564            };
565            assert_eq!(reason, "source content withheld by policy");
566            let Err(reason) = distil_transcript(
567                source,
568                None,
569                "https://youtu.be/x".to_string(),
570                &options(mode),
571                &refuse,
572            ) else {
573                panic!("mode {} delivered a refused transcript", mode.label());
574            };
575            assert_eq!(reason, "source content withheld by policy");
576        }
577    }
578
579    /// Every derived part comes from the admitted text: a value admission
580    /// masks appears in no mode's output, the link list and the title are
581    /// admitted too, and an identity admission changes nothing.
582    #[test]
583    fn distilled_parts_derive_only_from_admitted_text() {
584        let mask = |text: &str| -> Result<String, String> {
585            if text == "Launch plan" {
586                return Err("withheld".to_string());
587            }
588            Ok(text
589                .replace("ORBITAL-7", "[masked]")
590                .replace("intra.example", "[masked]"))
591        };
592        let identity = |text: &str| -> Result<String, String> { Ok(text.to_string()) };
593        let source = "Project ORBITAL-7 ships on 14 March 2027. Revenue grew 40% in Q3 2026.";
594        for mode in ALL_MODES {
595            let masked = distil_page(
596                page(source),
597                "https://docs.example/page".to_string(),
598                &options(mode),
599                &mask,
600            )
601            .unwrap();
602            assert!(!masked.content.contains("ORBITAL-7"), "{}", mode.label());
603            assert!(
604                !masked.content.contains("intra.example"),
605                "{}",
606                mode.label()
607            );
608            assert!(!masked.content.contains("Launch plan"), "{}", mode.label());
609            assert_eq!(masked.original_tokens, 100);
610
611            let masked = distil_transcript(
612                source,
613                Some("Launch plan".to_string()),
614                "https://youtu.be/x".to_string(),
615                &options(mode),
616                &mask,
617            )
618            .unwrap();
619            assert!(!masked.content.contains("ORBITAL-7"), "{}", mode.label());
620            assert!(!masked.content.contains("Launch plan"), "{}", mode.label());
621        }
622        let plain = distil_page(
623            page(source),
624            "https://docs.example/page".to_string(),
625            &options(ReadMode::Markdown),
626            &identity,
627        )
628        .unwrap();
629        assert!(plain.content.starts_with(source), "{}", plain.content);
630    }
631
632    #[test]
633    fn read_mode_parsing_is_lenient() {
634        assert_eq!(ReadMode::parse("MD"), Some(ReadMode::Markdown));
635        assert_eq!(ReadMode::parse(" transcript "), Some(ReadMode::Transcript));
636        assert_eq!(ReadMode::parse("summary"), Some(ReadMode::Transcript));
637        assert_eq!(ReadMode::parse("bogus"), None);
638    }
639
640    #[test]
641    fn content_type_classification() {
642        assert!(is_html("text/html"));
643        assert!(is_html("application/xhtml+xml"));
644        assert!(is_textual("text/plain"));
645        assert!(is_textual("application/json"));
646        assert!(!is_html("application/pdf"));
647        assert!(!is_textual("application/pdf"));
648    }
649
650    #[test]
651    fn claim_renderers_handle_empty_and_confidence() {
652        assert_eq!(render_facts(&[]), "No matching content found.");
653        assert_eq!(render_quotes(&[]), "No quotable content found.");
654
655        let claims = claims_from(
656            vec![("Alpha".to_string(), 0.9), ("Beta".to_string(), 0.5)],
657            "https://src.example/page",
658        );
659        assert_eq!(render_facts(&claims), "- (0.90) Alpha\n- (0.50) Beta");
660        assert_eq!(
661            claims[0].source_url.as_deref(),
662            Some("https://src.example/page")
663        );
664    }
665
666    #[test]
667    fn render_links_absolutizes_and_dedupes() {
668        let links = vec![
669            html_to_text::Link {
670                text: "rel".into(),
671                href: "/about".into(),
672            },
673            html_to_text::Link {
674                text: "abs".into(),
675                href: "https://y.com/z".into(),
676            },
677            html_to_text::Link {
678                text: "dup".into(),
679                href: "https://y.com/z".into(),
680            },
681        ];
682        let out = render_links(&links, "https://x.com/dir/page");
683        assert!(out.contains("[rel](https://x.com/about)"));
684        assert!(out.contains("[abs](https://y.com/z)"));
685        assert_eq!(out.matches("https://y.com/z").count(), 1);
686    }
687
688    #[test]
689    fn enforce_budget_truncates_when_over() {
690        let big = "word ".repeat(5000);
691        let out = enforce_budget(&big, 50);
692        assert!(out.contains("[truncated"));
693        assert!(crate::core::tokens::count_tokens(&out) < crate::core::tokens::count_tokens(&big));
694    }
695
696    #[test]
697    fn enforce_budget_keeps_small_content() {
698        let small = "short content";
699        assert_eq!(enforce_budget(small, 1000), small);
700    }
701
702    #[test]
703    fn persona_chunker_trims_at_paragraph_boundaries() {
704        let _guard = crate::core::data_dir::test_env_lock();
705        crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
706
707        let para = "alpha beta gamma delta epsilon zeta eta theta";
708        let content = format!("{para}\n\n{para}\n\n{para}\n\n{para}");
709        let budget = crate::core::tokens::count_tokens(para) * 2 + 1;
710        let out = enforce_budget(&content, budget);
711
712        crate::test_env::remove_var("LEAN_CTX_PERSONA");
713
714        assert!(out.contains("[truncated"), "{out}");
715        // The cut lands on a paragraph boundary: kept paragraphs verbatim,
716        // no mid-word fragment before the marker.
717        let body = out.split("\n\n…[truncated").next().unwrap();
718        assert_eq!(body, format!("{para}\n\n{para}"), "{out}");
719    }
720
721    #[test]
722    fn persona_compressor_strips_markdown_noise_for_research() {
723        let _guard = crate::core::data_dir::test_env_lock();
724        crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
725
726        let body = "Intro ![badge](https://img.example/b.svg)\n\n\
727                    See [the docs](https://docs.example/page) for details."
728            .to_string();
729        let out = apply_persona_compressor(body, ReadMode::Markdown);
730
731        crate::test_env::remove_var("LEAN_CTX_PERSONA");
732
733        assert!(out.contains("the docs"), "{out}");
734        assert!(!out.contains("https://docs.example"), "{out}");
735        assert!(!out.contains("img.example"), "{out}");
736    }
737
738    #[test]
739    fn persona_compressor_leaves_extractive_modes_verbatim() {
740        let _guard = crate::core::data_dir::test_env_lock();
741        crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
742
743        let body = "- (0.90) A [cited](https://src.example) claim.".to_string();
744        let out = apply_persona_compressor(body.clone(), ReadMode::Facts);
745
746        crate::test_env::remove_var("LEAN_CTX_PERSONA");
747
748        assert_eq!(out, body);
749    }
750
751    #[test]
752    fn persona_compressor_is_identity_for_coding() {
753        let _guard = crate::core::data_dir::test_env_lock();
754        crate::test_env::set_var("LEAN_CTX_PERSONA", "coding");
755
756        let body = "Anything ![badge](https://img.example/b.svg) stays.".to_string();
757        let out = apply_persona_compressor(body.clone(), ReadMode::Markdown);
758
759        crate::test_env::remove_var("LEAN_CTX_PERSONA");
760
761        assert_eq!(out, body);
762    }
763}