Skip to main content

lean_ctx/core/
structured_read.rs

1use std::collections::BTreeMap;
2
3/// Markdown outline: heading tree + fenced code block boundaries.
4pub fn extract_markdown_outline(content: &str) -> String {
5    let mut parts = Vec::new();
6    let mut in_code_block = false;
7
8    for line in content.lines() {
9        let trimmed = line.trim();
10
11        if trimmed.starts_with("```") {
12            in_code_block = !in_code_block;
13            continue;
14        }
15        if in_code_block {
16            continue;
17        }
18
19        if let Some(heading) = parse_heading(trimmed) {
20            parts.push(heading);
21        }
22    }
23
24    if parts.is_empty() {
25        return String::new();
26    }
27
28    parts.join("\n")
29}
30
31fn parse_heading(line: &str) -> Option<String> {
32    let level = line.bytes().take_while(|&b| b == b'#').count();
33    if !(1..=6).contains(&level) {
34        return None;
35    }
36    let rest = line[level..].trim();
37    if rest.is_empty() {
38        return None;
39    }
40    let indent = "  ".repeat(level.saturating_sub(1));
41    Some(format!("{indent}{rest}"))
42}
43
44/// JSON structure: key-tree with types, depth 3, max 20 keys per level.
45/// Reuses logic from `patterns::json_schema` but produces a read-mode output.
46pub fn extract_json_structure(content: &str) -> String {
47    let trimmed = content.trim();
48    let val: serde_json::Value = match serde_json::from_str(trimmed) {
49        Ok(v) => v,
50        Err(_) => return String::new(),
51    };
52    format_json_value(&val, 0)
53}
54
55fn format_json_value(val: &serde_json::Value, depth: usize) -> String {
56    let indent = "  ".repeat(depth);
57    match val {
58        serde_json::Value::Object(map) => {
59            if map.is_empty() {
60                return format!("{indent}{{}}");
61            }
62            if depth > 3 {
63                return format!("{indent}{{...{} keys}}", map.len());
64            }
65            let mut entries = Vec::new();
66            for (key, value) in map.iter().take(20) {
67                match value {
68                    serde_json::Value::Object(inner) if !inner.is_empty() && depth < 3 => {
69                        let nested = format_json_value(value, depth + 1);
70                        entries.push(format!("{indent}  {key}: {{\n{nested}\n{indent}  }}"));
71                    }
72                    serde_json::Value::Array(arr) if !arr.is_empty() => {
73                        let item_type = arr.first().map_or("any", json_type_name);
74                        entries.push(format!("{indent}  {key}: [{item_type}...{}]", arr.len()));
75                    }
76                    _ => {
77                        entries.push(format!("{indent}  {key}: {}", json_type_name(value)));
78                    }
79                }
80            }
81            if map.len() > 20 {
82                entries.push(format!("{indent}  ...+{} more keys", map.len() - 20));
83            }
84            entries.join("\n")
85        }
86        serde_json::Value::Array(arr) => {
87            if arr.is_empty() {
88                return format!("{indent}[]");
89            }
90            let first_schema = format_json_value(&arr[0], depth + 1);
91            format!(
92                "{indent}[{} items, each:\n{first_schema}\n{indent}]",
93                arr.len()
94            )
95        }
96        other => format!("{indent}{}", json_type_name(other)),
97    }
98}
99
100fn json_type_name(val: &serde_json::Value) -> &'static str {
101    match val {
102        serde_json::Value::Null => "null",
103        serde_json::Value::Bool(_) => "bool",
104        serde_json::Value::Number(_) => "num",
105        serde_json::Value::String(_) => "str",
106        serde_json::Value::Array(_) => "array",
107        serde_json::Value::Object(_) => "object",
108    }
109}
110
111/// YAML structure: indent-based key extraction with nested structure.
112pub fn extract_yaml_structure(content: &str) -> String {
113    let mut parts = Vec::new();
114    let mut prev_indent = 0usize;
115
116    for line in content.lines() {
117        let trimmed = line.trim();
118        if trimmed.is_empty() || trimmed.starts_with('#') {
119            continue;
120        }
121
122        let indent = line.len() - line.trim_start().len();
123        if let Some(key) = extract_yaml_key(trimmed) {
124            let level = indent / 2;
125            let prefix = "  ".repeat(level);
126            parts.push(format!("{prefix}{key}"));
127            prev_indent = indent;
128        } else if trimmed.starts_with("- ")
129            && indent <= prev_indent + 2
130            && let Some(key) = extract_yaml_key(trimmed.trim_start_matches("- "))
131        {
132            let level = indent / 2;
133            let prefix = "  ".repeat(level);
134            parts.push(format!("{prefix}- {key}"));
135        }
136    }
137
138    deduplicate_consecutive(&parts)
139}
140
141fn extract_yaml_key(line: &str) -> Option<String> {
142    let colon_pos = line.find(':')?;
143    let key = line[..colon_pos].trim();
144    if key.is_empty() || key.contains(' ') && !key.starts_with('"') {
145        return None;
146    }
147    let value_part = line[colon_pos + 1..].trim();
148    if value_part.is_empty() || value_part == "|" || value_part == ">" {
149        Some(format!("{key}:"))
150    } else if value_part.len() > 40 {
151        Some(format!("{key}: ..."))
152    } else {
153        Some(format!("{key}: {value_part}"))
154    }
155}
156
157fn deduplicate_consecutive(lines: &[String]) -> String {
158    if lines.is_empty() {
159        return String::new();
160    }
161    let mut result = Vec::with_capacity(lines.len());
162    let mut prev = "";
163    for line in lines {
164        if line != prev {
165            result.push(line.as_str());
166            prev = line;
167        }
168    }
169    result.join("\n")
170}
171
172/// TOML structure: `[section]` headers + top-level key=value pairs.
173pub fn extract_toml_structure(content: &str) -> String {
174    let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
175    let mut current_section = String::new();
176
177    for line in content.lines() {
178        let trimmed = line.trim();
179        if trimmed.is_empty() || trimmed.starts_with('#') {
180            continue;
181        }
182
183        if trimmed.starts_with('[') {
184            if let Some(end) = trimmed.find(']') {
185                current_section = trimmed[1..end].to_string();
186                sections.entry(current_section.clone()).or_default();
187            }
188            continue;
189        }
190
191        if let Some(eq_pos) = trimmed.find('=') {
192            let key = trimmed[..eq_pos].trim();
193            let value = trimmed[eq_pos + 1..].trim();
194            let display_val = if value.len() > 40 { "..." } else { value };
195            sections
196                .entry(current_section.clone())
197                .or_default()
198                .push(format!("{key} = {display_val}"));
199        }
200    }
201
202    let mut parts = Vec::new();
203    for (section, keys) in &sections {
204        if section.is_empty() {
205            for k in keys {
206                parts.push(k.clone());
207            }
208        } else {
209            parts.push(format!("[{section}]"));
210            for k in keys.iter().take(10) {
211                parts.push(format!("  {k}"));
212            }
213            if keys.len() > 10 {
214                parts.push(format!("  ...+{} more", keys.len() - 10));
215            }
216        }
217    }
218
219    parts.join("\n")
220}
221
222/// Lock file summary: package count + direct dependency names.
223pub fn extract_lock_summary(content: &str, path: &str) -> String {
224    let lower = path.to_lowercase();
225    if lower.ends_with("cargo.lock") {
226        extract_cargo_lock_summary(content)
227    } else if lower.ends_with("package-lock.json") {
228        extract_npm_lock_summary(content)
229    } else if lower.ends_with("yarn.lock") {
230        extract_yarn_lock_summary(content)
231    } else if lower.ends_with("poetry.lock") || lower.ends_with("pdm.lock") {
232        extract_poetry_lock_summary(content)
233    } else if lower.ends_with("go.sum") {
234        extract_go_sum_summary(content)
235    } else {
236        extract_generic_lock_summary(content)
237    }
238}
239
240fn extract_cargo_lock_summary(content: &str) -> String {
241    let pkg_count = content
242        .lines()
243        .filter(|l| l.trim() == "[[package]]")
244        .count();
245
246    let mut local_crates: Vec<&str> = Vec::new();
247    let mut local_deps: Vec<&str> = Vec::new();
248    let mut current_name: Option<&str> = None;
249    let mut has_source = false;
250    let mut in_deps = false;
251
252    for line in content.lines() {
253        let t = line.trim();
254        if t == "[[package]]" {
255            if let Some(name) = current_name
256                && !has_source
257                && !local_crates.contains(&name)
258            {
259                local_crates.push(name);
260            }
261            current_name = None;
262            has_source = false;
263            in_deps = false;
264            continue;
265        }
266        if t.starts_with("name = ") {
267            current_name = Some(t.trim_start_matches("name = ").trim_matches('"'));
268        } else if t.starts_with("source = ") {
269            has_source = true;
270        } else if t.starts_with("dependencies = [") {
271            if !has_source {
272                in_deps = true;
273            }
274        } else if in_deps {
275            if t == "]" {
276                in_deps = false;
277            } else {
278                let dep = t.trim_matches(|c: char| c == '"' || c == ',');
279                let dep_name = dep.split_whitespace().next().unwrap_or(dep);
280                if !dep_name.is_empty() && !local_deps.contains(&dep_name) && local_deps.len() < 30
281                {
282                    local_deps.push(dep_name);
283                }
284            }
285        }
286    }
287    if let Some(name) = current_name
288        && !has_source
289        && !local_crates.contains(&name)
290    {
291        local_crates.push(name);
292    }
293
294    let mut out = format!("Cargo.lock: {pkg_count} packages");
295    if !local_crates.is_empty() {
296        out.push_str(&format!("\n  workspace: {}", local_crates.join(", ")));
297    }
298    if !local_deps.is_empty() {
299        out.push_str(&format!("\n  direct deps: {}", local_deps.join(", ")));
300    }
301    out
302}
303
304fn extract_npm_lock_summary(content: &str) -> String {
305    let val: serde_json::Value = match serde_json::from_str(content) {
306        Ok(v) => v,
307        Err(_) => return extract_generic_lock_summary(content),
308    };
309    let name = val.get("name").and_then(|v| v.as_str()).unwrap_or("?");
310    let pkg_count = val
311        .get("packages")
312        .and_then(|v| v.as_object())
313        .map(serde_json::Map::len)
314        .or_else(|| {
315            val.get("dependencies")
316                .and_then(|v| v.as_object())
317                .map(serde_json::Map::len)
318        })
319        .unwrap_or(0);
320    format!("package-lock.json ({name}): {pkg_count} packages")
321}
322
323fn extract_yarn_lock_summary(content: &str) -> String {
324    let pkg_count = content
325        .lines()
326        .filter(|l| !l.starts_with(' ') && !l.starts_with('#') && l.contains('@'))
327        .count();
328    format!("yarn.lock: ~{pkg_count} packages")
329}
330
331fn extract_poetry_lock_summary(content: &str) -> String {
332    let pkg_count = content
333        .lines()
334        .filter(|l| l.trim() == "[[package]]")
335        .count();
336    format!("poetry.lock: {pkg_count} packages")
337}
338
339fn extract_go_sum_summary(content: &str) -> String {
340    let mut modules = std::collections::HashSet::new();
341    for line in content.lines() {
342        if let Some(space) = line.find(' ') {
343            modules.insert(&line[..space]);
344        }
345    }
346    format!("go.sum: {} modules", modules.len())
347}
348
349fn extract_generic_lock_summary(content: &str) -> String {
350    let line_count = content.lines().count();
351    format!("lock file: {line_count} lines")
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn markdown_outline_extracts_headings() {
360        let md =
361            "# Title\n\nSome text.\n\n## Section A\n\n### Sub A1\n\n## Section B\n\nMore text.";
362        let outline = extract_markdown_outline(md);
363        assert!(outline.contains("Title"));
364        assert!(outline.contains("  Section A"));
365        assert!(outline.contains("    Sub A1"));
366        assert!(outline.contains("  Section B"));
367    }
368
369    #[test]
370    fn markdown_outline_skips_code_blocks() {
371        let md = "# Real\n\n```\n# Not a heading\n```\n\n## Also Real";
372        let outline = extract_markdown_outline(md);
373        assert!(outline.contains("Real"));
374        assert!(outline.contains("Also Real"));
375        assert!(!outline.contains("Not a heading"));
376    }
377
378    #[test]
379    fn markdown_outline_empty_for_no_headings() {
380        let md = "Just plain text\nwithout any headings.";
381        assert!(extract_markdown_outline(md).is_empty());
382    }
383
384    #[test]
385    fn json_structure_extracts_keys() {
386        let json = r#"{"name": "test", "version": "1.0", "deps": {"a": 1, "b": 2}}"#;
387        let structure = extract_json_structure(json);
388        assert!(structure.contains("name: str"));
389        assert!(structure.contains("version: str"));
390        assert!(structure.contains("deps: {"));
391        assert!(structure.contains("a: num"));
392    }
393
394    #[test]
395    fn json_structure_handles_arrays() {
396        let json = r#"[{"id": 1}, {"id": 2}]"#;
397        let structure = extract_json_structure(json);
398        assert!(structure.contains("2 items"));
399        assert!(structure.contains("id: num"));
400    }
401
402    #[test]
403    fn json_structure_empty_for_invalid() {
404        assert!(extract_json_structure("not json").is_empty());
405    }
406
407    #[test]
408    fn yaml_structure_extracts_keys() {
409        let yaml =
410            "name: my-app\nversion: 1.0\nservices:\n  web:\n    port: 8080\n  db:\n    port: 5432";
411        let structure = extract_yaml_structure(yaml);
412        assert!(structure.contains("name: my-app"));
413        assert!(structure.contains("version: 1.0"));
414        assert!(structure.contains("services:"));
415        assert!(structure.contains("web:"));
416    }
417
418    #[test]
419    fn yaml_structure_skips_comments() {
420        let yaml = "# Comment\nkey: value\n# Another comment\nkey2: value2";
421        let structure = extract_yaml_structure(yaml);
422        assert!(!structure.contains("Comment"));
423        assert!(structure.contains("key: value"));
424        assert!(structure.contains("key2: value2"));
425    }
426
427    #[test]
428    fn toml_structure_extracts_sections() {
429        let toml =
430            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0\"";
431        let structure = extract_toml_structure(toml);
432        assert!(structure.contains("[package]"));
433        assert!(structure.contains("name = \"test\""));
434        assert!(structure.contains("[dependencies]"));
435        assert!(structure.contains("serde = \"1.0\""));
436    }
437
438    #[test]
439    fn toml_structure_handles_top_level_keys() {
440        let toml = "key = \"value\"\n\n[section]\na = 1";
441        let structure = extract_toml_structure(toml);
442        assert!(structure.contains("key = \"value\""));
443        assert!(structure.contains("[section]"));
444    }
445
446    #[test]
447    fn cargo_lock_summary() {
448        let lock = "[[package]]\nname = \"serde\"\nversion = \"1.0\"\n\n[[package]]\nname = \"tokio\"\nversion = \"1.0\"";
449        let summary = extract_lock_summary(lock, "Cargo.lock");
450        assert!(summary.contains("2 packages"));
451    }
452
453    #[test]
454    fn npm_lock_summary() {
455        let lock = r#"{"name":"app","lockfileVersion":3,"packages":{"":{},"node_modules/a":{},"node_modules/b":{}}}"#;
456        let summary = extract_lock_summary(lock, "package-lock.json");
457        assert!(summary.contains("app"));
458        assert!(summary.contains("3 packages"));
459    }
460
461    #[test]
462    fn yarn_lock_summary_counts() {
463        let lock = "# yarn lockfile v1\n\na@^1.0:\n  version \"1.0\"\n\nb@^2.0:\n  version \"2.0\"";
464        let summary = extract_lock_summary(lock, "yarn.lock");
465        assert!(summary.contains("2 packages"));
466    }
467
468    #[test]
469    fn go_sum_summary_counts_modules() {
470        let sum = "github.com/a/b v1.0.0 h1:abc=\ngithub.com/a/b v1.0.0/go.mod h1:def=\ngithub.com/c/d v2.0.0 h1:ghi=";
471        let summary = extract_lock_summary(sum, "go.sum");
472        assert!(summary.contains("2 modules"));
473    }
474}