1use std::fmt::Write as _;
7
8use crate::core::signatures::{Signature, extract_signatures};
9use crate::core::tokens::count_tokens;
10
11#[derive(Debug, Clone)]
13pub struct StructuralDescription {
14 pub path: String,
16 pub module_doc: Option<String>,
18 pub types: Vec<TypeFingerprint>,
20 pub functions: Vec<FunctionFingerprint>,
22 pub import_count: usize,
24 pub total_lines: usize,
26 pub description_tokens: usize,
28 pub original_tokens: usize,
30}
31
32#[derive(Debug, Clone)]
34pub struct TypeFingerprint {
35 pub name: String,
37 pub kind: &'static str,
39 pub field_count: usize,
41 pub is_exported: bool,
43}
44
45#[derive(Debug, Clone)]
47pub struct FunctionFingerprint {
48 pub name: String,
50 pub params: String,
52 pub return_type: String,
54 pub is_async: bool,
56 pub is_exported: bool,
58 pub complexity_hint: &'static str,
60}
61
62pub(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 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 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}