1use std::fmt::Write;
5
6use crate::error::DbError;
7use crate::model::DepType;
8use crate::port::ExportStore;
9
10const CHARS_PER_TOKEN: usize = 4;
11
12pub fn export_markdown(
13 db: &dyn ExportStore,
14 file_pattern: Option<&str>,
15 token_budget: Option<i64>,
16) -> Result<String, DbError> {
17 let files = db.list_files(file_pattern)?;
18 let (file_count, symbol_count, dep_count) = db.stats()?;
19 let max_chars = token_budget.map(|t| t as usize * CHARS_PER_TOKEN);
20
21 let mut out = String::new();
22
23 writeln!(out, "# Code Structure Index")?;
25 writeln!(out)?;
26 writeln!(
27 out,
28 "**Files:** {} | **Symbols:** {} | **Dependencies:** {}",
29 file_count, symbol_count, dep_count
30 )?;
31 writeln!(out)?;
32 writeln!(out, "---")?;
33 writeln!(out)?;
34
35 for file in &files {
36 if let Some(max) = max_chars {
38 if out.len() >= max {
39 writeln!(out)?;
40 writeln!(out, "---")?;
41 writeln!(
42 out,
43 "*Output truncated at ~{} tokens.*",
44 out.len() / CHARS_PER_TOKEN
45 )?;
46 break;
47 }
48 }
49
50 writeln!(out, "## `{}`", file.path)?;
51 writeln!(out)?;
52
53 let symbols = db.get_symbols_for_file(file.id)?;
55 if symbols.is_empty() {
56 writeln!(out, "*No symbols extracted.*")?;
57 writeln!(out)?;
58 continue;
59 }
60
61 for sym in &symbols {
62 if let Some(sig) = &sym.signature {
64 writeln!(
65 out,
66 "### {} `{}` (line {}-{})",
67 sym.kind, sym.name, sym.start_line, sym.end_line
68 )?;
69 writeln!(out)?;
70 writeln!(out, "```{}", file.language)?;
71 writeln!(out, "{}", sig)?;
72 writeln!(out, "```")?;
73 } else {
74 writeln!(
75 out,
76 "### {} `{}` (line {}-{})",
77 sym.kind, sym.name, sym.start_line, sym.end_line
78 )?;
79 }
80 writeln!(out)?;
81
82 if let Some(summary) = &sym.summary {
84 writeln!(out, "> {}", summary)?;
85 writeln!(out)?;
86 }
87
88 let children = db.get_children(sym.id)?;
90 if !children.is_empty() {
91 writeln!(out, "**Members:**")?;
92 writeln!(out)?;
93 for child in &children {
94 let sig_str = child.signature.as_deref().unwrap_or(&child.name);
95 writeln!(
96 out,
97 "- `{}` {} (line {})",
98 sig_str, child.kind, child.start_line
99 )?;
100 }
101 writeln!(out)?;
102 }
103 }
104
105 let deps = db.get_deps_for_file(file.id)?;
107 let imports: Vec<_> = deps
108 .iter()
109 .filter(|d| d.dep_type == DepType::Imports)
110 .collect();
111 if !imports.is_empty() {
112 writeln!(out, "**Imports:**")?;
113 writeln!(out)?;
114 for dep in imports {
115 let target = dep.raw_import.as_deref().unwrap_or("(unknown)");
116 writeln!(out, "- `{}`", target)?;
117 }
118 writeln!(out)?;
119 }
120
121 writeln!(out, "---")?;
122 writeln!(out)?;
123 }
124
125 Ok(out)
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::model::{Dependency, File, ParseStatus, Symbol, SymbolKind};
132 use crate::DbError;
133
134 struct MockExportStore {
137 files: Vec<File>,
138 symbols: std::collections::HashMap<i64, Vec<Symbol>>,
139 children: std::collections::HashMap<i64, Vec<Symbol>>,
140 deps: std::collections::HashMap<i64, Vec<Dependency>>,
141 stats: (i64, i64, i64),
142 }
143
144 impl MockExportStore {
145 fn empty() -> Self {
146 Self {
147 files: vec![],
148 symbols: std::collections::HashMap::new(),
149 children: std::collections::HashMap::new(),
150 deps: std::collections::HashMap::new(),
151 stats: (0, 0, 0),
152 }
153 }
154 }
155
156 impl crate::SymbolLookup for MockExportStore {
157 fn get_file_id(&self, _path: &str) -> Result<Option<i64>, DbError> {
158 Ok(None)
159 }
160 fn get_symbol_by_id(&self, _id: i64) -> Result<Option<Symbol>, DbError> {
161 Ok(None)
162 }
163 fn search_symbols_by_name(
164 &self,
165 _name: &str,
166 _limit: i64,
167 ) -> Result<Vec<crate::SymbolResult>, DbError> {
168 Ok(vec![])
169 }
170 fn get_symbols_for_file(&self, file_id: i64) -> Result<Vec<Symbol>, DbError> {
171 Ok(self.symbols.get(&file_id).cloned().unwrap_or_default())
172 }
173 fn get_children(&self, symbol_id: i64) -> Result<Vec<Symbol>, DbError> {
174 Ok(self.children.get(&symbol_id).cloned().unwrap_or_default())
175 }
176 fn impact_analysis(
177 &self,
178 _id: i64,
179 _depth: i64,
180 ) -> Result<Vec<crate::ImpactEntry>, DbError> {
181 Ok(vec![])
182 }
183 fn stats(&self) -> Result<(i64, i64, i64), DbError> {
184 Ok(self.stats)
185 }
186 }
187
188 impl ExportStore for MockExportStore {
189 fn list_files(&self, _pattern: Option<&str>) -> Result<Vec<File>, DbError> {
190 Ok(self.files.clone())
191 }
192 fn get_deps_for_file(&self, file_id: i64) -> Result<Vec<Dependency>, DbError> {
193 Ok(self.deps.get(&file_id).cloned().unwrap_or_default())
194 }
195 }
196
197 fn make_file(id: i64, path: &str, lang: &str) -> File {
198 File {
199 id,
200 scan_id: 1,
201 path: path.to_string(),
202 language: lang.to_string(),
203 size_bytes: 100,
204 content_hash: "abc".to_string(),
205 last_modified: 0,
206 parse_status: ParseStatus::Ok,
207 }
208 }
209
210 fn make_sym(
211 id: i64,
212 file_id: i64,
213 name: &str,
214 kind: SymbolKind,
215 sig: Option<&str>,
216 summary: Option<&str>,
217 ) -> Symbol {
218 Symbol {
219 id,
220 file_id,
221 parent_id: None,
222 name: name.to_string(),
223 kind,
224 signature: sig.map(|s| s.to_string()),
225 summary: summary.map(|s| s.to_string()),
226 start_line: 1,
227 end_line: 10,
228 start_byte: 0,
229 end_byte: 100,
230 }
231 }
232
233 #[test]
234 fn test_export_empty_db() {
235 let db = MockExportStore::empty();
236 let result = export_markdown(&db, None, None).unwrap();
237 assert!(result.contains("# Code Structure Index"));
238 assert!(result.contains("**Files:** 0"));
239 }
240
241 #[test]
242 fn test_export_file_with_no_symbols() {
243 let mut db = MockExportStore::empty();
244 db.files = vec![make_file(1, "src/empty.rs", "rust")];
245 db.stats = (1, 0, 0);
246
247 let result = export_markdown(&db, None, None).unwrap();
248 assert!(result.contains("`src/empty.rs`"));
249 assert!(result.contains("*No symbols extracted.*"));
250 }
251
252 #[test]
253 fn test_export_symbol_with_signature() {
254 let mut db = MockExportStore::empty();
255 db.files = vec![make_file(1, "src/lib.rs", "rust")];
256 db.symbols.insert(
257 1,
258 vec![make_sym(
259 10,
260 1,
261 "my_fn",
262 SymbolKind::Function,
263 Some("fn my_fn(x: i32) -> bool"),
264 None,
265 )],
266 );
267 db.stats = (1, 1, 0);
268
269 let result = export_markdown(&db, None, None).unwrap();
270 assert!(result.contains("```rust"));
271 assert!(result.contains("fn my_fn(x: i32) -> bool"));
272 }
273
274 #[test]
275 fn test_export_symbol_without_signature() {
276 let mut db = MockExportStore::empty();
277 db.files = vec![make_file(1, "src/lib.rs", "rust")];
278 db.symbols.insert(
279 1,
280 vec![make_sym(10, 1, "MY_CONST", SymbolKind::Const, None, None)],
281 );
282 db.stats = (1, 1, 0);
283
284 let result = export_markdown(&db, None, None).unwrap();
285 assert!(result.contains("const `MY_CONST`"));
286 assert!(!result.contains("```"));
287 }
288
289 #[test]
290 fn test_export_symbol_with_summary() {
291 let mut db = MockExportStore::empty();
292 db.files = vec![make_file(1, "src/lib.rs", "rust")];
293 db.symbols.insert(
294 1,
295 vec![make_sym(
296 10,
297 1,
298 "foo",
299 SymbolKind::Function,
300 None,
301 Some("Does something important"),
302 )],
303 );
304 db.stats = (1, 1, 0);
305
306 let result = export_markdown(&db, None, None).unwrap();
307 assert!(result.contains("> Does something important"));
308 }
309
310 #[test]
311 fn test_export_symbol_with_children() {
312 let mut db = MockExportStore::empty();
313 db.files = vec![make_file(1, "src/lib.rs", "rust")];
314 db.symbols.insert(
315 1,
316 vec![make_sym(10, 1, "MyStruct", SymbolKind::Struct, None, None)],
317 );
318 db.children.insert(
319 10,
320 vec![Symbol {
321 id: 11,
322 file_id: 1,
323 parent_id: Some(10),
324 name: "new".to_string(),
325 kind: SymbolKind::Method,
326 signature: Some("fn new() -> Self".to_string()),
327 summary: None,
328 start_line: 5,
329 end_line: 8,
330 start_byte: 50,
331 end_byte: 80,
332 }],
333 );
334 db.stats = (1, 2, 0);
335
336 let result = export_markdown(&db, None, None).unwrap();
337 assert!(result.contains("**Members:**"));
338 assert!(result.contains("`fn new() -> Self`"));
339 }
340
341 #[test]
342 fn test_export_file_with_imports() {
343 let mut db = MockExportStore::empty();
344 db.files = vec![make_file(1, "src/lib.rs", "rust")];
345 db.symbols.insert(
346 1,
347 vec![make_sym(10, 1, "foo", SymbolKind::Function, None, None)],
348 );
349 db.deps.insert(
350 1,
351 vec![Dependency {
352 id: 1,
353 source_file_id: 1,
354 source_symbol_id: None,
355 target_file_id: Some(2),
356 target_symbol_id: None,
357 dep_type: DepType::Imports,
358 level: crate::model::DepLevel::File,
359 raw_import: Some("std::io".to_string()),
360 }],
361 );
362 db.stats = (1, 1, 1);
363
364 let result = export_markdown(&db, None, None).unwrap();
365 assert!(result.contains("**Imports:**"));
366 assert!(result.contains("`std::io`"));
367 }
368
369 #[test]
370 fn test_export_token_budget_truncation() {
371 let mut db = MockExportStore::empty();
372 let mut files = vec![];
374 for i in 0..20 {
375 let f = make_file(i, &format!("src/file_{}.rs", i), "rust");
376 files.push(f);
377 db.symbols.insert(
378 i,
379 vec![make_sym(
380 i * 10,
381 i,
382 &format!("fn_{}", i),
383 SymbolKind::Function,
384 Some(&format!("fn fn_{}() -> i32", i)),
385 None,
386 )],
387 );
388 }
389 db.files = files;
390 db.stats = (20, 20, 0);
391
392 let result = export_markdown(&db, None, Some(50)).unwrap();
394 assert!(result.contains("*Output truncated at"));
395 }
396
397 #[test]
398 fn test_export_fence_lang_uses_file_language() {
399 for (path, lang, expected_fence) in [
402 ("src/lib.rs", "rust", "```rust"),
403 ("src/app.ts", "typescript", "```typescript"),
404 ("cmd/main.go", "go", "```go"),
405 ("src/main.py", "python", "```python"),
406 ] {
407 let mut db = MockExportStore::empty();
408 db.files = vec![make_file(1, path, lang)];
409 db.symbols.insert(
410 1,
411 vec![make_sym(
412 10,
413 1,
414 "f",
415 SymbolKind::Function,
416 Some("fn f()"),
417 None,
418 )],
419 );
420 db.stats = (1, 1, 0);
421
422 let result = export_markdown(&db, None, None).unwrap();
423 assert!(
424 result.contains(expected_fence),
425 "language '{}' should produce fence '{}', got:\n{}",
426 lang,
427 expected_fence,
428 result
429 );
430 }
431 }
432
433 #[test]
434 fn test_export_import_without_raw_import() {
435 let mut db = MockExportStore::empty();
436 db.files = vec![make_file(1, "src/lib.rs", "rust")];
437 db.symbols.insert(
438 1,
439 vec![make_sym(10, 1, "foo", SymbolKind::Function, None, None)],
440 );
441 db.deps.insert(
442 1,
443 vec![Dependency {
444 id: 1,
445 source_file_id: 1,
446 source_symbol_id: None,
447 target_file_id: Some(2),
448 target_symbol_id: None,
449 dep_type: DepType::Imports,
450 level: crate::model::DepLevel::File,
451 raw_import: None,
452 }],
453 );
454 db.stats = (1, 1, 1);
455
456 let result = export_markdown(&db, None, None).unwrap();
457 assert!(result.contains("`(unknown)`"));
458 }
459
460 #[test]
461 fn test_export_non_import_deps_excluded() {
462 let mut db = MockExportStore::empty();
463 db.files = vec![make_file(1, "src/lib.rs", "rust")];
464 db.symbols.insert(
465 1,
466 vec![make_sym(10, 1, "foo", SymbolKind::Function, None, None)],
467 );
468 db.deps.insert(
469 1,
470 vec![Dependency {
471 id: 1,
472 source_file_id: 1,
473 source_symbol_id: None,
474 target_file_id: Some(2),
475 target_symbol_id: None,
476 dep_type: DepType::Calls,
477 level: crate::model::DepLevel::Symbol,
478 raw_import: None,
479 }],
480 );
481 db.stats = (1, 1, 1);
482
483 let result = export_markdown(&db, None, None).unwrap();
484 assert!(!result.contains("**Imports:**"));
485 }
486
487 #[test]
488 fn test_export_child_without_signature_uses_name() {
489 let mut db = MockExportStore::empty();
490 db.files = vec![make_file(1, "src/lib.rs", "rust")];
491 db.symbols.insert(
492 1,
493 vec![make_sym(10, 1, "MyStruct", SymbolKind::Struct, None, None)],
494 );
495 db.children.insert(
496 10,
497 vec![Symbol {
498 id: 11,
499 file_id: 1,
500 parent_id: Some(10),
501 name: "field_x".to_string(),
502 kind: SymbolKind::Variable,
503 signature: None,
504 summary: None,
505 start_line: 3,
506 end_line: 3,
507 start_byte: 20,
508 end_byte: 40,
509 }],
510 );
511 db.stats = (1, 2, 0);
512
513 let result = export_markdown(&db, None, None).unwrap();
514 assert!(result.contains("`field_x`"));
515 }
516}