use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use crate::core::property_graph::CodeGraph;
use crate::core::tokens::count_tokens;
use serde_json::{json, Value};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OutputFormat {
Text,
Json,
}
fn parse_format(format: Option<&str>) -> Result<OutputFormat, String> {
let f = format.unwrap_or("text").trim().to_lowercase();
match f.as_str() {
"text" => Ok(OutputFormat::Text),
"json" => Ok(OutputFormat::Json),
_ => Err("Error: format must be text|json".to_string()),
}
}
pub fn handle(action: &str, path: Option<&str>, root: &str, format: Option<&str>) -> String {
let fmt = match parse_format(format) {
Ok(f) => f,
Err(e) => return e,
};
match action {
"overview" => handle_overview(root, fmt),
"clusters" => handle_clusters(root, fmt),
"layers" => handle_layers(root, fmt),
"cycles" => handle_cycles(root, fmt),
"entrypoints" => handle_entrypoints(root, fmt),
"module" => handle_module(path, root, fmt),
_ => "Unknown action. Use: overview, clusters, layers, cycles, entrypoints, module"
.to_string(),
}
}
fn open_graph(root: &str) -> Result<CodeGraph, String> {
CodeGraph::open(Path::new(root)).map_err(|e| format!("Failed to open graph: {e}"))
}
struct GraphData {
forward: HashMap<String, Vec<String>>,
reverse: HashMap<String, Vec<String>>,
all_files: HashSet<String>,
}
fn ensure_graph_built(root: &str) {
let Ok(graph) = CodeGraph::open(Path::new(root)) else {
return;
};
if graph.node_count().unwrap_or(0) == 0 {
drop(graph);
let result = crate::tools::ctx_impact::handle("build", None, root, None, None);
tracing::info!(
"Auto-built graph for architecture: {}",
&result[..result.len().min(100)]
);
}
}
fn load_graph_data(graph: &CodeGraph) -> Result<GraphData, String> {
let nodes = graph.node_count().map_err(|e| format!("{e}"))?;
if nodes == 0 {
return Err(
"Graph is empty after auto-build. No supported source files found.".to_string(),
);
}
let conn = &graph.connection();
let mut stmt = conn
.prepare(
"SELECT DISTINCT n_src.file_path, n_tgt.file_path
FROM edges e
JOIN nodes n_src ON e.source_id = n_src.id
JOIN nodes n_tgt ON e.target_id = n_tgt.id
WHERE e.kind = 'imports'
AND n_src.kind = 'file' AND n_tgt.kind = 'file'
AND n_src.file_path != n_tgt.file_path",
)
.map_err(|e| format!("{e}"))?;
let mut forward: HashMap<String, Vec<String>> = HashMap::new();
let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
let mut all_files: HashSet<String> = HashSet::new();
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| format!("{e}"))?;
for row in rows {
let (src, tgt) = row.map_err(|e| format!("{e}"))?;
all_files.insert(src.clone());
all_files.insert(tgt.clone());
forward.entry(src.clone()).or_default().push(tgt.clone());
reverse.entry(tgt).or_default().push(src);
}
let mut file_stmt = conn
.prepare("SELECT DISTINCT file_path FROM nodes WHERE kind = 'file'")
.map_err(|e| format!("{e}"))?;
let file_rows = file_stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| format!("{e}"))?;
for f in file_rows.flatten() {
all_files.insert(f);
}
for deps in forward.values_mut() {
deps.sort();
deps.dedup();
}
for deps in reverse.values_mut() {
deps.sort();
deps.dedup();
}
Ok(GraphData {
forward,
reverse,
all_files,
})
}
fn handle_overview(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let clusters = compute_clusters(&data);
let layers = compute_layers(&data);
let entrypoints = find_entrypoints(&data);
let cycles = find_cycles(&data);
let files_total = data.all_files.len();
let import_edges = data.forward.values().map(std::vec::Vec::len).sum::<usize>();
let clusters_total = clusters.len();
let clusters_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_CLUSTERS_LIMIT.max(1);
let clusters_truncated = clusters_total > clusters_limit;
let layers_total = layers.len();
let layers_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_LAYERS_LIMIT.max(1);
let layers_truncated = layers_total > layers_limit;
let entrypoints_total = entrypoints.len();
let entrypoints_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_ENTRYPOINTS_LIMIT.max(1);
let entrypoints_truncated = entrypoints_total > entrypoints_limit;
let cycles_total = cycles.len();
let cycles_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_CYCLES_LIMIT.max(1);
let cycles_truncated = cycles_total > cycles_limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let clusters_json: Vec<Value> = clusters
.iter()
.take(clusters_limit)
.map(|c| {
json!({
"dir": common_prefix(&c.files),
"file_count": c.files.len(),
"internal_edges": c.internal_edges
})
})
.collect();
let layers_json: Vec<Value> = layers
.iter()
.take(layers_limit)
.map(|l| {
json!({
"depth": l.depth,
"file_count": l.files.len()
})
})
.collect();
let entrypoints_json: Vec<Value> = entrypoints
.iter()
.take(entrypoints_limit)
.map(|ep| {
let imports = data.forward.get(ep).map_or(0, std::vec::Vec::len);
json!({ "file": ep, "imports": imports })
})
.collect();
let cycles_json: Vec<Value> = cycles
.iter()
.take(cycles_limit)
.map(|c| json!({ "path": c, "len": c.len().saturating_sub(1) }))
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "overview",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root_path),
"files_total": files_total,
"import_edges": import_edges,
"clusters_total": clusters_total,
"clusters": clusters_json,
"clusters_truncated": clusters_truncated,
"layers_total": layers_total,
"layers": layers_json,
"layers_truncated": layers_truncated,
"entrypoints_total": entrypoints_total,
"entrypoints": entrypoints_json,
"entrypoints_truncated": entrypoints_truncated,
"cycles_total": cycles_total,
"cycles": cycles_json,
"cycles_truncated": cycles_truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!(
"Architecture Overview ({files_total} files, {import_edges} import edges)\n"
);
result.push_str(&format!("\nClusters: {clusters_total}\n"));
for (i, cluster) in clusters.iter().enumerate().take(clusters_limit) {
let dir = common_prefix(&cluster.files);
result.push_str(&format!(
" #{}: {} files ({})\n",
i + 1,
cluster.files.len(),
dir
));
}
if clusters_truncated {
result.push_str(&format!(
" ... +{} more\n",
clusters_total - clusters_limit
));
}
result.push_str(&format!("\nLayers: {layers_total}\n"));
for layer in layers.iter().take(layers_limit) {
result.push_str(&format!(
" L{}: {} files\n",
layer.depth,
layer.files.len()
));
}
if layers_truncated {
result.push_str(&format!(" ... +{} more\n", layers_total - layers_limit));
}
result.push_str(&format!("\nEntrypoints: {entrypoints_total}\n"));
for ep in entrypoints.iter().take(entrypoints_limit) {
result.push_str(&format!(" {ep}\n"));
}
if entrypoints_truncated {
result.push_str(&format!(
" ... +{} more\n",
entrypoints_total - entrypoints_limit
));
}
result.push_str(&format!("\nCycles: {cycles_total}\n"));
for cycle in cycles.iter().take(cycles_limit) {
result.push_str(&format!(" {}\n", cycle.join(" -> ")));
}
if cycles_truncated {
result.push_str(&format!(" ... +{} more\n", cycles_total - cycles_limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture: {tokens} tok]")
}
}
}
fn handle_clusters(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let clusters = compute_clusters(&data);
let total = clusters.len();
let limit = crate::core::budgets::ARCHITECTURE_CLUSTERS_LIMIT.max(1);
let file_limit = crate::core::budgets::ARCHITECTURE_CLUSTER_FILES_LIMIT.max(1);
let truncated = total > limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let items: Vec<Value> = clusters
.iter()
.take(limit)
.map(|c| {
let dir = common_prefix(&c.files);
let files_total = c.files.len();
let files_truncated = files_total > file_limit;
let mut files = c.files.clone();
if files_truncated {
files.truncate(file_limit);
}
json!({
"dir": dir,
"file_count": files_total,
"internal_edges": c.internal_edges,
"files": files,
"files_truncated": files_truncated
})
})
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "clusters",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root_path),
"clusters_total": total,
"clusters": items,
"truncated": truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!("Module Clusters ({total}):\n");
for (i, cluster) in clusters.iter().take(limit).enumerate() {
let dir = common_prefix(&cluster.files);
result.push_str(&format!(
"\n#{} — {} ({} files, {} internal edges)\n",
i + 1,
dir,
cluster.files.len(),
cluster.internal_edges
));
for file in cluster.files.iter().take(file_limit) {
result.push_str(&format!(" {file}\n"));
}
if cluster.files.len() > file_limit {
result.push_str(&format!(
" ... +{} more\n",
cluster.files.len() - file_limit
));
}
}
if truncated {
result.push_str(&format!("\n... +{} more clusters\n", total - limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture clusters: {tokens} tok]")
}
}
}
fn handle_layers(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let layers = compute_layers(&data);
let total = layers.len();
let limit = crate::core::budgets::ARCHITECTURE_LAYERS_LIMIT.max(1);
let file_limit = crate::core::budgets::ARCHITECTURE_LAYER_FILES_LIMIT.max(1);
let truncated = total > limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let items: Vec<Value> = layers
.iter()
.take(limit)
.map(|l| {
let files_total = l.files.len();
let files_truncated = files_total > file_limit;
let mut files = l.files.clone();
if files_truncated {
files.truncate(file_limit);
}
json!({
"depth": l.depth,
"file_count": files_total,
"files": files,
"files_truncated": files_truncated
})
})
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "layers",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root_path),
"layers_total": total,
"layers": items,
"truncated": truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!("Dependency Layers ({total}):\n");
for layer in layers.iter().take(limit) {
result.push_str(&format!(
"\nLayer {} ({} files):\n",
layer.depth,
layer.files.len()
));
for file in layer.files.iter().take(file_limit) {
result.push_str(&format!(" {file}\n"));
}
if layer.files.len() > file_limit {
result.push_str(&format!(" ... +{} more\n", layer.files.len() - file_limit));
}
}
if truncated {
result.push_str(&format!("\n... +{} more layers\n", total - limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture layers: {tokens} tok]")
}
}
}
fn handle_cycles(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let cycles = find_cycles(&data);
if cycles.is_empty() {
return match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "cycles",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root_path),
"cycles_total": 0,
"cycles": []
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => "No dependency cycles found.".to_string(),
};
}
let total = cycles.len();
let limit = crate::core::budgets::ARCHITECTURE_CYCLES_LIMIT.max(1);
let truncated = total > limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let items: Vec<Value> = cycles
.iter()
.take(limit)
.map(|c| json!({ "path": c, "len": c.len().saturating_sub(1) }))
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "cycles",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root_path),
"cycles_total": total,
"cycles": items,
"truncated": truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!("Dependency Cycles ({total}):\n");
for (i, cycle) in cycles.iter().take(limit).enumerate() {
result.push_str(&format!("\n#{}: {}\n", i + 1, cycle.join(" -> ")));
}
if truncated {
result.push_str(&format!("\n... +{} more cycles\n", total - limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture cycles: {tokens} tok]")
}
}
}
fn handle_entrypoints(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let entrypoints = find_entrypoints(&data);
let total = entrypoints.len();
let limit = crate::core::budgets::ARCHITECTURE_ENTRYPOINTS_LIMIT.max(1);
let truncated = total > limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let items: Vec<Value> = entrypoints
.iter()
.take(limit)
.map(|ep| {
let imports = data.forward.get(ep).map_or(0, std::vec::Vec::len);
json!({ "file": ep, "imports": imports })
})
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "entrypoints",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root_path),
"entrypoints_total": total,
"entrypoints": items,
"truncated": truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!("Entrypoints ({total} — files with no dependents):\n");
for ep in entrypoints.iter().take(limit) {
let dep_count = data.forward.get(ep).map_or(0, std::vec::Vec::len);
result.push_str(&format!(" {ep} (imports {dep_count} files)\n"));
}
if truncated {
result.push_str(&format!(" ... +{} more\n", total - limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture entrypoints: {tokens} tok]")
}
}
}
fn handle_module(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
let Some(target) = path else {
return "path is required for 'module' action".to_string();
};
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let canon_root = crate::core::pathutil::safe_canonicalize(std::path::Path::new(root))
.map_or_else(|_| root.to_string(), |p| p.to_string_lossy().to_string());
let canon_target = crate::core::pathutil::safe_canonicalize(std::path::Path::new(target))
.map_or_else(|_| target.to_string(), |p| p.to_string_lossy().to_string());
let root_slash = if canon_root.ends_with('/') {
canon_root.clone()
} else {
format!("{canon_root}/")
};
let rel = canon_target
.strip_prefix(&root_slash)
.or_else(|| canon_target.strip_prefix(&canon_root))
.unwrap_or(&canon_target)
.trim_start_matches('/');
let prefix = if rel.contains('/') {
rel.rsplitn(2, '/').last().unwrap_or(rel)
} else {
rel
};
let mut module_files: Vec<String> = data
.all_files
.iter()
.filter(|f| f.starts_with(prefix))
.cloned()
.collect();
module_files.sort();
if module_files.is_empty() {
return format!("No files found in module path '{prefix}'");
}
let file_set: HashSet<&str> = module_files
.iter()
.map(std::string::String::as_str)
.collect();
let mut internal_edges = 0;
let mut external_imports: Vec<String> = Vec::new();
let mut external_dependents: Vec<String> = Vec::new();
for file in &module_files {
if let Some(deps) = data.forward.get(file) {
for dep in deps {
if file_set.contains(dep.as_str()) {
internal_edges += 1;
} else {
external_imports.push(format!("{file} -> {dep}"));
}
}
}
if let Some(revs) = data.reverse.get(file) {
for rev in revs {
if !file_set.contains(rev.as_str()) {
external_dependents.push(format!("{rev} -> {file}"));
}
}
}
}
external_imports.sort();
external_imports.dedup();
external_dependents.sort();
external_dependents.dedup();
let files_total = module_files.len();
let file_limit = crate::core::budgets::ARCHITECTURE_MODULE_FILES_LIMIT.max(1);
let files_truncated = files_total > file_limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let files: Vec<String> = module_files.iter().take(file_limit).cloned().collect();
let ext_limit = 50usize;
let ext_imports_total = external_imports.len();
let ext_dependents_total = external_dependents.len();
let imports_truncated = ext_imports_total > ext_limit;
let dependents_truncated = ext_dependents_total > ext_limit;
let imports: Vec<String> = external_imports.iter().take(ext_limit).cloned().collect();
let dependents: Vec<String> = external_dependents
.iter()
.take(ext_limit)
.cloned()
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "module",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root_path),
"module_prefix": prefix,
"file_count": files_total,
"internal_edges": internal_edges,
"files": files,
"files_truncated": files_truncated,
"external_imports_total": ext_imports_total,
"external_imports": imports,
"external_imports_truncated": imports_truncated,
"external_dependents_total": ext_dependents_total,
"external_dependents": dependents,
"external_dependents_truncated": dependents_truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!(
"Module '{prefix}' ({files_total} files, {internal_edges} internal edges)\n"
);
result.push_str("\nFiles:\n");
for f in module_files.iter().take(file_limit) {
result.push_str(&format!(" {f}\n"));
}
if files_truncated {
result.push_str(&format!(" ... +{} more\n", files_total - file_limit));
}
if !external_imports.is_empty() {
result.push_str(&format!(
"\nExternal imports ({}):\n",
external_imports.len()
));
for imp in external_imports.iter().take(15) {
result.push_str(&format!(" {imp}\n"));
}
if external_imports.len() > 15 {
result.push_str(&format!(" ... +{} more\n", external_imports.len() - 15));
}
}
if !external_dependents.is_empty() {
result.push_str(&format!(
"\nExternal dependents ({}):\n",
external_dependents.len()
));
for dep in external_dependents.iter().take(15) {
result.push_str(&format!(" {dep}\n"));
}
if external_dependents.len() > 15 {
result.push_str(&format!(" ... +{} more\n", external_dependents.len() - 15));
}
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture module: {tokens} tok]")
}
}
}
#[derive(Debug)]
struct Cluster {
files: Vec<String>,
internal_edges: usize,
}
fn compute_clusters(data: &GraphData) -> Vec<Cluster> {
let mut dir_groups: HashMap<String, Vec<String>> = HashMap::new();
for file in &data.all_files {
let dir = file.rsplitn(2, '/').last().unwrap_or("").to_string();
dir_groups.entry(dir).or_default().push(file.clone());
}
let mut clusters: Vec<Cluster> = Vec::new();
for files in dir_groups.values() {
if files.len() < 2 {
continue;
}
let file_set: HashSet<&str> = files.iter().map(std::string::String::as_str).collect();
let mut internal = 0;
for file in files {
if let Some(deps) = data.forward.get(file) {
for dep in deps {
if file_set.contains(dep.as_str()) {
internal += 1;
}
}
}
}
let mut sorted = files.clone();
sorted.sort();
clusters.push(Cluster {
files: sorted,
internal_edges: internal,
});
}
clusters.sort_by(|a, b| {
b.files
.len()
.cmp(&a.files.len())
.then_with(|| a.files[0].cmp(&b.files[0]))
});
clusters
}
struct Layer {
depth: usize,
files: Vec<String>,
}
fn compute_layers(data: &GraphData) -> Vec<Layer> {
let leaf_files: HashSet<&String> = data
.all_files
.iter()
.filter(|f| data.forward.get(*f).is_none_or(std::vec::Vec::is_empty))
.collect();
let mut depth_map: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
for leaf in &leaf_files {
depth_map.insert((*leaf).clone(), 0);
queue.push_back(((*leaf).clone(), 0));
}
while let Some((file, depth)) = queue.pop_front() {
if let Some(dependents) = data.reverse.get(&file) {
for dep in dependents {
let new_depth = depth + 1;
let current = depth_map.get(dep).copied().unwrap_or(0);
if new_depth > current {
depth_map.insert(dep.clone(), new_depth);
queue.push_back((dep.clone(), new_depth));
}
}
}
}
for file in &data.all_files {
depth_map.entry(file.clone()).or_insert(0);
}
let max_depth = depth_map.values().copied().max().unwrap_or(0);
let mut layers: Vec<Layer> = Vec::new();
for d in 0..=max_depth {
let mut files: Vec<String> = depth_map
.iter()
.filter(|(_, &depth)| depth == d)
.map(|(f, _)| f.clone())
.collect();
if !files.is_empty() {
files.sort();
layers.push(Layer { depth: d, files });
}
}
layers
}
fn find_entrypoints(data: &GraphData) -> Vec<String> {
let mut entrypoints: Vec<String> = data
.all_files
.iter()
.filter(|f| !data.reverse.contains_key(*f))
.cloned()
.collect();
entrypoints.sort();
entrypoints
}
fn find_cycles(data: &GraphData) -> Vec<Vec<String>> {
let mut cycles: Vec<Vec<String>> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
let mut starts: Vec<&String> = data.all_files.iter().collect();
starts.sort();
for start in starts {
if visited.contains(start) {
continue;
}
let mut stack: Vec<String> = Vec::new();
let mut on_stack: HashSet<String> = HashSet::new();
dfs_cycles(
start,
&data.forward,
&mut stack,
&mut on_stack,
&mut visited,
&mut cycles,
);
}
cycles.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
cycles.truncate(crate::core::budgets::ARCHITECTURE_CYCLES_LIMIT.max(1));
cycles
}
fn dfs_cycles(
node: &str,
graph: &HashMap<String, Vec<String>>,
stack: &mut Vec<String>,
on_stack: &mut HashSet<String>,
visited: &mut HashSet<String>,
cycles: &mut Vec<Vec<String>>,
) {
if on_stack.contains(node) {
let cycle_start = stack.iter().position(|n| n == node).unwrap_or(0);
let mut cycle: Vec<String> = stack[cycle_start..].to_vec();
cycle.push(node.to_string());
cycles.push(cycle);
return;
}
if visited.contains(node) {
return;
}
on_stack.insert(node.to_string());
stack.push(node.to_string());
if let Some(deps) = graph.get(node) {
for dep in deps {
dfs_cycles(dep, graph, stack, on_stack, visited, cycles);
}
}
stack.pop();
on_stack.remove(node);
visited.insert(node.to_string());
}
fn common_prefix(files: &[String]) -> String {
if files.is_empty() {
return String::new();
}
if files.len() == 1 {
return files[0]
.rsplitn(2, '/')
.last()
.unwrap_or(&files[0])
.to_string();
}
let parts: Vec<Vec<&str>> = files.iter().map(|f| f.split('/').collect()).collect();
let min_len = parts.iter().map(std::vec::Vec::len).min().unwrap_or(0);
let mut common = Vec::new();
for i in 0..min_len {
let segment = parts[0][i];
if parts.iter().all(|p| p[i] == segment) {
common.push(segment);
} else {
break;
}
}
if common.is_empty() {
"(root)".to_string()
} else {
common.join("/")
}
}
fn project_meta(root: &str) -> Value {
let root_hash = crate::core::project_hash::hash_project_root(root);
let identity_hash = crate::core::project_hash::project_identity(root)
.as_deref()
.map(md5_hex);
let root_path = Path::new(root);
json!({
"project_root_hash": root_hash,
"project_identity_hash": identity_hash,
"git": {
"head": git_out(root_path, &["rev-parse", "--short", "HEAD"]),
"branch": git_out(root_path, &["rev-parse", "--abbrev-ref", "HEAD"]),
"dirty": git_dirty(root_path)
}
})
}
fn graph_summary(project_root: &Path) -> Value {
let db_path = project_root.join(".lean-ctx").join("graph.db");
if !db_path.exists() {
return json!({
"exists": false,
"db_path": ".lean-ctx/graph.db",
"nodes": null,
"edges": null
});
}
match CodeGraph::open(project_root) {
Ok(g) => json!({
"exists": true,
"db_path": ".lean-ctx/graph.db",
"nodes": g.node_count().ok(),
"edges": g.edge_count().ok()
}),
Err(_) => json!({
"exists": true,
"db_path": ".lean-ctx/graph.db",
"nodes": null,
"edges": null
}),
}
}
fn git_dirty(project_root: &Path) -> bool {
let out = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(project_root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
match out {
Ok(o) if o.status.success() => !o.stdout.is_empty(),
_ => false,
}
}
fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
let out = std::process::Command::new("git")
.args(args)
.current_dir(project_root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let s = s.trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn md5_hex(s: &str) -> String {
use md5::{Digest, Md5};
let mut hasher = Md5::new();
hasher.update(s.as_bytes());
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn common_prefix_single() {
let files = vec!["src/core/cache.rs".to_string()];
assert_eq!(common_prefix(&files), "src/core");
}
#[test]
fn common_prefix_multiple() {
let files = vec![
"src/core/cache.rs".to_string(),
"src/core/config.rs".to_string(),
"src/core/session.rs".to_string(),
];
assert_eq!(common_prefix(&files), "src/core");
}
#[test]
fn common_prefix_different_dirs() {
let files = vec![
"src/tools/ctx_read.rs".to_string(),
"src/core/cache.rs".to_string(),
];
assert_eq!(common_prefix(&files), "src");
}
#[test]
fn entrypoints_no_dependents() {
let mut forward: HashMap<String, Vec<String>> = HashMap::new();
forward.insert("main.rs".to_string(), vec!["lib.rs".to_string()]);
let all_files: HashSet<String> = ["main.rs", "lib.rs"]
.iter()
.map(std::string::ToString::to_string)
.collect();
let data = GraphData {
forward,
reverse: {
let mut r = HashMap::new();
r.insert("lib.rs".to_string(), vec!["main.rs".to_string()]);
r
},
all_files,
};
let eps = find_entrypoints(&data);
assert_eq!(eps, vec!["main.rs"]);
}
#[test]
fn layers_simple_chain() {
let mut forward: HashMap<String, Vec<String>> = HashMap::new();
forward.insert("a.rs".to_string(), vec!["b.rs".to_string()]);
forward.insert("b.rs".to_string(), vec!["c.rs".to_string()]);
let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
reverse.insert("b.rs".to_string(), vec!["a.rs".to_string()]);
reverse.insert("c.rs".to_string(), vec!["b.rs".to_string()]);
let all_files: HashSet<String> = ["a.rs", "b.rs", "c.rs"]
.iter()
.map(std::string::ToString::to_string)
.collect();
let data = GraphData {
forward,
reverse,
all_files,
};
let layers = compute_layers(&data);
assert!(layers.len() >= 2);
let layer0 = layers.iter().find(|l| l.depth == 0).unwrap();
assert!(layer0.files.contains(&"c.rs".to_string()));
let layer2 = layers.iter().find(|l| l.depth == 2).unwrap();
assert!(layer2.files.contains(&"a.rs".to_string()));
}
#[test]
fn cycles_detection() {
let mut forward: HashMap<String, Vec<String>> = HashMap::new();
forward.insert("a.rs".to_string(), vec!["b.rs".to_string()]);
forward.insert("b.rs".to_string(), vec!["a.rs".to_string()]);
let all_files: HashSet<String> = ["a.rs", "b.rs"]
.iter()
.map(std::string::ToString::to_string)
.collect();
let data = GraphData {
forward,
reverse: HashMap::new(),
all_files,
};
let cycles = find_cycles(&data);
assert!(!cycles.is_empty());
}
#[test]
fn handle_unknown() {
let result = handle("invalid", None, "/tmp", None);
assert!(result.contains("Unknown action"));
}
}