lean_ctx/core/
structured_read.rs1use std::collections::BTreeMap;
2
3pub 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
44pub 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
111pub 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
172pub 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 §ions {
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
222pub 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}