use anyhow::{bail, Result};
use clap::Args;
use serde::Serialize;
use std::path::Path;
use crate::commands::entrypoints::EntrypointInfo;
use crate::config::workspace::WorkspaceConfig;
use crate::core::graph::Graph;
use crate::core::workspace_graph::WorkspaceGraph;
use crate::output::formatter;
use crate::output::json::JsonOutput;
use crate::Context;
#[derive(Args, Debug)]
pub struct MapArgs {
#[arg(long, default_value = "10")]
pub limit: usize,
#[arg(long, short = 'j')]
pub json: bool,
}
#[derive(Debug, Serialize)]
pub struct MapStats {
pub file_count: usize,
pub symbol_count: usize,
pub edge_count: usize,
pub languages: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct CoreSymbol {
pub name: String,
pub kind: String,
pub file_path: String,
pub caller_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct DirStats {
pub directory: String,
pub file_count: usize,
pub symbol_count: usize,
}
#[derive(Debug, Serialize)]
pub struct MapData {
pub stats: MapStats,
pub entrypoints: Vec<(String, Vec<EntrypointInfo>)>,
pub core_symbols: Vec<CoreSymbol>,
pub architecture: Vec<DirStats>,
}
pub fn run(args: &MapArgs, ctx: &Context) -> Result<()> {
match ctx {
Context::SingleProject { root } => run_single(args, root),
Context::Workspace {
workspace_root,
config,
..
} => run_workspace(args, workspace_root, config),
}
}
fn run_single(args: &MapArgs, project_root: &Path) -> Result<()> {
let scope_dir = project_root.join(".scope");
if !scope_dir.exists() {
bail!("No .scope/ directory found. Run 'scope init' first.");
}
let db_path = scope_dir.join("graph.db");
if !db_path.exists() {
bail!("No index found. Run 'scope index' to build one first.");
}
let graph = Graph::open(&db_path)?;
let stats = MapStats {
file_count: graph.file_count()?,
symbol_count: graph.symbol_count()?,
edge_count: graph.edge_count()?,
languages: graph.get_languages()?,
};
let raw_entrypoints = graph.get_entrypoints()?;
let (ep_groups, ep_total, _ep_file_count) =
crate::commands::entrypoints::collapse_and_group(&raw_entrypoints, &graph);
let raw_core = graph.get_symbols_by_importance(args.limit)?;
let core_symbols: Vec<CoreSymbol> = raw_core
.into_iter()
.map(|(sym, count)| CoreSymbol {
name: sym.name,
kind: sym.kind,
file_path: sym.file_path,
caller_count: count,
project: None,
})
.collect();
let raw_dirs = graph.get_directory_stats()?;
let architecture: Vec<DirStats> = raw_dirs
.into_iter()
.map(|(dir, files, symbols)| DirStats {
directory: dir,
file_count: files,
symbol_count: symbols,
})
.collect();
let project_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
if args.json {
let data = MapData {
stats,
entrypoints: ep_groups,
core_symbols,
architecture,
};
let output = JsonOutput {
command: "map",
symbol: None,
data: &data,
truncated: false,
total: ep_total,
};
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
formatter::print_map(
project_name,
&stats,
&ep_groups,
&core_symbols,
&architecture,
);
}
Ok(())
}
fn run_workspace(args: &MapArgs, workspace_root: &Path, config: &WorkspaceConfig) -> Result<()> {
let members: Vec<(String, std::path::PathBuf)> = config
.workspace
.members
.iter()
.map(|entry| {
let name = WorkspaceConfig::resolve_member_name(entry);
let path = workspace_root.join(&entry.path);
(name, path)
})
.collect();
let wg = WorkspaceGraph::open(members)?;
let stats = MapStats {
file_count: wg.file_count(),
symbol_count: wg.symbol_count(),
edge_count: wg.edge_count(),
languages: wg.get_languages(),
};
let ws_entrypoints = wg.get_entrypoints();
let mut all_ep_infos: Vec<(EntrypointInfo, String)> = Vec::new();
for (project_name, entries) in &ws_entrypoints {
for (sym, outgoing) in entries {
all_ep_infos.push((
EntrypointInfo {
name: format!("{project_name}::{}", sym.name),
file_path: sym.file_path.clone(),
method_count: 0,
outgoing_call_count: *outgoing,
kind: sym.kind.clone(),
},
project_name.clone(),
));
}
}
let group_order = [
"API Controllers",
"Background Workers",
"Event Handlers",
"Other",
];
let mut ep_groups: Vec<(String, Vec<EntrypointInfo>)> = Vec::new();
for &group_name in &group_order {
let members: Vec<EntrypointInfo> = all_ep_infos
.iter()
.filter(|(e, _)| {
crate::commands::entrypoints::classify_group(&e.file_path) == group_name
})
.map(|(e, _)| e.clone())
.collect();
if !members.is_empty() {
ep_groups.push((group_name.to_string(), members));
}
}
let ep_total = all_ep_infos.len();
let per_member_limit = args.limit.saturating_mul(2);
let mut all_core: Vec<CoreSymbol> = Vec::new();
for member in wg.members() {
match member.graph.get_symbols_by_importance(per_member_limit) {
Ok(symbols) => {
for (sym, count) in symbols {
all_core.push(CoreSymbol {
name: sym.name,
kind: sym.kind,
file_path: sym.file_path,
caller_count: count,
project: Some(member.name.clone()),
});
}
}
Err(e) => {
tracing::warn!("Error getting core symbols from '{}': {}", member.name, e);
}
}
}
all_core.sort_by(|a, b| b.caller_count.cmp(&a.caller_count));
all_core.truncate(args.limit);
let mut architecture: Vec<DirStats> = Vec::new();
for member in wg.members() {
match member.graph.get_directory_stats() {
Ok(dirs) => {
for (dir, files, symbols) in dirs {
architecture.push(DirStats {
directory: format!("{}/{}", member.name, dir),
file_count: files,
symbol_count: symbols,
});
}
}
Err(e) => {
tracing::warn!(
"Error getting directory stats from '{}': {}",
member.name,
e
);
}
}
}
architecture.sort_by(|a, b| b.symbol_count.cmp(&a.symbol_count));
if args.json {
let data = MapData {
stats,
entrypoints: ep_groups,
core_symbols: all_core,
architecture,
};
let output = JsonOutput {
command: "map",
symbol: None,
data: &data,
truncated: false,
total: ep_total,
};
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
formatter::print_map(
&config.workspace.name,
&stats,
&ep_groups,
&all_core,
&architecture,
);
}
Ok(())
}