Skip to main content

lean_ctx/core/mdl_mode/
structural.rs

1//! Minimum description length (MDL) structural fingerprints for source files.
2//!
3//! Replaces full source with compact type and function signatures to minimize
4//! token cost while preserving navigable structure.
5
6use std::fmt::Write as _;
7
8use crate::core::signatures::{Signature, extract_signatures};
9use crate::core::tokens::count_tokens;
10
11/// A structural description of a source file.
12#[derive(Debug, Clone)]
13pub struct StructuralDescription {
14    /// File path.
15    pub path: String,
16    /// Module-level doc comment (first line only).
17    pub module_doc: Option<String>,
18    /// Exported types (structs, enums, traits).
19    pub types: Vec<TypeFingerprint>,
20    /// Exported functions and methods.
21    pub functions: Vec<FunctionFingerprint>,
22    /// Number of import declarations.
23    pub import_count: usize,
24    /// Total lines in original file.
25    pub total_lines: usize,
26    /// Token count of this structural description.
27    pub description_tokens: usize,
28    /// Token count of the original file.
29    pub original_tokens: usize,
30}
31
32/// Compact identity and member count for an exported type.
33#[derive(Debug, Clone)]
34pub struct TypeFingerprint {
35    /// Declared type name.
36    pub name: String,
37    /// Type category such as `struct`, `enum`, or `trait`.
38    pub kind: &'static str,
39    /// Number of fields, variants, or trait members.
40    pub field_count: usize,
41    /// Whether the declaration is exported.
42    pub is_exported: bool,
43}
44
45/// Compact signature and complexity classification for an exported function.
46#[derive(Debug, Clone)]
47pub struct FunctionFingerprint {
48    /// Declared function or method name.
49    pub name: String,
50    /// Compact parameter text.
51    pub params: String,
52    /// Declared return type.
53    pub return_type: String,
54    /// Whether the function is asynchronous.
55    pub is_async: bool,
56    /// Whether the function is exported.
57    pub is_exported: bool,
58    /// Coarse size classification: `simple`, `moderate`, or `complex`.
59    pub complexity_hint: &'static str,
60}
61
62/// Generate a structural description from source code.
63pub(crate) fn generate_structural_description(
64    content: &str,
65    path: &str,
66    file_ext: &str,
67) -> StructuralDescription {
68    let lines: Vec<&str> = content.lines().collect();
69    let signatures = extract_signatures(content, file_ext.trim_start_matches('.'));
70
71    let types = signatures
72        .iter()
73        .filter(|signature| {
74            signature.is_exported && matches!(signature.kind, "struct" | "enum" | "trait")
75        })
76        .map(|signature| TypeFingerprint {
77            name: signature.name.clone(),
78            kind: signature.kind,
79            field_count: type_member_count(signature, &lines),
80            is_exported: signature.is_exported,
81        })
82        .collect();
83
84    let functions = signatures
85        .iter()
86        .filter(|signature| signature.is_exported && matches!(signature.kind, "fn" | "method"))
87        .map(function_fingerprint)
88        .collect();
89
90    let original_tokens = count_tokens(content);
91    let mut description = StructuralDescription {
92        path: path.to_string(),
93        module_doc: extract_module_doc(content),
94        types,
95        functions,
96        import_count: count_imports(content),
97        total_lines: lines.len(),
98        description_tokens: 0,
99        original_tokens,
100    };
101
102    if !content.is_empty() {
103        description.description_tokens = count_tokens(&description.render());
104    }
105    description
106}
107
108impl StructuralDescription {
109    /// Render as compact text for LLM context.
110    pub fn render(&self) -> String {
111        let mut rendered = format!(
112            "# {} ({} lines, {} tokens → {} tokens structural)",
113            self.path, self.total_lines, self.original_tokens, self.description_tokens
114        );
115
116        if let Some(module_doc) = &self.module_doc {
117            let _ = write!(rendered, "\n## Module doc: {module_doc}");
118        }
119        if !self.types.is_empty() {
120            rendered.push_str("\n## Types: ");
121            let type_text = self
122                .types
123                .iter()
124                .map(render_type)
125                .collect::<Vec<_>>()
126                .join(", ");
127            rendered.push_str(&type_text);
128        }
129        if !self.functions.is_empty() {
130            rendered.push_str("\n## Functions: ");
131            let function_text = self
132                .functions
133                .iter()
134                .map(render_function)
135                .collect::<Vec<_>>()
136                .join(", ");
137            rendered.push_str(&function_text);
138        }
139        let _ = write!(rendered, "\n## Imports: {} imports", self.import_count);
140        rendered
141    }
142
143    /// Return structural token count divided by original token count.
144    pub fn compression_ratio(&self) -> f64 {
145        if self.original_tokens == 0 {
146            return 1.0;
147        }
148        self.description_tokens as f64 / self.original_tokens as f64
149    }
150}
151
152fn function_fingerprint(signature: &Signature) -> FunctionFingerprint {
153    let line_count = signature
154        .start_line
155        .zip(signature.end_line)
156        .map_or(1, |(start, end)| end.saturating_sub(start) + 1);
157    let param_count = signature
158        .params
159        .split(',')
160        .filter(|param| !param.trim().is_empty())
161        .count();
162    let complexity_hint = if line_count > 30 || param_count > 5 || signature.is_async {
163        "complex"
164    } else if line_count > 10 || param_count >= 3 {
165        "moderate"
166    } else {
167        "simple"
168    };
169
170    FunctionFingerprint {
171        name: signature.name.clone(),
172        params: signature.params.clone(),
173        return_type: signature.return_type.clone(),
174        is_async: signature.is_async,
175        is_exported: signature.is_exported,
176        complexity_hint,
177    }
178}
179
180fn type_member_count(signature: &Signature, lines: &[&str]) -> usize {
181    let Some(start) = signature.start_line else {
182        return 0;
183    };
184    let end = signature.end_line.unwrap_or(start).min(lines.len());
185    let Some(source) = lines.get(start.saturating_sub(1)..end) else {
186        return 0;
187    };
188    let declaration = source.join("\n");
189    count_braced_members(&declaration)
190}
191
192fn count_braced_members(declaration: &str) -> usize {
193    let Some(open) = declaration.find('{') else {
194        return 0;
195    };
196    let mut depth = 1_usize;
197    let mut count = 0_usize;
198    let mut has_member_text = false;
199
200    for character in declaration[open + 1..].chars() {
201        match character {
202            '{' => {
203                if depth == 1 {
204                    has_member_text = true;
205                }
206                depth += 1;
207            }
208            '}' => {
209                if depth == 1 {
210                    break;
211                }
212                depth -= 1;
213            }
214            ',' | ';' if depth == 1 => {
215                if has_member_text {
216                    count += 1;
217                    has_member_text = false;
218                }
219            }
220            character if depth == 1 && !character.is_whitespace() => has_member_text = true,
221            _ => {}
222        }
223    }
224    count + usize::from(has_member_text)
225}
226
227fn extract_module_doc(content: &str) -> Option<String> {
228    let mut in_block = false;
229    for line in content.lines() {
230        let trimmed = line.trim();
231        if let Some(doc) = trimmed.strip_prefix("//!") {
232            return nonempty_doc(doc);
233        }
234        if let Some(rest) = trimmed.strip_prefix("/**") {
235            in_block = true;
236            let first_line = rest.split("*/").next().unwrap_or_default();
237            if let Some(doc) = nonempty_doc(first_line) {
238                return Some(doc);
239            }
240        } else if in_block {
241            let doc_line = trimmed.trim_start_matches('*');
242            if let Some(doc) = nonempty_doc(doc_line.split("*/").next().unwrap_or_default()) {
243                return Some(doc);
244            }
245            if trimmed.contains("*/") {
246                in_block = false;
247            }
248        }
249    }
250    None
251}
252
253fn nonempty_doc(text: &str) -> Option<String> {
254    let doc = text.trim().trim_end_matches("*/").trim();
255    (!doc.is_empty()).then(|| doc.to_string())
256}
257
258fn count_imports(content: &str) -> usize {
259    content
260        .lines()
261        .map(str::trim_start)
262        .filter(|line| {
263            line.starts_with("use ")
264                || line.starts_with("pub use ")
265                || line.starts_with("import ")
266                || line.starts_with("from ")
267                || line.starts_with("#include")
268        })
269        .count()
270}
271
272fn render_type(fingerprint: &TypeFingerprint) -> String {
273    let label = match fingerprint.kind {
274        "struct" => "Struct",
275        "enum" => "Enum",
276        "trait" => "Trait",
277        _ => "Type",
278    };
279    let member_label = if fingerprint.kind == "enum" {
280        "variants"
281    } else {
282        "fields"
283    };
284    format!(
285        "{label} {}({} {member_label})",
286        fingerprint.name, fingerprint.field_count
287    )
288}
289
290fn render_function(fingerprint: &FunctionFingerprint) -> String {
291    let return_type = if fingerprint.return_type.is_empty() {
292        String::new()
293    } else {
294        format!(" -> {}", fingerprint.return_type)
295    };
296    let async_marker = if fingerprint.is_async { " [async]" } else { "" };
297    let export_marker = if fingerprint.is_exported {
298        " [pub]"
299    } else {
300        ""
301    };
302    format!(
303        "{}({}){return_type}{async_marker}{export_marker} [{}]",
304        fingerprint.name, fingerprint.params, fingerprint.complexity_hint
305    )
306}
307
308#[cfg(test)]
309mod tests {
310    use super::generate_structural_description;
311
312    const RUST_SOURCE: &str = r"//! User model and lookup helpers.
313use std::collections::HashMap;
314
315pub struct User {
316    pub id: u64,
317    pub name: String,
318}
319
320pub fn find_user(users: &HashMap<u64, User>, id: u64) -> Option<&User> {
321    users.get(&id)
322}
323";
324
325    #[test]
326    fn generate_for_rust_file_with_struct_and_fn() {
327        let desc = generate_structural_description(RUST_SOURCE, "src/user.rs", "rs");
328
329        assert_eq!(desc.types.len(), 1);
330        assert_eq!(desc.types[0].name, "User");
331        assert_eq!(desc.types[0].field_count, 2);
332        assert_eq!(desc.functions.len(), 1);
333        assert_eq!(desc.functions[0].name, "find_user");
334        assert_eq!(desc.import_count, 1);
335    }
336
337    #[test]
338    fn render_includes_types_and_functions() {
339        let rendered = generate_structural_description(RUST_SOURCE, "src/user.rs", "rs").render();
340
341        assert!(rendered.contains("Struct User(2 fields)"));
342        assert!(
343            rendered.contains("find_user("),
344            "render should include find_user function"
345        );
346    }
347
348    #[test]
349    fn compression_ratio_is_less_than_one() {
350        let body = "    let value = id + 1;\n".repeat(80);
351        let source = format!("pub fn transform(id: u64) -> u64 {{\n{body}    id\n}}\n");
352        let desc = generate_structural_description(&source, "src/large.rs", "rs");
353
354        assert!(desc.compression_ratio() < 1.0);
355    }
356
357    #[test]
358    fn empty_file_produces_minimal_description() {
359        let desc = generate_structural_description("", "empty.rs", "rs");
360
361        assert!(desc.types.is_empty());
362        assert!(desc.functions.is_empty());
363        assert_eq!(desc.total_lines, 0);
364        assert_eq!(desc.description_tokens, 0);
365    }
366
367    #[test]
368    fn module_doc_extracted_correctly() {
369        let desc = generate_structural_description(RUST_SOURCE, "src/user.rs", "rs");
370
371        assert_eq!(
372            desc.module_doc.as_deref(),
373            Some("User model and lookup helpers.")
374        );
375    }
376}