1use std::collections::{BTreeSet, HashMap, HashSet};
10use std::fmt::Write as _;
11
12const SECTION_BODY_FRACTION: f64 = 0.35;
13const MIN_SECTION_BODY_LINES: usize = 2;
14const MIN_CONTENT_LINES: usize = 24;
16
17const STOP_WORDS: &[&str] = &[
18 "the", "and", "for", "with", "that", "this", "from", "into", "your", "you", "are", "can",
19 "will", "not", "all", "use", "using", "used", "lean", "ctx", "context", "agent", "agents",
20];
21
22struct Unit {
27 start: usize,
29 end: usize,
30 kind: UnitKind,
31}
32
33#[derive(Clone, Copy, PartialEq, Eq)]
34enum UnitKind {
35 Heading,
36 Prose,
37 Fence,
38}
39
40impl Unit {
41 fn content_lines(&self, lines: &[&str]) -> usize {
44 lines[self.start..self.end]
45 .iter()
46 .filter(|l| !l.trim().is_empty())
47 .count()
48 }
49}
50
51pub fn compact_markdown(content: &str) -> Option<String> {
55 if content.trim().is_empty() || !looks_like_markdown(content) {
56 return None;
57 }
58
59 let lines: Vec<&str> = content.lines().collect();
60 let units = parse_units(&lines);
61 let content_total: usize = units.iter().map(|u| u.content_lines(&lines)).sum();
62 if content_total < MIN_CONTENT_LINES {
63 return None;
64 }
65
66 let keep = select_units(&lines, &units, content_total);
67
68 let mut out = String::new();
69 let mut omitted = 0usize;
70 for (uidx, unit) in units.iter().enumerate() {
71 if keep.contains(&uidx) {
72 flush_omission(&mut out, &mut omitted);
73 for line in &lines[unit.start..unit.end] {
74 out.push_str(line.trim_end());
75 out.push('\n');
76 }
77 } else {
78 omitted += unit.content_lines(&lines);
79 }
80 }
81 flush_omission(&mut out, &mut omitted);
82
83 let kept_content = out.lines().filter(|l| !l.trim().is_empty()).count();
84 if kept_content >= content_total || out.len() >= content.len() {
85 None
86 } else {
87 Some(out)
88 }
89}
90
91pub fn has_markdown_headings(content: &str) -> bool {
95 content.lines().any(is_heading)
96}
97
98fn parse_units(lines: &[&str]) -> Vec<Unit> {
103 let mut units = Vec::new();
104 let mut i = 0;
105 while i < lines.len() {
106 let trimmed = lines[i].trim_start();
107 if trimmed.is_empty() {
108 i += 1;
109 continue;
110 }
111 if let Some(marker) = fence_marker(trimmed) {
112 let mut end = i + 1;
113 while end < lines.len() && !is_closing_fence(lines[end], marker) {
114 end += 1;
115 }
116 let end = (end + 1).min(lines.len());
118 units.push(Unit {
119 start: i,
120 end,
121 kind: UnitKind::Fence,
122 });
123 i = end;
124 continue;
125 }
126 let kind = if is_heading(lines[i]) {
127 UnitKind::Heading
128 } else {
129 UnitKind::Prose
130 };
131 units.push(Unit {
132 start: i,
133 end: i + 1,
134 kind,
135 });
136 i += 1;
137 }
138 units
139}
140
141fn fence_marker(trimmed: &str) -> Option<char> {
143 ['`', '~']
144 .into_iter()
145 .find(|&marker| trimmed.chars().take_while(|c| *c == marker).count() >= 3)
146}
147
148fn is_closing_fence(line: &str, marker: char) -> bool {
151 let trimmed = line.trim_start();
152 trimmed.chars().take_while(|c| *c == marker).count() >= 3
153 && trimmed.chars().all(|c| c == marker || c.is_whitespace())
154}
155
156fn select_units(lines: &[&str], units: &[Unit], content_total: usize) -> HashSet<usize> {
157 let docs = token_sets(lines);
158 let df = document_frequency(&docs);
159 let mut keep = HashSet::new();
160 let mut section_body: Vec<usize> = Vec::new();
161
162 for (uidx, unit) in units.iter().enumerate() {
163 if unit.kind == UnitKind::Heading {
164 select_section_body(
165 &mut keep,
166 §ion_body,
167 lines,
168 units,
169 &docs,
170 &df,
171 content_total,
172 );
173 section_body.clear();
174 keep.insert(uidx);
175 } else {
176 section_body.push(uidx);
177 }
178 }
179 select_section_body(
180 &mut keep,
181 §ion_body,
182 lines,
183 units,
184 &docs,
185 &df,
186 content_total,
187 );
188 keep
189}
190
191fn select_section_body(
192 keep: &mut HashSet<usize>,
193 body: &[usize],
194 lines: &[&str],
195 units: &[Unit],
196 docs: &[BTreeSet<String>],
197 df: &HashMap<String, usize>,
198 content_total: usize,
199) {
200 if body.is_empty() {
201 return;
202 }
203
204 let body_lines: usize = body.iter().map(|u| units[*u].content_lines(lines)).sum();
205 let target = section_body_budget(body_lines);
206
207 let mut scored: Vec<(usize, f64)> = body
208 .iter()
209 .map(|uidx| {
210 (
211 *uidx,
212 score_unit(&units[*uidx], lines, docs, content_total, df),
213 )
214 })
215 .collect();
216 scored.sort_by(|a, b| {
217 b.1.partial_cmp(&a.1)
218 .unwrap_or(std::cmp::Ordering::Equal)
219 .then_with(|| a.0.cmp(&b.0))
220 });
221
222 if let Some(first) = body.first() {
224 keep.insert(*first);
225 }
226 let mut taken = 0usize;
229 for (uidx, _) in scored {
230 if taken >= target {
231 break;
232 }
233 keep.insert(uidx);
234 taken += units[uidx].content_lines(lines);
235 }
236}
237
238fn section_body_budget(line_count: usize) -> usize {
239 let fractional = ((line_count as f64) * SECTION_BODY_FRACTION).ceil() as usize;
240 fractional.max(MIN_SECTION_BODY_LINES).min(line_count)
241}
242
243fn flush_omission(out: &mut String, omitted: &mut usize) {
244 if *omitted == 0 {
245 return;
246 }
247 let _ = writeln!(out, "... [lean-ctx: omitted {omitted} lines]");
248 *omitted = 0;
249}
250
251fn looks_like_markdown(content: &str) -> bool {
252 content.lines().any(is_heading)
253 || content.lines().any(|l| l.trim_start().starts_with("- "))
254 || content.lines().any(|l| l.trim_start().starts_with("* "))
255 || content.lines().any(|l| l.trim_start().starts_with("| "))
256}
257
258fn is_heading(line: &str) -> bool {
262 let trimmed = line.trim_start();
263 let hashes = trimmed.chars().take_while(|c| *c == '#').count();
264 (1..=6).contains(&hashes) && (trimmed.len() == hashes || trimmed[hashes..].starts_with(' '))
265}
266
267fn token_sets(lines: &[&str]) -> Vec<BTreeSet<String>> {
271 lines
272 .iter()
273 .map(|line| tokens(line).into_iter().collect())
274 .collect()
275}
276
277fn document_frequency(docs: &[BTreeSet<String>]) -> HashMap<String, usize> {
278 let mut df = HashMap::new();
279 for doc in docs {
280 for token in doc {
281 *df.entry(token.clone()).or_insert(0) += 1;
282 }
283 }
284 df
285}
286
287fn score_unit(
288 unit: &Unit,
289 lines: &[&str],
290 docs: &[BTreeSet<String>],
291 content_total: usize,
292 df: &HashMap<String, usize>,
293) -> f64 {
294 match unit.kind {
295 UnitKind::Fence => {
296 let mut tokens = BTreeSet::new();
299 for doc in &docs[unit.start..unit.end] {
300 tokens.extend(doc.iter().cloned());
301 }
302 idf_sum(&tokens, content_total, df) + 10.0 + position_bonus(unit.start)
303 }
304 _ => score_line(
305 unit.start,
306 lines[unit.start],
307 &docs[unit.start],
308 content_total,
309 df,
310 ),
311 }
312}
313
314fn score_line(
315 idx: usize,
316 line: &str,
317 tokens: &BTreeSet<String>,
318 line_count: usize,
319 df: &HashMap<String, usize>,
320) -> f64 {
321 let mut score = idf_sum(tokens, line_count, df);
322
323 let trimmed = line.trim_start();
324 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("| ") {
325 score += 2.0;
326 }
327 if line.contains('`')
328 || line.contains("MUST")
329 || line.contains("SHOULD")
330 || line.contains("BLOCKING")
331 || line.contains("WARNING")
332 || line.contains("ctx_")
333 || line.contains("lean-ctx")
334 {
335 score += 10.0;
336 }
337 score + position_bonus(idx)
338}
339
340fn idf_sum(tokens: &BTreeSet<String>, line_count: usize, df: &HashMap<String, usize>) -> f64 {
342 let mut score = 0.0;
343 for token in tokens {
344 let freq = *df.get(token).unwrap_or(&1) as f64;
345 score += ((line_count as f64 + 1.0) / (freq + 1.0)).ln();
346 }
347 score
348}
349
350fn position_bonus(idx: usize) -> f64 {
351 1.0 / (idx + 1) as f64
352}
353
354fn tokens(line: &str) -> Vec<String> {
355 let mut out = Vec::new();
356 let mut cur = String::new();
357 for ch in line.chars() {
358 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '/' | '.' | ':') {
359 cur.push(ch);
360 } else if !cur.is_empty() {
361 push_token(&mut out, &cur);
362 cur.clear();
363 }
364 }
365 if !cur.is_empty() {
366 push_token(&mut out, &cur);
367 }
368 out
369}
370
371fn push_token(out: &mut Vec<String>, token: &str) {
372 let t = token
373 .trim_matches(|c: char| matches!(c, '.' | ',' | ':' | ';' | '(' | ')' | '[' | ']'))
374 .to_ascii_lowercase();
375 if t.len() < 3 || STOP_WORDS.contains(&t.as_str()) {
376 return;
377 }
378 if t.len() >= 8 || t.chars().any(|c| matches!(c, '_' | '-' | '/' | '.' | ':')) {
379 out.push(t);
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::{compact_markdown, has_markdown_headings};
386
387 #[test]
388 fn keeps_all_headings_and_shrinks() {
389 let input = "# Title\n\nIntro paragraph with ordinary words.\n\n## Setup\n\n";
390 let body = "- `ctx_read` keeps important details for agents.\n";
391 let repeated = "This sentence is useful once but repeated many times for filler.\n";
392 let mut doc = input.to_string();
393 for _ in 0..20 {
394 doc.push_str(repeated);
395 }
396 doc.push_str(body);
397 doc.push_str("## Safety\n\nMUST preserve warnings and exact commands.\n");
398 for _ in 0..20 {
399 doc.push_str(repeated);
400 }
401
402 let compacted = compact_markdown(&doc).expect("markdown should compact");
403 assert!(compacted.len() < doc.len());
404 assert!(compacted.contains("# Title"));
405 assert!(compacted.contains("## Setup"));
406 assert!(compacted.contains("## Safety"));
407 assert!(compacted.contains("ctx_read"));
408 assert!(compacted.contains("MUST preserve"));
409 assert!(compacted.contains("[lean-ctx: omitted"));
410 }
411
412 #[test]
413 fn keeps_body_lines_from_each_section() {
414 let mut doc = String::from("# Root\n\nRoot intro.\n");
415 for section in 0..8 {
416 doc.push_str(&format!("\n## Section {section}\n\n"));
417 doc.push_str(&format!("Section {section} overview line.\n"));
418 for item in 0..12 {
419 doc.push_str(&format!(
420 "- repeated filler item {item} for section {section}.\n"
421 ));
422 }
423 doc.push_str(&format!(
424 "BLOCKING section {section} exact requirement with `ctx_read`.\n"
425 ));
426 }
427
428 let compacted = compact_markdown(&doc).expect("markdown should compact");
429 assert!(compacted.len() < doc.len());
430 for section in 0..8 {
431 assert!(compacted.contains(&format!("## Section {section}")));
432 assert!(compacted.contains(&format!("Section {section} overview line.")));
433 assert!(compacted.contains(&format!("BLOCKING section {section}")));
434 }
435 assert!(compacted.contains("[lean-ctx: omitted"));
436 }
437
438 #[test]
439 fn short_docs_pass_through() {
440 assert!(compact_markdown("# Title\n\nSmall.\n").is_none());
441 }
442
443 #[test]
444 fn output_is_deterministic_across_calls() {
445 let mut doc = String::from("# Determinism\n\nIntro line for the document.\n");
451 for section in 0..4 {
452 doc.push_str(&format!("\n## Section {section}\n\n"));
453 for i in 0..30 {
454 doc.push_str(&format!(
455 "candidate_{i} shared_token_{} another_token_{} overlapping detail item.\n",
456 i % 3,
457 i % 7,
458 ));
459 }
460 }
461
462 let first = compact_markdown(&doc).expect("doc should compact");
463 for _ in 0..16 {
464 let next = compact_markdown(&doc).expect("doc should compact");
465 assert_eq!(first, next, "compaction must be byte-stable across calls");
466 }
467 }
468
469 #[test]
470 fn fenced_blocks_stay_atomic() {
471 let filler = "Ordinary explanatory filler sentence repeated for volume.\n";
472 let mut doc = String::from("# Guide\n\nIntro line.\n\n## Usage\n\n");
473 for _ in 0..30 {
474 doc.push_str(filler);
475 }
476 doc.push_str("Run the exact `ctx_read` command below:\n\n");
477 doc.push_str(
478 "```bash\nlean-ctx read src/lib.rs\n\nlean-ctx search \"ctx_read\" src/\n```\n",
479 );
480 for _ in 0..30 {
481 doc.push_str(filler);
482 }
483
484 let compacted = compact_markdown(&doc).expect("doc should compact");
485
486 let mut in_fence = false;
489 for line in compacted.lines() {
490 if line.trim_start().starts_with("```") {
491 in_fence = !in_fence;
492 } else if in_fence {
493 assert!(
494 !line.starts_with("... [lean-ctx:"),
495 "omission marker inside a fenced block:\n{compacted}"
496 );
497 }
498 }
499 assert!(!in_fence, "unbalanced code fences:\n{compacted}");
500
501 assert!(compacted.contains(
504 "```bash\nlean-ctx read src/lib.rs\n\nlean-ctx search \"ctx_read\" src/\n```"
505 ));
506 assert!(compacted.contains("[lean-ctx: omitted"));
507 }
508
509 #[test]
510 fn heading_inside_fence_is_not_structure() {
511 let mut doc = String::from("# Real Heading\n\nIntro.\n\n");
514 doc.push_str("```sh\n# just a shell comment\necho done\n```\n");
515 for i in 0..30 {
516 doc.push_str(&format!("Body filler sentence number {i} for volume.\n"));
517 }
518
519 let compacted = compact_markdown(&doc).expect("doc should compact");
520 let fences = compacted.matches("```").count();
521 assert_eq!(fences % 2, 0, "fences must stay balanced:\n{compacted}");
522 }
523
524 #[test]
525 fn has_markdown_headings_requires_atx_space() {
526 assert!(has_markdown_headings("# Title\nbody\n"));
527 assert!(has_markdown_headings("###\nempty heading is valid\n"));
528 assert!(!has_markdown_headings("#!/usr/bin/env bash\necho hi\n"));
529 assert!(!has_markdown_headings("#pragma once\nplain text\n"));
530 assert!(!has_markdown_headings("- a list\n- alone\n"));
531 }
532}