use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use crate::snapshot::Snapshot;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct BarrelAnalysis {
pub missing_barrels: Vec<MissingBarrel>,
pub deep_chains: Vec<ReexportChain>,
pub inconsistent_paths: Vec<InconsistentImport>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MissingBarrel {
pub directory: String,
pub file_count: usize,
pub external_import_count: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ReexportChain {
pub symbol: String,
pub chain: Vec<String>,
pub depth: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InconsistentImport {
pub symbol: String,
pub canonical_path: String,
pub alternative_paths: Vec<(String, usize)>,
}
pub fn analyze_barrel_chaos(snapshot: &Snapshot) -> BarrelAnalysis {
if is_pure_rust_project(snapshot) {
return BarrelAnalysis {
missing_barrels: Vec::new(),
deep_chains: Vec::new(),
inconsistent_paths: Vec::new(),
};
}
let missing_barrels = detect_missing_barrels(snapshot);
let deep_chains = detect_deep_chains(snapshot);
let inconsistent_paths = detect_inconsistent_paths(snapshot);
BarrelAnalysis {
missing_barrels,
deep_chains,
inconsistent_paths,
}
}
fn is_pure_rust_project(snapshot: &Snapshot) -> bool {
let has_ts_js = snapshot.files.iter().any(|file| {
let path = file.path.to_lowercase();
path.ends_with(".ts")
|| path.ends_with(".tsx")
|| path.ends_with(".js")
|| path.ends_with(".jsx")
|| path.ends_with(".mjs")
|| path.ends_with(".cjs")
});
!has_ts_js
}
fn detect_missing_barrels(snapshot: &Snapshot) -> Vec<MissingBarrel> {
let mut dir_files: HashMap<String, Vec<String>> = HashMap::new();
for file in &snapshot.files {
if let Some(dir) = get_directory(&file.path) {
dir_files.entry(dir).or_default().push(file.path.clone());
}
}
let mut importers: HashMap<String, HashSet<String>> = HashMap::new();
for edge in &snapshot.edges {
importers
.entry(edge.to.clone())
.or_default()
.insert(edge.from.clone());
}
let mut missing = Vec::new();
for (dir, files) in &dir_files {
if has_index_file(files) {
continue;
}
if files.len() <= 1 {
continue;
}
let mut external_import_count = 0;
for file in files {
if let Some(file_importers) = importers.get(file) {
for importer in file_importers {
if let Some(importer_dir) = get_directory(importer)
&& importer_dir != *dir
{
external_import_count += 1;
}
}
}
}
const EXTERNAL_IMPORT_THRESHOLD: usize = 3;
if external_import_count >= EXTERNAL_IMPORT_THRESHOLD {
missing.push(MissingBarrel {
directory: dir.clone(),
file_count: files.len(),
external_import_count,
});
}
}
missing.sort_by(|a, b| b.external_import_count.cmp(&a.external_import_count));
missing
}
fn detect_deep_chains(snapshot: &Snapshot) -> Vec<ReexportChain> {
let mut reexport_graph: HashMap<String, Vec<String>> = HashMap::new();
for edge in &snapshot.edges {
if is_barrel_file(&edge.from) {
reexport_graph
.entry(edge.from.clone())
.or_default()
.push(edge.to.clone());
}
}
let _symbol_origins: HashMap<&str, &str> = snapshot
.export_index
.iter()
.flat_map(|(symbol, files)| {
files
.iter()
.map(move |file| (symbol.as_str(), file.as_str()))
})
.collect();
let mut deep_chains = Vec::new();
for (barrel, targets) in &reexport_graph {
for target in targets {
let chain = trace_reexport_chain(barrel, target, &reexport_graph);
if chain.len() > 2 {
if let Some(file_analysis) = snapshot.files.iter().find(|f| &f.path == target) {
for export in &file_analysis.exports {
deep_chains.push(ReexportChain {
symbol: export.name.clone(),
chain: chain.clone(),
depth: chain.len() - 1,
});
}
}
}
}
}
deep_chains.sort_by(|a, b| b.depth.cmp(&a.depth));
deep_chains
}
fn trace_reexport_chain(
start: &str,
target: &str,
reexport_graph: &HashMap<String, Vec<String>>,
) -> Vec<String> {
let mut chain = vec![start.to_string()];
let mut current = target;
let mut visited = HashSet::new();
while is_barrel_file(current) && !visited.contains(current) {
visited.insert(current.to_string());
chain.push(current.to_string());
if let Some(next_targets) = reexport_graph.get(current) {
if let Some(next) = next_targets.first() {
current = next;
} else {
break;
}
} else {
break;
}
}
if !is_barrel_file(current) && current != chain.last().unwrap() {
chain.push(current.to_string());
}
chain
}
fn detect_inconsistent_paths(snapshot: &Snapshot) -> Vec<InconsistentImport> {
let mut symbol_sources: HashMap<String, HashMap<String, usize>> = HashMap::new();
for file in &snapshot.files {
for import in &file.imports {
for symbol in &import.symbols {
let symbol_name = symbol.alias.as_ref().unwrap_or(&symbol.name);
symbol_sources
.entry(symbol_name.clone())
.or_default()
.entry(import.source.clone())
.and_modify(|count| *count += 1)
.or_insert(1);
}
}
}
let mut inconsistent = Vec::new();
for (symbol, sources) in symbol_sources {
if sources.len() <= 1 {
continue;
}
let mut sources_vec: Vec<_> = sources.into_iter().collect();
sources_vec.sort_by(|a, b| b.1.cmp(&a.1));
if let Some((canonical, _canonical_count)) = sources_vec.first() {
let alternative_paths: Vec<_> = sources_vec
.iter()
.skip(1)
.map(|(path, count)| (path.clone(), *count))
.collect();
if alternative_paths.iter().any(|(_, count)| *count > 1) {
inconsistent.push(InconsistentImport {
symbol: symbol.clone(),
canonical_path: canonical.clone(),
alternative_paths,
});
}
}
}
inconsistent.sort_by(|a, b| b.alternative_paths.len().cmp(&a.alternative_paths.len()));
inconsistent
}
fn get_directory(path: &str) -> Option<String> {
Path::new(path)
.parent()
.and_then(|p| p.to_str())
.map(|s| s.to_string())
}
fn has_index_file(files: &[String]) -> bool {
files.iter().any(|f| {
let filename = Path::new(f)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
filename.starts_with("index.")
})
}
fn is_barrel_file(path: &str) -> bool {
if let Some(filename) = Path::new(path).file_name().and_then(|n| n.to_str()) {
filename.starts_with("index.")
} else {
false
}
}
pub fn format_barrel_analysis(analysis: &BarrelAnalysis) -> String {
let mut output = String::new();
output.push_str("📦 BARREL CHAOS\n\n");
if !analysis.missing_barrels.is_empty() {
output.push_str(&format!(
" Missing index.ts ({} directories):\n",
analysis.missing_barrels.len()
));
for barrel in analysis.missing_barrels.iter().take(10) {
let suggestion = if barrel.file_count <= 2 {
"maybe inline?"
} else {
"create index.ts"
};
output.push_str(&format!(
" ├─ {:<30} {} files, {} external imports → {}\n",
format!("{}/", barrel.directory),
barrel.file_count,
barrel.external_import_count,
suggestion
));
}
if analysis.missing_barrels.len() > 10 {
output.push_str(&format!(
" └─ ... and {} more\n",
analysis.missing_barrels.len() - 10
));
}
output.push('\n');
}
if !analysis.deep_chains.is_empty() {
output.push_str(&format!(
" Deep Re-export Chains ({}):\n",
analysis.deep_chains.len()
));
let mut seen_chains = HashSet::new();
let mut displayed = 0;
for chain_data in &analysis.deep_chains {
let chain_key = chain_data.chain.join(" -> ");
if seen_chains.insert(chain_key.clone()) && displayed < 5 {
let warning = if chain_data.depth > 2 { " [!]" } else { "" };
output.push_str(&format!(
" |- {}: {} (depth: {}){}\n",
chain_data.symbol, chain_key, chain_data.depth, warning
));
displayed += 1;
}
}
if analysis.deep_chains.len() > displayed {
output.push_str(&format!(
" └─ ... and {} more\n",
analysis.deep_chains.len() - displayed
));
}
output.push('\n');
}
if !analysis.inconsistent_paths.is_empty() {
output.push_str(" Inconsistent Import Paths:\n");
for inconsistent in analysis.inconsistent_paths.iter().take(5) {
output.push_str(&format!(" ├─ {} imported via:\n", inconsistent.symbol));
output.push_str(&format!(
" │ ├─ {} ({} files) ← CANONICAL\n",
inconsistent.canonical_path,
"?"
));
for (path, count) in &inconsistent.alternative_paths {
output.push_str(&format!(" │ └─ {} ({} files) ← LEGACY\n", path, count));
}
}
if analysis.inconsistent_paths.len() > 5 {
output.push_str(&format!(
" └─ ... and {} more\n",
analysis.inconsistent_paths.len() - 5
));
}
}
if analysis.missing_barrels.is_empty()
&& analysis.deep_chains.is_empty()
&& analysis.inconsistent_paths.is_empty()
{
output.push_str(" [OK] No barrel chaos detected\n");
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_barrel_file() {
assert!(is_barrel_file("src/index.ts"));
assert!(is_barrel_file("src/components/index.js"));
assert!(is_barrel_file("index.tsx"));
assert!(!is_barrel_file("src/utils.ts"));
assert!(!is_barrel_file("src/component.tsx"));
}
#[test]
fn test_has_index_file() {
let files = vec![
"src/utils.ts".to_string(),
"src/index.ts".to_string(),
"src/types.ts".to_string(),
];
assert!(has_index_file(&files));
let no_index = vec!["src/utils.ts".to_string(), "src/types.ts".to_string()];
assert!(!has_index_file(&no_index));
}
#[test]
fn test_get_directory() {
assert_eq!(
get_directory("src/components/Button.tsx"),
Some("src/components".to_string())
);
assert_eq!(get_directory("src/index.ts"), Some("src".to_string()));
assert_eq!(get_directory("index.ts"), Some("".to_string()));
}
#[test]
fn test_is_pure_rust_project() {
use crate::snapshot::SnapshotMetadata;
use crate::types::FileAnalysis;
fn make_snapshot(file_paths: Vec<&str>) -> Snapshot {
Snapshot {
metadata: SnapshotMetadata {
..Default::default()
},
files: file_paths
.iter()
.map(|path| FileAnalysis {
path: path.to_string(),
..Default::default()
})
.collect(),
edges: Vec::new(),
export_index: std::collections::HashMap::new(),
command_bridges: Vec::new(),
event_bridges: Vec::new(),
barrels: Vec::new(),
}
}
let rust_snapshot = make_snapshot(vec!["src/main.rs", "src/lib.rs"]);
assert!(is_pure_rust_project(&rust_snapshot));
let ts_snapshot = make_snapshot(vec!["src/index.ts", "src/utils.ts"]);
assert!(!is_pure_rust_project(&ts_snapshot));
let js_snapshot = make_snapshot(vec!["src/index.js", "src/utils.jsx"]);
assert!(!is_pure_rust_project(&js_snapshot));
let mixed_snapshot = make_snapshot(vec!["src/main.rs", "src/index.tsx"]);
assert!(!is_pure_rust_project(&mixed_snapshot));
assert!(!is_pure_rust_project(&make_snapshot(vec!["app.mjs"])));
assert!(!is_pure_rust_project(&make_snapshot(vec!["config.cjs"])));
}
#[test]
fn test_analyze_barrel_chaos_skips_rust_projects() {
use crate::snapshot::SnapshotMetadata;
use crate::types::FileAnalysis;
let rust_snapshot = Snapshot {
metadata: SnapshotMetadata {
..Default::default()
},
files: vec![
FileAnalysis {
path: "src/main.rs".to_string(),
..Default::default()
},
FileAnalysis {
path: "src/lib.rs".to_string(),
..Default::default()
},
],
edges: Vec::new(),
export_index: std::collections::HashMap::new(),
command_bridges: Vec::new(),
event_bridges: Vec::new(),
barrels: Vec::new(),
};
let analysis = analyze_barrel_chaos(&rust_snapshot);
assert!(analysis.missing_barrels.is_empty());
assert!(analysis.deep_chains.is_empty());
assert!(analysis.inconsistent_paths.is_empty());
}
}