Skip to main content

astmap_core/
export.rs

1// Markdown export with optional file pattern filter and token budget.
2// Approximate token counting: 1 token ≈ 4 characters.
3
4use 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    // Header
24    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        // Budget check
37        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        // Top-level symbols
54        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            // Symbol header
63            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            // Summary (if available)
83            if let Some(summary) = &sym.summary {
84                writeln!(out, "> {}", summary)?;
85                writeln!(out)?;
86            }
87
88            // Children (methods, etc.)
89            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        // File-level dependencies
106        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    // --- Mock ExportStore ---
135
136    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        // Create many files to exceed a tiny budget
373        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        // Very small budget: 50 tokens = 200 chars
393        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        // Verify that the fence language comes directly from file.language,
400        // so adding a new language requires no changes to core.
401        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}