use std::collections::{HashSet, VecDeque};
use std::process::Command as Proc;
use anyhow::Result;
use clap::Args;
use serde_json::{Value, json};
use agentstatedeveloper_core::{
AsgEffectStore, AsgIndexStore, AsgLedgerStore, EffectStore, Engine, IndexStore, LedgerKind,
LedgerStore, classify_layer_sym, endpoints_from_tree, estimate_tokens, git_dirty_files,
glob_match, intent_focus, load_layer_overrides, match_edges, matches_any_path_glob,
parse_intent, propose_test_path, resolve_scope, stale_warning, symbol_tier, trim_for_agent,
};
use crate::commands::graph::build_id_map;
use crate::config::Config;
#[derive(Debug, Args)]
pub struct ImpactArgs {
pub qname: String,
#[arg(long, default_value = "3")]
pub depth: usize,
#[arg(long, default_value = "20")]
pub git_depth: usize,
#[arg(long)]
pub quiet: bool,
#[arg(long)]
pub intent: Option<String>,
#[arg(long)]
pub agent: bool,
#[arg(long, default_value = "8000")]
pub agent_budget: usize,
#[arg(long)]
pub exclude: Option<String>,
#[arg(long)]
pub paths: Option<String>,
#[arg(long)]
pub scope: Option<String>,
#[arg(long)]
pub limit: Option<usize>,
#[arg(long)]
pub json: bool,
}
pub fn run(cfg: &Config, args: ImpactArgs) -> Result<()> {
if !args.quiet {
if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
eprintln!("{warn}");
}
}
let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
let layer_overrides = load_layer_overrides(&cfg.db_path);
let engine = Engine::open_sqlite(&cfg.db_path)?;
let index_store = AsgIndexStore::from_engine(&engine);
let effect_store = AsgEffectStore::from_engine(&engine);
let ledger_store = AsgLedgerStore::from_engine(&engine);
let id_map = build_id_map(&engine);
let symbol = index_store
.get_symbol_by_qname(&engine.ref_name, &args.qname)?
.ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;
let mut paths_filter: Vec<String> = Vec::new();
if let Some(ref scope) = args.scope {
paths_filter.extend(resolve_scope(scope, &cfg.db_path));
}
if let Some(ref paths) = args.paths {
paths_filter.extend(
paths
.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty()),
);
}
let exclusions: Vec<String> = args
.exclude
.as_deref()
.map(|e| {
e.split(',')
.map(|t| t.trim().to_lowercase())
.filter(|t| !t.is_empty())
.collect()
})
.unwrap_or_default();
let tier = symbol_tier(&symbol.file);
let layer = classify_layer_sym(&symbol.file, &symbol.qname, tier, &layer_overrides);
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
visited.insert(symbol.symbol_id.clone());
queue.push_back((symbol.symbol_id.clone(), 0));
let mut caller_rows: Vec<Value> = Vec::new();
let mut affected_test_rows: Vec<Value> = Vec::new();
let mut touched_files: IndexMap = IndexMap::new();
touched_files.insert(symbol.file.clone(), 0usize);
while let Some((sym_id, depth)) = queue.pop_front() {
if depth >= args.depth {
continue;
}
let neighbors = index_store
.get_callers(&engine.ref_name, &sym_id)
.unwrap_or_default();
for nbr_id in neighbors {
if visited.contains(&nbr_id) {
continue;
}
visited.insert(nbr_id.clone());
if let Some(s) = id_map.get(&nbr_id) {
if !paths_filter.is_empty() && !matches_any_path_glob(&paths_filter, &s.file) {
continue;
}
if !exclusions.is_empty() {
let lfile = s.file.to_lowercase();
let lqname = s.qname.to_lowercase();
if exclusions
.iter()
.any(|e| glob_match(e, &lfile) || lqname.contains(e.as_str()))
{
continue;
}
}
let t = symbol_tier(&s.file);
let l = classify_layer_sym(&s.file, &s.qname, t, &layer_overrides);
let row = json!({
"qname": s.qname,
"file": s.file,
"line": s.start.line,
"depth": depth + 1,
"layer": l,
});
if t == 2 {
affected_test_rows.push(row);
} else {
caller_rows.push(row.clone());
}
touched_files.entry(s.file.clone()).or_insert(depth + 1);
if depth + 1 < args.depth {
queue.push_back((nbr_id, depth + 1));
}
}
}
}
let effects_raw = effect_store
.get_effects(&engine.ref_name, &symbol.symbol_id)
.unwrap_or_default();
let effects_out: Value = serde_json::to_value(&effects_raw).unwrap_or(json!(null));
let mut all_invariants: Vec<Value> = Vec::new();
let mut all_hazards: Vec<Value> = Vec::new();
let mut all_validation_scenarios: Vec<Value> = Vec::new();
let mut all_known_bugs: Vec<Value> = Vec::new();
let mut seen_inv: HashSet<String> = HashSet::new();
let mut seen_ids: HashSet<String> = HashSet::new();
let all_sym_ids: Vec<String> = std::iter::once(symbol.symbol_id.clone())
.chain(visited.iter().cloned())
.filter(|id| seen_ids.insert(id.clone()))
.collect();
for sym_id in &all_sym_ids {
let entries = ledger_store
.list_entries(&engine.ref_name, sym_id)
.unwrap_or_default();
for entry in entries {
let key = entry.summary.clone();
match entry.kind {
LedgerKind::Invariant => {
if seen_inv.insert(key) {
let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
if let Some(obj) = v.as_object_mut() {
obj.insert("source_symbol_id".to_string(), json!(sym_id));
}
all_invariants.push(v);
}
}
LedgerKind::Hazard => {
let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
if let Some(obj) = v.as_object_mut() {
obj.insert("source_symbol_id".to_string(), json!(sym_id));
}
all_hazards.push(v);
}
LedgerKind::ValidationScenario => {
let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
if let Some(obj) = v.as_object_mut() {
obj.insert("source_symbol_id".to_string(), json!(sym_id));
}
all_validation_scenarios.push(v);
}
LedgerKind::KnownBug => {
let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
if let Some(obj) = v.as_object_mut() {
obj.insert("source_symbol_id".to_string(), json!(sym_id));
}
all_known_bugs.push(v);
}
_ => {}
}
}
}
let recently_touched = git_recent_touches(&touched_files, args.git_depth);
let dirty = git_dirty_files();
let stale_symbols: Vec<&str> = touched_files
.keys()
.filter(|f| dirty.contains(*f))
.map(String::as_str)
.collect();
let test_gap = affected_test_rows.is_empty() && tier != 2;
let proposed_test_path = if test_gap {
Some(propose_test_path(&symbol.file))
} else {
None
};
let mut sym_val = serde_json::to_value(&symbol)?;
if let Some(obj) = sym_val.as_object_mut() {
obj.remove("body");
}
if let Some(lim) = args.limit {
caller_rows.truncate(lim);
}
let focus = intent_focus(intent);
let cross_service = {
let tree = engine
.repo
.get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
.unwrap_or(Value::Null);
let all = endpoints_from_tree(&tree);
if all.iter().any(|e| e.symbol_id == symbol.symbol_id) {
let rows: Vec<Value> = match_edges(&all)
.iter()
.filter(|e| {
e.from.symbol_id == symbol.symbol_id || e.to.symbol_id == symbol.symbol_id
})
.map(|e| {
let is_handler = e.to.symbol_id == symbol.symbol_id;
let peer = if is_handler { &e.from } else { &e.to };
json!({
"role": if is_handler { "handler" } else { "consumer" },
"contract": e.contract,
"peer_qname": peer.qname,
"cross_repo": e.cross_repo,
"confidence": e.confidence,
})
})
.collect();
Value::Array(rows)
} else {
Value::Null
}
};
let out = json!({
"symbol": sym_val,
"layer": layer,
"intent": if intent.is_empty() { Value::Null } else { Value::String(intent.to_string()) },
"focus": if focus.is_empty() { Value::Null } else { Value::String(focus.to_string()) },
"caller_count": caller_rows.len(),
"test_count": affected_test_rows.len(),
"test_gap": test_gap,
"proposed_test_path": proposed_test_path,
"stale_symbols": stale_symbols,
"invariants": all_invariants,
"hazards": all_hazards,
"validation_scenarios": all_validation_scenarios,
"known_bugs": all_known_bugs,
"effects": effects_out,
"callers": caller_rows,
"cross_service": cross_service,
"affected_tests": affected_test_rows,
"recently_touched": recently_touched,
});
let out = if args.agent {
let max_list = (args.agent_budget / 500).max(3).min(20);
let trimmed = trim_for_agent(&out, max_list);
let json_str = serde_json::to_string_pretty(&trimmed)?;
let token_est = estimate_tokens(&json_str);
let mut v = trimmed;
if let Some(obj) = v.as_object_mut() {
obj.insert("token_estimate".into(), json!(token_est));
}
v
} else {
out
};
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(())
}
type IndexMap = std::collections::HashMap<String, usize>;
pub(crate) fn git_recent_touches_pub(files: &[(String, usize)], git_depth: usize) -> Value {
let map: IndexMap = files.iter().cloned().collect();
git_recent_touches(&map, git_depth)
}
fn git_recent_touches(files: &IndexMap, git_depth: usize) -> Value {
let mut result: Vec<Value> = Vec::new();
let mut sorted: Vec<(&String, &usize)> = files.iter().collect();
sorted.sort_by_key(|(_, d)| **d);
for (file, _) in sorted {
let output = Proc::new("git")
.args([
"log",
"--follow",
&format!("-n{git_depth}"),
"--pretty=format:%H\x1f%an\x1f%ad\x1f%s",
"--date=short",
"--",
file,
])
.output();
let commits: Vec<Value> = match output {
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.filter(|l| !l.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '\x1f').collect();
if parts.len() == 4 {
Some(json!({
"sha": &parts[0][..8.min(parts[0].len())],
"author": parts[1],
"date": parts[2],
"message": parts[3],
}))
} else {
None
}
})
.collect()
}
_ => vec![],
};
if !commits.is_empty() {
result.push(json!({ "file": file, "commits": commits }));
}
}
json!(result)
}