1use anyhow::Result;
8use clap::Args;
9use serde_json::{Value, json};
10
11use agentstatedeveloper_core::{
12 AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, Engine, FeedbackStore,
13 FtsFilters, IndexStore, LedgerStore, OwnershipSignal, apply_feedback_adjustments,
14 build_feedback_state_from_entries, classify_layer_sym, compute_trust_score,
15 compute_uncertainty, confidence_scores, detect_ambiguous_tokens, detect_possible_misses,
16 discover_symbol_ownership, estimate_tokens, explain_match, extract_summary, find_candidates,
17 gather_recency, git_dirty_files, intent_focus, intent_layer_order, load_layer_overrides,
18 parse_intent, parse_query, resolve_scope, result_bucket, stale_warning, symbol_tier,
19 trim_for_agent,
20};
21
22use crate::commands::context_for::assemble_symbol_context;
23use crate::config::Config;
24
25#[derive(Debug, Args)]
26pub struct InvestigateArgs {
27 #[arg(required = true, num_args = 1..)]
33 pub query: Vec<String>,
34
35 #[arg(long, default_value = "10")]
38 pub depth: usize,
39
40 #[arg(long)]
42 pub limit: Option<usize>,
43
44 #[arg(long)]
46 pub kind: Option<String>,
47
48 #[arg(long)]
50 pub language: Option<String>,
51
52 #[arg(long, default_value = "false")]
54 pub include_body: bool,
55
56 #[arg(long)]
58 pub include_tests: bool,
59
60 #[arg(long)]
62 pub quiet: bool,
63
64 #[arg(long)]
66 pub flat: bool,
67
68 #[arg(long)]
70 pub max_per_layer: Option<usize>,
71
72 #[arg(long)]
75 pub intent: Option<String>,
76
77 #[arg(long)]
80 pub agent: bool,
81
82 #[arg(long, default_value = "8000")]
84 pub agent_budget: usize,
85
86 #[arg(long)]
89 pub exclude: Option<String>,
90
91 #[arg(long)]
94 pub paths: Option<String>,
95
96 #[arg(long)]
98 pub scope: Option<String>,
99
100 #[arg(long)]
102 pub json: bool,
103}
104
105pub fn run(cfg: &Config, args: InvestigateArgs) -> Result<()> {
106 let query: String = args.query.join(" ");
110 if !args.quiet {
111 if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
112 eprintln!("{warn}");
113 }
114 }
115 let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
116 if args.intent.is_some() && intent.is_empty() {
117 eprintln!(
118 "asd: unknown intent {:?} — valid values: bugfix, feature, refactor, test, architecture, ui",
119 args.intent.as_deref().unwrap_or("")
120 );
121 }
122 let layer_overrides = load_layer_overrides(&cfg.db_path);
123 let engine = Engine::open_sqlite(&cfg.db_path)?;
124 let index_store = AsgIndexStore::from_engine(&engine);
125 let ledger_store = AsgLedgerStore::from_engine(&engine);
126 let effect_store = AsgEffectStore::from_engine(&engine);
127 let id_map = index_store.build_id_map(&engine);
128
129 let (tokens, mut exclusions) = parse_query(&query);
130 if let Some(ref excl) = args.exclude {
131 for term in excl
132 .split(',')
133 .map(|t| t.trim().to_lowercase())
134 .filter(|t| !t.is_empty())
135 {
136 exclusions.push(term);
137 }
138 }
139 if tokens.is_empty() {
140 println!("{}", json!({ "query": query, "entry_points": [] }));
141 return Ok(());
142 }
143
144 let mut paths_filter: Vec<String> = Vec::new();
145 if let Some(ref scope) = args.scope {
146 paths_filter.extend(resolve_scope(scope, &cfg.db_path));
147 }
148 if let Some(ref paths) = args.paths {
149 paths_filter.extend(
150 paths
151 .split(',')
152 .map(|p| p.trim().to_string())
153 .filter(|p| !p.is_empty()),
154 );
155 }
156 let filters = FtsFilters {
157 kind: args.kind.as_deref().map(|k| k.to_lowercase()),
158 language: args.language.as_deref().map(|l| l.to_lowercase()),
159 include_tests: args.include_tests,
160 tests_only: false,
161 exclude_terms: exclusions,
162 paths_filter,
163 exclude_paths: Vec::new(),
164 exclude_languages: Vec::new(),
165 };
166
167 let limit = args.limit.unwrap_or(args.depth);
171 let mut candidates: Vec<(f64, String)> = find_candidates(
172 &engine,
173 &query,
174 &tokens,
175 &filters,
176 &ledger_store,
177 &index_store,
178 limit,
179 );
180
181 let feedback_store = AsgFeedbackStore::from_engine(&engine);
184 let all_fb = feedback_store
185 .list_all(&engine.ref_name)
186 .unwrap_or_default();
187 let feedback_verdicts: Vec<(
189 String,
190 String,
191 agentstatedeveloper_core::FeedbackVerdict,
192 chrono::DateTime<chrono::Utc>,
193 )> = all_fb
194 .iter()
195 .filter(|e| e.file_scope.is_none())
196 .map(|e| {
197 (
198 e.symbol_id.clone(),
199 e.query.clone(),
200 e.verdict,
201 e.created_at,
202 )
203 })
204 .collect();
205 let feedback_metrics = apply_feedback_adjustments(
206 &engine,
207 &index_store,
208 &query,
209 &mut candidates,
210 &feedback_verdicts,
211 );
212
213 let recency = gather_recency(200, 14.0);
215
216 let mut ownership_cache: std::collections::HashMap<String, OwnershipSignal> =
220 std::collections::HashMap::new();
221
222 let mut entry_points: Vec<Value> = Vec::new();
223 for (score, qname) in &candidates {
224 let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
225 Ok(Some(s)) => s,
226 _ => continue,
227 };
228 let tier = symbol_tier(&sym.file);
229 let layer = classify_layer_sym(&sym.file, &sym.qname, tier, &layer_overrides);
230 let summary = extract_summary(sym.doc.as_deref(), sym.signature.as_deref());
231 let rec = recency.get(&sym.file);
232 let last_touched_days = rec.and_then(|r| r.last_touched_days);
233 let hot = rec.map(|r| r.hot).unwrap_or(false);
234 let ledger_entries = ledger_store
235 .list_entries(&engine.ref_name, &sym.symbol_id)
236 .unwrap_or_default();
237 let has_ledger = !ledger_entries.is_empty();
238 let match_reasons = explain_match(&sym, &tokens, &ledger_entries, hot);
239 let ownership_hint = ownership_cache.entry(sym.file.clone()).or_insert_with(|| {
241 discover_symbol_ownership(&sym.file, sym.start.line, sym.end.line, sym.doc.as_deref())
242 });
243 let ctx = assemble_symbol_context(
244 &engine,
245 &index_store,
246 &effect_store,
247 &ledger_store,
248 &sym,
249 &id_map,
250 args.include_body,
251 engine.fts.as_ref(),
252 Some(ownership_hint),
253 )?;
254 let bucket = result_bucket(&sym.file, &match_reasons, has_ledger, hot);
255 let mut ep = json!({
256 "score": score,
257 "layer": layer,
258 "summary": summary,
259 "last_touched_days": last_touched_days,
260 "hot": hot,
261 "match_reasons": match_reasons,
262 "bucket": bucket,
263 });
264 if let (Some(obj), Some(ctx_obj)) = (ep.as_object_mut(), ctx.as_object()) {
265 for (k, v) in ctx_obj {
266 obj.insert(k.clone(), v.clone());
267 }
268 }
269 entry_points.push(ep);
270 }
271
272 let mut all_invariants: Vec<Value> = Vec::new();
275 let mut all_hazards: Vec<Value> = Vec::new();
276 let mut seen_invariants: std::collections::HashSet<String> = std::collections::HashSet::new();
277
278 for ep in &entry_points {
279 let qname = ep
280 .get("symbol")
281 .and_then(|s| s.get("qname"))
282 .and_then(Value::as_str)
283 .unwrap_or("");
284
285 if let Some(invs) = ep.get("invariants").and_then(Value::as_array) {
286 for inv in invs {
287 let key = inv
288 .get("summary")
289 .and_then(Value::as_str)
290 .unwrap_or("")
291 .to_string();
292 if !key.is_empty() && seen_invariants.insert(key) {
293 let mut v = inv.clone();
294 if let Some(obj) = v.as_object_mut() {
295 obj.insert("source_qname".to_string(), Value::String(qname.to_string()));
296 }
297 all_invariants.push(v);
298 }
299 }
300 }
301 if let Some(hzs) = ep.get("hazards").and_then(Value::as_array) {
302 for hz in hzs {
303 let mut v = hz.clone();
304 if let Some(obj) = v.as_object_mut() {
305 obj.insert("source_qname".to_string(), Value::String(qname.to_string()));
306 }
307 all_hazards.push(v);
308 }
309 }
310 }
311
312 let layer_order = intent_layer_order(intent);
315 let mut by_layer: serde_json::Map<String, Value> = serde_json::Map::new();
316 for layer_key in layer_order {
317 let mut members: Vec<Value> = entry_points
318 .iter()
319 .filter(|ep| ep.get("layer").and_then(Value::as_str) == Some(*layer_key))
320 .cloned()
321 .collect();
322 if let Some(max) = args.max_per_layer {
323 members.truncate(max);
324 }
325 if !members.is_empty() {
326 by_layer.insert(layer_key.to_string(), Value::Array(members));
327 }
328 }
329
330 let focus = intent_focus(intent);
331
332 let raw_scores: Vec<f64> = candidates.iter().map(|(s, _)| *s).collect();
334 let confidences = confidence_scores(&raw_scores);
335 for (ep, conf) in entry_points.iter_mut().zip(confidences.iter()) {
337 if let Some(obj) = ep.as_object_mut() {
338 obj.insert("confidence".to_string(), json!(conf));
339 }
340 }
341 let dirty = git_dirty_files();
343 let stale_symbols: Vec<String> = entry_points
344 .iter()
345 .filter_map(|ep| {
346 ep.get("symbol")
347 .and_then(|s| s.get("file"))
348 .and_then(Value::as_str)
349 })
350 .filter(|f| dirty.contains(*f))
351 .map(|s| s.to_string())
352 .collect();
353
354 let ambiguous_terms = detect_ambiguous_tokens(&tokens, engine.fts.as_ref(), &filters);
355 let layers_present: std::collections::HashSet<&str> = entry_points
356 .iter()
357 .filter_map(|ep| ep.get("layer").and_then(Value::as_str))
358 .collect();
359 let scope_narrowed = !filters.paths_filter.is_empty() || !filters.exclude_terms.is_empty();
361 let possible_misses = if scope_narrowed {
362 vec![]
363 } else {
364 detect_possible_misses(&query, &layers_present, entry_points.len())
365 };
366
367 let dq_state = compute_trust_score(&cfg.db_path).data_quality.state;
369 let uncertainty = compute_uncertainty(
370 &tokens,
371 &ambiguous_terms,
372 &possible_misses,
373 entry_points.len(),
374 &[],
375 engine.fts.as_ref(),
376 Some(dq_state.as_str()),
377 );
378
379 let feedback_state =
384 build_feedback_state_from_entries(&all_fb, &query, feedback_metrics.entries_applied);
385 let coverage = if !feedback_state.available {
386 "none"
387 } else if feedback_state.query_matches == 0 {
388 "none"
389 } else if feedback_metrics.entries_applied > 0 {
390 "applied"
391 } else {
392 "partial"
393 };
394 let feedback_summary = json!({
395 "entries_applied": feedback_metrics.entries_applied,
396 "suppressed": feedback_metrics.suppressed,
397 "preserved_useful_siblings": feedback_metrics.preserved_useful_siblings,
398 "boosted": feedback_metrics.boosted,
399 "recurring_fp_suppressed": feedback_metrics.recurring_fp_suppressed,
400 "rules_applied": feedback_metrics.rules_applied,
401 "entries_total": feedback_state.entries_total,
402 "query_matches": feedback_state.query_matches,
403 "coverage": coverage,
404 });
405 let out = if args.flat {
406 json!({
407 "query": query,
408 "intent": if intent.is_empty() { Value::Null } else { Value::String(intent.to_string()) },
409 "focus": if focus.is_empty() { Value::Null } else { Value::String(focus.to_string()) },
410 "tokens": tokens,
411 "uncertainty": uncertainty.to_json(),
412 "ambiguous_terms": ambiguous_terms,
413 "possible_misses": possible_misses,
414 "scope_narrowed": scope_narrowed,
415 "stale_symbols": stale_symbols,
416 "feedback_state": feedback_state.to_json(),
417 "feedback_summary": feedback_summary,
418 "invariants": all_invariants,
419 "hazards": all_hazards,
420 "entry_points": entry_points,
421 })
422 } else {
423 json!({
424 "query": query,
425 "intent": if intent.is_empty() { Value::Null } else { Value::String(intent.to_string()) },
426 "focus": if focus.is_empty() { Value::Null } else { Value::String(focus.to_string()) },
427 "tokens": tokens,
428 "uncertainty": uncertainty.to_json(),
429 "ambiguous_terms": ambiguous_terms,
430 "possible_misses": possible_misses,
431 "scope_narrowed": scope_narrowed,
432 "stale_symbols": stale_symbols,
433 "feedback_state": feedback_state.to_json(),
434 "feedback_summary": feedback_summary,
435 "invariants": all_invariants,
436 "hazards": all_hazards,
437 "by_layer": by_layer,
438 })
439 };
440 let out = agentstatedeveloper_core::drop_empty_top_level(out);
446
447 let (out, compact_for_agent) = if args.agent {
448 let max_list = (args.agent_budget / 500).max(3).min(20);
449 let mut trimmed = trim_for_agent(&out, max_list);
450 if let Some(obj) = trimmed.as_object_mut() {
451 obj.remove("query");
452 }
453 let trimmed = agentstatedeveloper_core::drop_empty_top_level(trimmed);
454 let compact = serde_json::to_string(&trimmed)?;
455 let token_est = estimate_tokens(&compact);
456 let mut v = trimmed;
457 if let Some(obj) = v.as_object_mut() {
458 obj.insert("token_estimate".into(), json!(token_est));
459 }
460 (v, true)
461 } else {
462 (out, false)
463 };
464 let json_str = if compact_for_agent {
465 serde_json::to_string(&out)?
466 } else {
467 serde_json::to_string_pretty(&out)?
468 };
469 println!("{}", json_str);
470 Ok(())
471}