use std::path::Path;
use colored::Colorize;
use sem_core::parser::graph::EntityGraph;
pub struct ImpactOptions {
pub cwd: String,
pub entity_name: Option<String>,
pub entity_id: Option<String>,
pub file_hint: Option<String>,
pub json: bool,
pub file_exts: Vec<String>,
pub mode: ImpactMode,
pub depth: usize,
pub no_cache: bool,
}
pub enum ImpactMode {
All,
Deps,
Dependents,
Tests,
}
pub fn impact_command(opts: ImpactOptions) {
let root = Path::new(&opts.cwd);
let registry = super::create_registry(&opts.cwd);
let ext_filter = super::graph::normalize_exts(&opts.file_exts);
let file_paths = super::graph::find_supported_files_public(root, ®istry, &ext_filter);
let (graph, all_entities) = super::graph::get_or_build_graph(root, &file_paths, ®istry, opts.no_cache);
let entity = find_entity(&graph, opts.entity_name.as_deref(), opts.entity_id.as_deref(), opts.file_hint.as_deref());
match opts.mode {
ImpactMode::Deps => print_deps(&graph, entity, opts.json),
ImpactMode::Dependents => print_dependents(&graph, entity, opts.json),
ImpactMode::Tests => print_tests(&graph, entity, &all_entities, opts.json),
ImpactMode::All => print_all(&graph, entity, &all_entities, opts.json, opts.depth),
}
}
fn find_entity<'a>(
graph: &'a EntityGraph,
name: Option<&str>,
entity_id: Option<&str>,
file_hint: Option<&str>,
) -> &'a sem_core::parser::graph::EntityInfo {
if let Some(id) = entity_id {
if let Some(e) = graph.entities.get(id) {
return e;
}
eprintln!("{} Entity ID '{}' not found", "error:".red().bold(), id);
std::process::exit(1);
}
let name = name.unwrap_or_else(|| {
eprintln!("{} Either entity name or --entity-id is required", "error:".red().bold());
std::process::exit(1);
});
let mut matching: Vec<_> = graph.entities.values().filter(|e| e.name == name).collect();
if matching.is_empty() {
eprintln!("{} Entity '{}' not found", "error:".red().bold(), name);
std::process::exit(1);
}
if let Some(file) = file_hint {
if let Some(e) = matching.iter().find(|e| e.file_path == file) {
return e;
}
}
matching.sort_by_key(|e| (&e.file_path, e.start_line));
matching[0]
}
fn entity_json(e: &sem_core::parser::graph::EntityInfo) -> serde_json::Value {
serde_json::json!({
"entityId": e.id, "name": e.name, "type": e.entity_type,
"file": e.file_path, "lines": [e.start_line, e.end_line],
})
}
fn entity_list_json(entities: &[&sem_core::parser::graph::EntityInfo]) -> Vec<serde_json::Value> {
entities.iter().map(|e| entity_json(*e)).collect()
}
fn print_entity_header(e: &sem_core::parser::graph::EntityInfo) {
println!(
"{} {} {} ({}:{}–{})",
"⊕".green(),
e.entity_type.dimmed(),
e.name.bold(),
e.file_path.dimmed(),
e.start_line,
e.end_line,
);
}
fn print_deps(graph: &EntityGraph, entity: &sem_core::parser::graph::EntityInfo, json: bool) {
let deps = graph.get_dependencies(&entity.id);
if json {
let output = serde_json::json!({
"entity": entity_json(entity),
"dependencies": entity_list_json(&deps),
});
println!("{}", serde_json::to_string(&output).unwrap());
} else {
print_entity_header(entity);
if deps.is_empty() {
println!("\n {} {}", "✓".green().bold(), "No dependencies.".dimmed());
} else {
println!("\n {} {}", "→".blue(), "depends on:".dimmed());
for dep in &deps {
println!(
" {} {} {} ({})",
"→".blue(),
dep.entity_type.dimmed(),
dep.name.bold(),
dep.file_path.dimmed(),
);
}
}
println!();
}
}
fn print_dependents(graph: &EntityGraph, entity: &sem_core::parser::graph::EntityInfo, json: bool) {
let dependents = graph.get_dependents(&entity.id);
if json {
let output = serde_json::json!({
"entity": entity_json(entity),
"dependents": entity_list_json(&dependents),
});
println!("{}", serde_json::to_string(&output).unwrap());
} else {
print_entity_header(entity);
if dependents.is_empty() {
println!("\n {} {}", "✓".green().bold(), "No dependents.".dimmed());
} else {
println!("\n {} {}", "←".yellow(), "depended on by:".dimmed());
for dep in &dependents {
println!(
" {} {} {} ({})",
"←".yellow(),
dep.entity_type.dimmed(),
dep.name.bold(),
dep.file_path.dimmed(),
);
}
}
println!();
}
}
fn print_tests(
graph: &EntityGraph,
entity: &sem_core::parser::graph::EntityInfo,
all_entities: &[sem_core::model::entity::SemanticEntity],
json: bool,
) {
let tests = graph.test_impact(&entity.id, all_entities);
if json {
let output = serde_json::json!({
"entity": entity_json(entity),
"tests": entity_list_json(&tests),
});
println!("{}", serde_json::to_string(&output).unwrap());
} else {
print_entity_header(entity);
if tests.is_empty() {
println!("\n {} {}", "✓".green().bold(), "No tests found.".dimmed());
} else {
println!(
"\n {} {}",
"⚡".yellow(),
format!("{} tests affected:", tests.len()).bold()
);
let mut by_file: std::collections::HashMap<&str, Vec<_>> =
std::collections::HashMap::new();
for t in &tests {
by_file.entry(t.file_path.as_str()).or_default().push(t);
}
let mut files: Vec<_> = by_file.keys().copied().collect();
files.sort();
for file in files {
println!(" {}", file.bold());
let mut entities = by_file[file].clone();
entities.sort_by_key(|e| e.start_line);
for t in entities {
println!(
" {} {} (L{}–{})",
t.entity_type.dimmed(),
t.name.bold(),
t.start_line,
t.end_line,
);
}
}
}
println!();
}
}
fn print_all(
graph: &EntityGraph,
entity: &sem_core::parser::graph::EntityInfo,
all_entities: &[sem_core::model::entity::SemanticEntity],
json: bool,
depth: usize,
) {
let deps = graph.get_dependencies(&entity.id);
let dependents = graph.get_dependents(&entity.id);
let impact_bounded = graph.impact_analysis_bounded(&entity.id, depth);
let tests = graph.test_impact(&entity.id, all_entities);
if json {
let impact_entities: Vec<serde_json::Value> = impact_bounded.iter().map(|(e, d)| {
let mut v = entity_json(e);
v.as_object_mut().unwrap().insert("depth".to_string(), serde_json::json!(d));
v
}).collect();
let output = serde_json::json!({
"entity": entity_json(entity),
"dependencies": entity_list_json(&deps),
"dependents": entity_list_json(&dependents),
"impact": {
"depth": depth,
"total": impact_bounded.len(),
"entities": impact_entities,
},
"tests": entity_list_json(&tests),
});
println!("{}", serde_json::to_string(&output).unwrap());
} else {
print_entity_header(entity);
if !deps.is_empty() {
println!("\n {} {}", "→".blue(), "depends on:".dimmed());
for dep in &deps {
println!(
" {} {} {} ({})",
"→".blue(),
dep.entity_type.dimmed(),
dep.name.bold(),
dep.file_path.dimmed(),
);
}
}
if !dependents.is_empty() {
println!("\n {} {}", "←".yellow(), "depended on by:".dimmed());
for dep in &dependents {
println!(
" {} {} {} ({})",
"←".yellow(),
dep.entity_type.dimmed(),
dep.name.bold(),
dep.file_path.dimmed(),
);
}
}
if impact_bounded.is_empty() {
println!(
"\n {} {}",
"✓".green().bold(),
"No other entities are affected by changes to this entity."
.dimmed()
);
} else {
let max_depth_seen = impact_bounded.iter().map(|(_, d)| *d).max().unwrap_or(0);
let depth_label = if depth == 0 { "unlimited".to_string() } else { format!("depth {}", depth) };
println!(
"\n {} {}",
"!".red().bold(),
format!("{} entities transitively affected ({}):", impact_bounded.len(), depth_label).red(),
);
for d in 1..=max_depth_seen {
let at_depth: Vec<_> = impact_bounded.iter().filter(|(_, dd)| *dd == d).map(|(e, _)| *e).collect();
if at_depth.is_empty() { continue; }
let label = if d == 1 { "Direct dependents".to_string() } else { format!("Depth {}", d) };
println!("\n {} ({})", label.bold(), at_depth.len());
for imp in &at_depth {
println!(
" {} {} {} ({}:L{})",
"→".red(),
imp.entity_type.dimmed(),
imp.name.bold(),
imp.file_path.dimmed(),
imp.start_line,
);
}
}
}
if !tests.is_empty() {
println!(
"\n {} {}",
"⚡".yellow(),
format!("{} tests affected:", tests.len()).bold()
);
for t in &tests {
println!(
" {} {} ({})",
t.entity_type.dimmed(),
t.name.bold(),
t.file_path.dimmed(),
);
}
}
println!();
}
}