use std::fmt::Write;
use crate::error::DbError;
use crate::model::DepType;
use crate::port::ExportStore;
const CHARS_PER_TOKEN: usize = 4;
pub fn export_markdown(
db: &dyn ExportStore,
file_pattern: Option<&str>,
token_budget: Option<i64>,
) -> Result<String, DbError> {
let files = db.list_files(file_pattern)?;
let (file_count, symbol_count, dep_count) = db.stats()?;
let max_chars = token_budget.map(|t| t as usize * CHARS_PER_TOKEN);
let mut out = String::new();
writeln!(out, "# Code Structure Index")?;
writeln!(out)?;
writeln!(
out,
"**Files:** {} | **Symbols:** {} | **Dependencies:** {}",
file_count, symbol_count, dep_count
)?;
writeln!(out)?;
writeln!(out, "---")?;
writeln!(out)?;
for file in &files {
if let Some(max) = max_chars {
if out.len() >= max {
writeln!(out)?;
writeln!(out, "---")?;
writeln!(
out,
"*Output truncated at ~{} tokens.*",
out.len() / CHARS_PER_TOKEN
)?;
break;
}
}
writeln!(out, "## `{}`", file.path)?;
writeln!(out)?;
let symbols = db.get_symbols_for_file(file.id)?;
if symbols.is_empty() {
writeln!(out, "*No symbols extracted.*")?;
writeln!(out)?;
continue;
}
for sym in &symbols {
if let Some(sig) = &sym.signature {
writeln!(
out,
"### {} `{}` (line {}-{})",
sym.kind, sym.name, sym.start_line, sym.end_line
)?;
writeln!(out)?;
writeln!(out, "```{}", file.language)?;
writeln!(out, "{}", sig)?;
writeln!(out, "```")?;
} else {
writeln!(
out,
"### {} `{}` (line {}-{})",
sym.kind, sym.name, sym.start_line, sym.end_line
)?;
}
writeln!(out)?;
if let Some(summary) = &sym.summary {
writeln!(out, "> {}", summary)?;
writeln!(out)?;
}
let children = db.get_children(sym.id)?;
if !children.is_empty() {
writeln!(out, "**Members:**")?;
writeln!(out)?;
for child in &children {
let sig_str = child.signature.as_deref().unwrap_or(&child.name);
writeln!(
out,
"- `{}` {} (line {})",
sig_str, child.kind, child.start_line
)?;
}
writeln!(out)?;
}
}
let deps = db.get_deps_for_file(file.id)?;
let imports: Vec<_> = deps
.iter()
.filter(|d| d.dep_type == DepType::Imports)
.collect();
if !imports.is_empty() {
writeln!(out, "**Imports:**")?;
writeln!(out)?;
for dep in imports {
let target = dep.raw_import.as_deref().unwrap_or("(unknown)");
writeln!(out, "- `{}`", target)?;
}
writeln!(out)?;
}
writeln!(out, "---")?;
writeln!(out)?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Dependency, File, ParseStatus, Symbol, SymbolKind};
use crate::DbError;
struct MockExportStore {
files: Vec<File>,
symbols: std::collections::HashMap<i64, Vec<Symbol>>,
children: std::collections::HashMap<i64, Vec<Symbol>>,
deps: std::collections::HashMap<i64, Vec<Dependency>>,
stats: (i64, i64, i64),
}
impl MockExportStore {
fn empty() -> Self {
Self {
files: vec![],
symbols: std::collections::HashMap::new(),
children: std::collections::HashMap::new(),
deps: std::collections::HashMap::new(),
stats: (0, 0, 0),
}
}
}
impl crate::SymbolLookup for MockExportStore {
fn get_file_id(&self, _path: &str) -> Result<Option<i64>, DbError> {
Ok(None)
}
fn get_symbol_by_id(&self, _id: i64) -> Result<Option<Symbol>, DbError> {
Ok(None)
}
fn search_symbols_by_name(
&self,
_name: &str,
_limit: i64,
) -> Result<Vec<crate::SymbolResult>, DbError> {
Ok(vec![])
}
fn get_symbols_for_file(&self, file_id: i64) -> Result<Vec<Symbol>, DbError> {
Ok(self.symbols.get(&file_id).cloned().unwrap_or_default())
}
fn get_children(&self, symbol_id: i64) -> Result<Vec<Symbol>, DbError> {
Ok(self.children.get(&symbol_id).cloned().unwrap_or_default())
}
fn impact_analysis(
&self,
_id: i64,
_depth: i64,
) -> Result<Vec<crate::ImpactEntry>, DbError> {
Ok(vec![])
}
fn stats(&self) -> Result<(i64, i64, i64), DbError> {
Ok(self.stats)
}
}
impl ExportStore for MockExportStore {
fn list_files(&self, _pattern: Option<&str>) -> Result<Vec<File>, DbError> {
Ok(self.files.clone())
}
fn get_deps_for_file(&self, file_id: i64) -> Result<Vec<Dependency>, DbError> {
Ok(self.deps.get(&file_id).cloned().unwrap_or_default())
}
}
fn make_file(id: i64, path: &str, lang: &str) -> File {
File {
id,
scan_id: 1,
path: path.to_string(),
language: lang.to_string(),
size_bytes: 100,
content_hash: "abc".to_string(),
last_modified: 0,
parse_status: ParseStatus::Ok,
}
}
fn make_sym(
id: i64,
file_id: i64,
name: &str,
kind: SymbolKind,
sig: Option<&str>,
summary: Option<&str>,
) -> Symbol {
Symbol {
id,
file_id,
parent_id: None,
name: name.to_string(),
kind,
signature: sig.map(|s| s.to_string()),
summary: summary.map(|s| s.to_string()),
start_line: 1,
end_line: 10,
start_byte: 0,
end_byte: 100,
}
}
#[test]
fn test_export_empty_db() {
let db = MockExportStore::empty();
let result = export_markdown(&db, None, None).unwrap();
assert!(result.contains("# Code Structure Index"));
assert!(result.contains("**Files:** 0"));
}
#[test]
fn test_export_file_with_no_symbols() {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, "src/empty.rs", "rust")];
db.stats = (1, 0, 0);
let result = export_markdown(&db, None, None).unwrap();
assert!(result.contains("`src/empty.rs`"));
assert!(result.contains("*No symbols extracted.*"));
}
#[test]
fn test_export_symbol_with_signature() {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, "src/lib.rs", "rust")];
db.symbols.insert(
1,
vec![make_sym(
10,
1,
"my_fn",
SymbolKind::Function,
Some("fn my_fn(x: i32) -> bool"),
None,
)],
);
db.stats = (1, 1, 0);
let result = export_markdown(&db, None, None).unwrap();
assert!(result.contains("```rust"));
assert!(result.contains("fn my_fn(x: i32) -> bool"));
}
#[test]
fn test_export_symbol_without_signature() {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, "src/lib.rs", "rust")];
db.symbols.insert(
1,
vec![make_sym(10, 1, "MY_CONST", SymbolKind::Const, None, None)],
);
db.stats = (1, 1, 0);
let result = export_markdown(&db, None, None).unwrap();
assert!(result.contains("const `MY_CONST`"));
assert!(!result.contains("```"));
}
#[test]
fn test_export_symbol_with_summary() {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, "src/lib.rs", "rust")];
db.symbols.insert(
1,
vec![make_sym(
10,
1,
"foo",
SymbolKind::Function,
None,
Some("Does something important"),
)],
);
db.stats = (1, 1, 0);
let result = export_markdown(&db, None, None).unwrap();
assert!(result.contains("> Does something important"));
}
#[test]
fn test_export_symbol_with_children() {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, "src/lib.rs", "rust")];
db.symbols.insert(
1,
vec![make_sym(10, 1, "MyStruct", SymbolKind::Struct, None, None)],
);
db.children.insert(
10,
vec![Symbol {
id: 11,
file_id: 1,
parent_id: Some(10),
name: "new".to_string(),
kind: SymbolKind::Method,
signature: Some("fn new() -> Self".to_string()),
summary: None,
start_line: 5,
end_line: 8,
start_byte: 50,
end_byte: 80,
}],
);
db.stats = (1, 2, 0);
let result = export_markdown(&db, None, None).unwrap();
assert!(result.contains("**Members:**"));
assert!(result.contains("`fn new() -> Self`"));
}
#[test]
fn test_export_file_with_imports() {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, "src/lib.rs", "rust")];
db.symbols.insert(
1,
vec![make_sym(10, 1, "foo", SymbolKind::Function, None, None)],
);
db.deps.insert(
1,
vec![Dependency {
id: 1,
source_file_id: 1,
source_symbol_id: None,
target_file_id: Some(2),
target_symbol_id: None,
dep_type: DepType::Imports,
level: crate::model::DepLevel::File,
raw_import: Some("std::io".to_string()),
}],
);
db.stats = (1, 1, 1);
let result = export_markdown(&db, None, None).unwrap();
assert!(result.contains("**Imports:**"));
assert!(result.contains("`std::io`"));
}
#[test]
fn test_export_token_budget_truncation() {
let mut db = MockExportStore::empty();
let mut files = vec![];
for i in 0..20 {
let f = make_file(i, &format!("src/file_{}.rs", i), "rust");
files.push(f);
db.symbols.insert(
i,
vec![make_sym(
i * 10,
i,
&format!("fn_{}", i),
SymbolKind::Function,
Some(&format!("fn fn_{}() -> i32", i)),
None,
)],
);
}
db.files = files;
db.stats = (20, 20, 0);
let result = export_markdown(&db, None, Some(50)).unwrap();
assert!(result.contains("*Output truncated at"));
}
#[test]
fn test_export_fence_lang_uses_file_language() {
for (path, lang, expected_fence) in [
("src/lib.rs", "rust", "```rust"),
("src/app.ts", "typescript", "```typescript"),
("cmd/main.go", "go", "```go"),
("src/main.py", "python", "```python"),
] {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, path, lang)];
db.symbols.insert(
1,
vec![make_sym(
10,
1,
"f",
SymbolKind::Function,
Some("fn f()"),
None,
)],
);
db.stats = (1, 1, 0);
let result = export_markdown(&db, None, None).unwrap();
assert!(
result.contains(expected_fence),
"language '{}' should produce fence '{}', got:\n{}",
lang,
expected_fence,
result
);
}
}
#[test]
fn test_export_import_without_raw_import() {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, "src/lib.rs", "rust")];
db.symbols.insert(
1,
vec![make_sym(10, 1, "foo", SymbolKind::Function, None, None)],
);
db.deps.insert(
1,
vec![Dependency {
id: 1,
source_file_id: 1,
source_symbol_id: None,
target_file_id: Some(2),
target_symbol_id: None,
dep_type: DepType::Imports,
level: crate::model::DepLevel::File,
raw_import: None,
}],
);
db.stats = (1, 1, 1);
let result = export_markdown(&db, None, None).unwrap();
assert!(result.contains("`(unknown)`"));
}
#[test]
fn test_export_non_import_deps_excluded() {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, "src/lib.rs", "rust")];
db.symbols.insert(
1,
vec![make_sym(10, 1, "foo", SymbolKind::Function, None, None)],
);
db.deps.insert(
1,
vec![Dependency {
id: 1,
source_file_id: 1,
source_symbol_id: None,
target_file_id: Some(2),
target_symbol_id: None,
dep_type: DepType::Calls,
level: crate::model::DepLevel::Symbol,
raw_import: None,
}],
);
db.stats = (1, 1, 1);
let result = export_markdown(&db, None, None).unwrap();
assert!(!result.contains("**Imports:**"));
}
#[test]
fn test_export_child_without_signature_uses_name() {
let mut db = MockExportStore::empty();
db.files = vec![make_file(1, "src/lib.rs", "rust")];
db.symbols.insert(
1,
vec![make_sym(10, 1, "MyStruct", SymbolKind::Struct, None, None)],
);
db.children.insert(
10,
vec![Symbol {
id: 11,
file_id: 1,
parent_id: Some(10),
name: "field_x".to_string(),
kind: SymbolKind::Variable,
signature: None,
summary: None,
start_line: 3,
end_line: 3,
start_byte: 20,
end_byte: 40,
}],
);
db.stats = (1, 2, 0);
let result = export_markdown(&db, None, None).unwrap();
assert!(result.contains("`field_x`"));
}
}