use anyhow::Result;
use clap::Args;
use serde_json::{Value, json};
use agentstatedeveloper_core::{
AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, Engine, FeedbackStore,
FtsFilters, IndexStore, LedgerStore, OwnershipSignal, apply_feedback_adjustments,
build_feedback_state_from_entries, classify_layer_sym, compute_trust_score,
compute_uncertainty, confidence_scores, detect_ambiguous_tokens, detect_possible_misses,
discover_symbol_ownership, estimate_tokens, explain_match, extract_summary, find_candidates,
gather_recency, git_dirty_files, intent_focus, intent_layer_order, load_layer_overrides,
parse_intent, parse_query, resolve_scope, result_bucket, stale_warning, symbol_tier,
trim_for_agent,
};
use crate::commands::context_for::assemble_symbol_context;
use crate::config::Config;
#[derive(Debug, Args)]
pub struct InvestigateArgs {
#[arg(required = true, num_args = 1..)]
pub query: Vec<String>,
#[arg(long, default_value = "10")]
pub depth: usize,
#[arg(long)]
pub limit: Option<usize>,
#[arg(long)]
pub kind: Option<String>,
#[arg(long)]
pub language: Option<String>,
#[arg(long, default_value = "false")]
pub include_body: bool,
#[arg(long)]
pub include_tests: bool,
#[arg(long)]
pub quiet: bool,
#[arg(long)]
pub flat: bool,
#[arg(long)]
pub max_per_layer: Option<usize>,
#[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 json: bool,
}
pub fn run(cfg: &Config, args: InvestigateArgs) -> Result<()> {
let query: String = args.query.join(" ");
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("");
if args.intent.is_some() && intent.is_empty() {
eprintln!(
"asd: unknown intent {:?} — valid values: bugfix, feature, refactor, test, architecture, ui",
args.intent.as_deref().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 ledger_store = AsgLedgerStore::from_engine(&engine);
let effect_store = AsgEffectStore::from_engine(&engine);
let id_map = index_store.build_id_map(&engine);
let (tokens, mut exclusions) = parse_query(&query);
if let Some(ref excl) = args.exclude {
for term in excl
.split(',')
.map(|t| t.trim().to_lowercase())
.filter(|t| !t.is_empty())
{
exclusions.push(term);
}
}
if tokens.is_empty() {
println!("{}", json!({ "query": query, "entry_points": [] }));
return Ok(());
}
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 filters = FtsFilters {
kind: args.kind.as_deref().map(|k| k.to_lowercase()),
language: args.language.as_deref().map(|l| l.to_lowercase()),
include_tests: args.include_tests,
tests_only: false,
exclude_terms: exclusions,
paths_filter,
exclude_paths: Vec::new(),
exclude_languages: Vec::new(),
};
let limit = args.limit.unwrap_or(args.depth);
let mut candidates: Vec<(f64, String)> = find_candidates(
&engine,
&query,
&tokens,
&filters,
&ledger_store,
&index_store,
limit,
);
let feedback_store = AsgFeedbackStore::from_engine(&engine);
let all_fb = feedback_store
.list_all(&engine.ref_name)
.unwrap_or_default();
let feedback_verdicts: Vec<(
String,
String,
agentstatedeveloper_core::FeedbackVerdict,
chrono::DateTime<chrono::Utc>,
)> = all_fb
.iter()
.filter(|e| e.file_scope.is_none())
.map(|e| {
(
e.symbol_id.clone(),
e.query.clone(),
e.verdict,
e.created_at,
)
})
.collect();
let feedback_metrics = apply_feedback_adjustments(
&engine,
&index_store,
&query,
&mut candidates,
&feedback_verdicts,
);
let recency = gather_recency(200, 14.0);
let mut ownership_cache: std::collections::HashMap<String, OwnershipSignal> =
std::collections::HashMap::new();
let mut entry_points: Vec<Value> = Vec::new();
for (score, qname) in &candidates {
let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(s)) => s,
_ => continue,
};
let tier = symbol_tier(&sym.file);
let layer = classify_layer_sym(&sym.file, &sym.qname, tier, &layer_overrides);
let summary = extract_summary(sym.doc.as_deref(), sym.signature.as_deref());
let rec = recency.get(&sym.file);
let last_touched_days = rec.and_then(|r| r.last_touched_days);
let hot = rec.map(|r| r.hot).unwrap_or(false);
let ledger_entries = ledger_store
.list_entries(&engine.ref_name, &sym.symbol_id)
.unwrap_or_default();
let has_ledger = !ledger_entries.is_empty();
let match_reasons = explain_match(&sym, &tokens, &ledger_entries, hot);
let ownership_hint = ownership_cache.entry(sym.file.clone()).or_insert_with(|| {
discover_symbol_ownership(&sym.file, sym.start.line, sym.end.line, sym.doc.as_deref())
});
let ctx = assemble_symbol_context(
&engine,
&index_store,
&effect_store,
&ledger_store,
&sym,
&id_map,
args.include_body,
engine.fts.as_ref(),
Some(ownership_hint),
)?;
let bucket = result_bucket(&sym.file, &match_reasons, has_ledger, hot);
let mut ep = json!({
"score": score,
"layer": layer,
"summary": summary,
"last_touched_days": last_touched_days,
"hot": hot,
"match_reasons": match_reasons,
"bucket": bucket,
});
if let (Some(obj), Some(ctx_obj)) = (ep.as_object_mut(), ctx.as_object()) {
for (k, v) in ctx_obj {
obj.insert(k.clone(), v.clone());
}
}
entry_points.push(ep);
}
let mut all_invariants: Vec<Value> = Vec::new();
let mut all_hazards: Vec<Value> = Vec::new();
let mut seen_invariants: std::collections::HashSet<String> = std::collections::HashSet::new();
for ep in &entry_points {
let qname = ep
.get("symbol")
.and_then(|s| s.get("qname"))
.and_then(Value::as_str)
.unwrap_or("");
if let Some(invs) = ep.get("invariants").and_then(Value::as_array) {
for inv in invs {
let key = inv
.get("summary")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if !key.is_empty() && seen_invariants.insert(key) {
let mut v = inv.clone();
if let Some(obj) = v.as_object_mut() {
obj.insert("source_qname".to_string(), Value::String(qname.to_string()));
}
all_invariants.push(v);
}
}
}
if let Some(hzs) = ep.get("hazards").and_then(Value::as_array) {
for hz in hzs {
let mut v = hz.clone();
if let Some(obj) = v.as_object_mut() {
obj.insert("source_qname".to_string(), Value::String(qname.to_string()));
}
all_hazards.push(v);
}
}
}
let layer_order = intent_layer_order(intent);
let mut by_layer: serde_json::Map<String, Value> = serde_json::Map::new();
for layer_key in layer_order {
let mut members: Vec<Value> = entry_points
.iter()
.filter(|ep| ep.get("layer").and_then(Value::as_str) == Some(*layer_key))
.cloned()
.collect();
if let Some(max) = args.max_per_layer {
members.truncate(max);
}
if !members.is_empty() {
by_layer.insert(layer_key.to_string(), Value::Array(members));
}
}
let focus = intent_focus(intent);
let raw_scores: Vec<f64> = candidates.iter().map(|(s, _)| *s).collect();
let confidences = confidence_scores(&raw_scores);
for (ep, conf) in entry_points.iter_mut().zip(confidences.iter()) {
if let Some(obj) = ep.as_object_mut() {
obj.insert("confidence".to_string(), json!(conf));
}
}
let dirty = git_dirty_files();
let stale_symbols: Vec<String> = entry_points
.iter()
.filter_map(|ep| {
ep.get("symbol")
.and_then(|s| s.get("file"))
.and_then(Value::as_str)
})
.filter(|f| dirty.contains(*f))
.map(|s| s.to_string())
.collect();
let ambiguous_terms = detect_ambiguous_tokens(&tokens, engine.fts.as_ref(), &filters);
let layers_present: std::collections::HashSet<&str> = entry_points
.iter()
.filter_map(|ep| ep.get("layer").and_then(Value::as_str))
.collect();
let scope_narrowed = !filters.paths_filter.is_empty() || !filters.exclude_terms.is_empty();
let possible_misses = if scope_narrowed {
vec![]
} else {
detect_possible_misses(&query, &layers_present, entry_points.len())
};
let dq_state = compute_trust_score(&cfg.db_path).data_quality.state;
let uncertainty = compute_uncertainty(
&tokens,
&ambiguous_terms,
&possible_misses,
entry_points.len(),
&[],
engine.fts.as_ref(),
Some(dq_state.as_str()),
);
let feedback_state =
build_feedback_state_from_entries(&all_fb, &query, feedback_metrics.entries_applied);
let coverage = if !feedback_state.available {
"none"
} else if feedback_state.query_matches == 0 {
"none"
} else if feedback_metrics.entries_applied > 0 {
"applied"
} else {
"partial"
};
let feedback_summary = json!({
"entries_applied": feedback_metrics.entries_applied,
"suppressed": feedback_metrics.suppressed,
"preserved_useful_siblings": feedback_metrics.preserved_useful_siblings,
"boosted": feedback_metrics.boosted,
"recurring_fp_suppressed": feedback_metrics.recurring_fp_suppressed,
"rules_applied": feedback_metrics.rules_applied,
"entries_total": feedback_state.entries_total,
"query_matches": feedback_state.query_matches,
"coverage": coverage,
});
let out = if args.flat {
json!({
"query": query,
"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()) },
"tokens": tokens,
"uncertainty": uncertainty.to_json(),
"ambiguous_terms": ambiguous_terms,
"possible_misses": possible_misses,
"scope_narrowed": scope_narrowed,
"stale_symbols": stale_symbols,
"feedback_state": feedback_state.to_json(),
"feedback_summary": feedback_summary,
"invariants": all_invariants,
"hazards": all_hazards,
"entry_points": entry_points,
})
} else {
json!({
"query": query,
"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()) },
"tokens": tokens,
"uncertainty": uncertainty.to_json(),
"ambiguous_terms": ambiguous_terms,
"possible_misses": possible_misses,
"scope_narrowed": scope_narrowed,
"stale_symbols": stale_symbols,
"feedback_state": feedback_state.to_json(),
"feedback_summary": feedback_summary,
"invariants": all_invariants,
"hazards": all_hazards,
"by_layer": by_layer,
})
};
let out = agentstatedeveloper_core::drop_empty_top_level(out);
let (out, compact_for_agent) = if args.agent {
let max_list = (args.agent_budget / 500).max(3).min(20);
let mut trimmed = trim_for_agent(&out, max_list);
if let Some(obj) = trimmed.as_object_mut() {
obj.remove("query");
}
let trimmed = agentstatedeveloper_core::drop_empty_top_level(trimmed);
let compact = serde_json::to_string(&trimmed)?;
let token_est = estimate_tokens(&compact);
let mut v = trimmed;
if let Some(obj) = v.as_object_mut() {
obj.insert("token_estimate".into(), json!(token_est));
}
(v, true)
} else {
(out, false)
};
let json_str = if compact_for_agent {
serde_json::to_string(&out)?
} else {
serde_json::to_string_pretty(&out)?
};
println!("{}", json_str);
Ok(())
}