use std::collections::HashMap;
use std::path::{Path, PathBuf};
use petgraph::visit::EdgeRef;
use crate::graph::{
CodeGraph,
edge::EdgeKind,
node::{FileKind, GraphNode, SymbolVisibility},
};
use crate::query::find::kind_to_str;
#[derive(Debug, PartialEq, serde::Serialize)]
pub enum StructureNode {
Dir {
name: String,
children: Vec<StructureNode>,
},
SourceFile {
name: String,
symbols: Vec<StructureSymbol>,
},
NonParsedFile {
name: String,
kind_tag: String, },
Truncated {
count: usize, },
}
#[derive(Debug, PartialEq, serde::Serialize)]
pub struct StructureSymbol {
pub name: String,
pub kind: String, pub visibility: String, }
fn file_kind_tag(kind: &FileKind) -> &'static str {
match kind {
FileKind::Doc => "doc",
FileKind::Config => "config",
FileKind::Ci => "ci",
FileKind::Asset => "asset",
FileKind::Other => "other",
FileKind::Source => "source", }
}
fn visibility_label(vis: &SymbolVisibility) -> &'static str {
match vis {
SymbolVisibility::Pub => "pub",
SymbolVisibility::PubCrate => "pub(crate)",
SymbolVisibility::Private => "private",
}
}
fn collect_symbols(
graph: &CodeGraph,
file_idx: petgraph::stable_graph::NodeIndex,
) -> Vec<StructureSymbol> {
let mut symbols: Vec<StructureSymbol> = graph
.graph
.edges(file_idx)
.filter_map(|edge_ref| {
if let EdgeKind::Contains = edge_ref.weight()
&& let GraphNode::Symbol(ref sym) = graph.graph[edge_ref.target()]
{
return Some(StructureSymbol {
name: sym.name.clone(),
kind: kind_to_str(&sym.kind).to_string(),
visibility: visibility_label(&sym.visibility).to_string(),
});
}
None
})
.collect();
symbols.sort_by(|a, b| a.name.cmp(&b.name));
symbols
}
fn build_tree(graph: &CodeGraph, paths: &[(PathBuf, PathBuf)], depth: usize) -> Vec<StructureNode> {
if paths.is_empty() {
return vec![];
}
let mut dirs: HashMap<String, Vec<(PathBuf, PathBuf)>> = HashMap::new();
let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
for (rel, abs) in paths {
let mut components = rel.components();
let first = match components.next() {
Some(c) => c.as_os_str().to_string_lossy().into_owned(),
None => continue,
};
let rest: PathBuf = components.collect();
if rest.as_os_str().is_empty() {
files.push((rel.clone(), abs.clone()));
} else {
dirs.entry(first).or_default().push((rest, abs.clone()));
}
}
let mut dir_names: Vec<String> = dirs.keys().cloned().collect();
dir_names.sort();
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut nodes: Vec<StructureNode> = Vec::new();
if depth == 0 {
let total = dir_names.len() + files.len();
if total > 0 {
nodes.push(StructureNode::Truncated { count: total });
}
return nodes;
}
for dir_name in dir_names {
let children_paths = dirs.remove(&dir_name).unwrap_or_default();
let children = build_tree(graph, &children_paths, depth - 1);
nodes.push(StructureNode::Dir {
name: dir_name,
children,
});
}
for (_, abs) in &files {
let file_idx = match graph.file_index.get(abs) {
Some(&idx) => idx,
None => continue,
};
let file_info = match &graph.graph[file_idx] {
GraphNode::File(fi) => fi.clone(),
_ => continue,
};
let file_name = abs
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
match file_info.kind {
FileKind::Source => {
let symbols = collect_symbols(graph, file_idx);
nodes.push(StructureNode::SourceFile {
name: file_name,
symbols,
});
}
other => {
let kind_tag = file_kind_tag(&other).to_string();
nodes.push(StructureNode::NonParsedFile {
name: file_name,
kind_tag,
});
}
}
}
nodes
}
pub fn file_structure(
graph: &CodeGraph,
root: &Path,
path: Option<&Path>,
depth: usize,
) -> Vec<StructureNode> {
let base_dir: PathBuf = match path {
Some(p) => {
if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
}
}
None => root.to_path_buf(),
};
let mut paths: Vec<(PathBuf, PathBuf)> = graph
.file_index
.keys()
.filter_map(|abs| {
let rel = abs.strip_prefix(&base_dir).ok()?;
if rel.as_os_str().is_empty() {
return None;
}
Some((rel.to_path_buf(), abs.clone()))
})
.collect();
paths.sort_by(|a, b| a.0.cmp(&b.0));
build_tree(graph, &paths, depth)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::graph::{
CodeGraph,
node::{FileKind, SymbolInfo, SymbolKind, SymbolVisibility},
};
use crate::query::output::format_structure_to_string;
fn make_symbol(name: &str, kind: SymbolKind, vis: SymbolVisibility) -> SymbolInfo {
SymbolInfo {
name: name.into(),
kind,
line: 1,
visibility: vis,
..Default::default()
}
}
#[test]
fn test_empty_graph() {
let graph = CodeGraph::new();
let root = PathBuf::from("/tmp/test_project");
let tree = file_structure(&graph, &root, None, 3);
assert!(tree.is_empty(), "Empty graph should produce an empty tree");
}
#[test]
fn test_single_source_file() {
let mut graph = CodeGraph::new();
let root = PathBuf::from("/tmp/test_project");
let file_idx = graph.add_file(root.join("src/main.rs"), "rust");
graph.add_symbol(
file_idx,
make_symbol("main", SymbolKind::Function, SymbolVisibility::Pub),
);
graph.add_symbol(
file_idx,
make_symbol("Config", SymbolKind::Struct, SymbolVisibility::Pub),
);
let tree = file_structure(&graph, &root, None, 3);
assert_eq!(tree.len(), 1);
let dir = match &tree[0] {
StructureNode::Dir { name, children } => {
assert_eq!(name, "src");
children
}
other => panic!("Expected Dir, got {:?}", other),
};
assert_eq!(dir.len(), 1);
match &dir[0] {
StructureNode::SourceFile { name, symbols } => {
assert_eq!(name, "main.rs");
assert_eq!(symbols.len(), 2, "Should have 2 symbols");
}
other => panic!("Expected SourceFile, got {:?}", other),
}
}
#[test]
fn test_non_parsed_file() {
let mut graph = CodeGraph::new();
let root = PathBuf::from("/tmp/test_project");
graph.add_non_parsed_file(root.join("README.md"), FileKind::Doc);
let tree = file_structure(&graph, &root, None, 3);
assert_eq!(tree.len(), 1);
match &tree[0] {
StructureNode::NonParsedFile { name, kind_tag } => {
assert_eq!(name, "README.md");
assert_eq!(kind_tag, "doc");
}
other => panic!("Expected NonParsedFile, got {:?}", other),
}
}
#[test]
fn test_depth_limit() {
let mut graph = CodeGraph::new();
let root = PathBuf::from("/tmp/test_project");
graph.add_file(root.join("src/a/b/file.rs"), "rust");
let tree = file_structure(&graph, &root, None, 1);
assert_eq!(tree.len(), 1);
let children = match &tree[0] {
StructureNode::Dir { name, children } => {
assert_eq!(name, "src");
children
}
other => panic!("Expected Dir(src), got {:?}", other),
};
assert_eq!(children.len(), 1);
match &children[0] {
StructureNode::Truncated { count } => {
assert_eq!(*count, 1, "Should truncate 1 item (the 'a' directory)");
}
other => panic!("Expected Truncated, got {:?}", other),
}
}
#[test]
fn test_symbol_visibility() {
let mut graph = CodeGraph::new();
let root = PathBuf::from("/tmp/test_project");
let file_idx = graph.add_file(root.join("src/lib.rs"), "rust");
graph.add_symbol(
file_idx,
make_symbol("pub_fn", SymbolKind::Function, SymbolVisibility::Pub),
);
graph.add_symbol(
file_idx,
make_symbol("crate_fn", SymbolKind::Function, SymbolVisibility::PubCrate),
);
graph.add_symbol(
file_idx,
make_symbol("priv_fn", SymbolKind::Function, SymbolVisibility::Private),
);
let tree = file_structure(&graph, &root, None, 3);
let symbols = match &tree[0] {
StructureNode::Dir { children, .. } => match &children[0] {
StructureNode::SourceFile { symbols, .. } => symbols,
other => panic!("Expected SourceFile, got {:?}", other),
},
other => panic!("Expected Dir, got {:?}", other),
};
assert_eq!(symbols.len(), 3);
let pub_sym = symbols
.iter()
.find(|s| s.name == "pub_fn")
.expect("pub_fn not found");
assert_eq!(pub_sym.visibility, "pub");
let crate_sym = symbols
.iter()
.find(|s| s.name == "crate_fn")
.expect("crate_fn not found");
assert_eq!(crate_sym.visibility, "pub(crate)");
let priv_sym = symbols
.iter()
.find(|s| s.name == "priv_fn")
.expect("priv_fn not found");
assert_eq!(priv_sym.visibility, "private");
}
#[test]
fn test_path_scoping() {
let mut graph = CodeGraph::new();
let root = PathBuf::from("/tmp/test_project");
graph.add_file(root.join("src/main.rs"), "rust");
graph.add_file(root.join("tests/test_main.rs"), "rust");
let tree = file_structure(&graph, &root, Some(Path::new("src")), 3);
assert_eq!(tree.len(), 1, "Should only have 1 item (main.rs)");
match &tree[0] {
StructureNode::SourceFile { name, .. } => {
assert_eq!(name, "main.rs");
}
other => panic!("Expected SourceFile(main.rs), got {:?}", other),
}
}
#[test]
fn test_format_structure_output() {
let tree = vec![
StructureNode::Dir {
name: "src".to_string(),
children: vec![StructureNode::SourceFile {
name: "main.rs".to_string(),
symbols: vec![StructureSymbol {
name: "main".to_string(),
kind: "function".to_string(),
visibility: "pub".to_string(),
}],
}],
},
StructureNode::NonParsedFile {
name: "README.md".to_string(),
kind_tag: "doc".to_string(),
},
StructureNode::NonParsedFile {
name: "Cargo.toml".to_string(),
kind_tag: "config".to_string(),
},
];
let root = PathBuf::from("/tmp/test_project");
let output = format_structure_to_string(&tree, &root);
assert!(output.contains("src/"), "Should contain src/");
assert!(output.contains("main.rs"), "Should contain main.rs");
assert!(
output.contains("pub main (function)"),
"Should contain symbol with visibility"
);
assert!(
output.contains("README.md [doc]"),
"Should contain README.md with kind tag"
);
assert!(
output.contains("Cargo.toml [config]"),
"Should contain Cargo.toml with kind tag"
);
}
}