Skip to main content

blastcode/
query.rs

1//! Read-only queries backing the agent-facing tools.
2
3use std::collections::BTreeMap;
4
5use anyhow::{anyhow, bail, Result};
6use rusqlite::{params_from_iter, Connection, OptionalExtension};
7use serde_json::{json, Map, Value};
8
9use crate::model::Confidence;
10use crate::resolve::{sym_from_row, Resolver, SymRow, SYM_COLS, SYM_COLS_S};
11
12pub fn like_escape(s: &str) -> String {
13    s.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
14}
15
16pub fn def_json(s: &SymRow) -> Value {
17    let mut m = Map::new();
18    m.insert("name".into(), json!(s.qualname));
19    m.insert("kind".into(), json!(s.kind));
20    m.insert("file".into(), json!(s.file));
21    m.insert("line".into(), json!(s.start_line));
22    m.insert("end_line".into(), json!(s.end_line));
23    m.insert("signature".into(), json!(s.signature));
24    if let Some(d) = &s.doc {
25        m.insert("doc".into(), json!(d));
26    }
27    Value::Object(m)
28}
29
30// ---------------------------------------------------------------- skeleton
31
32pub fn skeleton(conn: &Connection, file: &str, max_depth: Option<u32>) -> Result<String> {
33    let info: Option<(String, i64)> = conn
34        .query_row("SELECT lang,lines FROM files WHERE path=?1", [file], |r| {
35            Ok((r.get(0)?, r.get(1)?))
36        })
37        .optional()?;
38    let Some((lang, lines)) = info else {
39        let base = file.rsplit('/').next().unwrap_or(file);
40        let mut st = conn.prepare("SELECT path FROM files WHERE path LIKE ?1 ESCAPE '\\' LIMIT 5")?;
41        let like = format!("%{}", like_escape(base));
42        let similar: Vec<String> = st
43            .query_map([like], |r| r.get::<_, String>(0))?
44            .collect::<rusqlite::Result<_>>()?;
45        if similar.is_empty() {
46            bail!("file not indexed: {file} (unsupported language, ignored by .gitignore, or too large)");
47        }
48        bail!("file not indexed: {file}. Did you mean: {}", similar.join(", "));
49    };
50    let mut st = conn.prepare(
51        "SELECT start_line,depth,signature,doc FROM symbols WHERE file=?1 ORDER BY id",
52    )?;
53    let rows: Vec<(u32, u32, String, Option<String>)> = st
54        .query_map([file], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
55        .collect::<rusqlite::Result<_>>()?;
56    let total = rows.len();
57    let mut out = format!("# {file} · {lang} · {lines} lines · {total} symbols\n");
58    for (line, depth, sig, doc) in rows {
59        if let Some(m) = max_depth {
60            if depth > m {
61                continue;
62            }
63        }
64        let indent = "  ".repeat(depth as usize);
65        let mut first = true;
66        for l in sig.lines() {
67            if first {
68                out.push_str(&format!("{line:>5}| {indent}{l}"));
69                first = false;
70            } else {
71                out.push_str(&format!("\n     | {indent}{l}"));
72            }
73        }
74        if let Some(d) = doc {
75            out.push_str(&format!("  — {d}"));
76        }
77        out.push('\n');
78    }
79    Ok(out)
80}
81
82// ------------------------------------------------------------ workspace map
83
84pub fn workspace_map(
85    conn: &Connection,
86    path: Option<&str>,
87    depth: usize,
88    max_chars: usize,
89) -> Result<String> {
90    let prefix = path
91        .map(|p| p.trim_matches('/').to_string())
92        .filter(|p| !p.is_empty());
93    let total_files: i64 = conn.query_row("SELECT count(*) FROM files", [], |r| r.get(0))?;
94    let total_syms: i64 = conn.query_row("SELECT count(*) FROM symbols", [], |r| r.get(0))?;
95
96    let mut st = conn.prepare(
97        "SELECT f.path, f.lang, s.name FROM files f
98         LEFT JOIN symbols s ON s.file=f.path AND s.depth=0 AND s.exported=1 AND s.kind<>'impl'
99         ORDER BY f.path, s.start_line",
100    )?;
101    let rows = st.query_map([], |r| {
102        Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Option<String>>(2)?))
103    })?;
104    let mut files: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
105    for r in rows {
106        let (p, lang, name) = r?;
107        if let Some(pre) = &prefix {
108            if p != *pre && !p.starts_with(&format!("{pre}/")) {
109                continue;
110            }
111        }
112        let e = files.entry(p).or_insert((lang, Vec::new()));
113        if let Some(n) = name {
114            if !e.1.contains(&n) {
115                e.1.push(n);
116            }
117        }
118    }
119
120    let base_depth = prefix.as_ref().map_or(0, |p| p.split('/').count());
121    let mut deep_counts: BTreeMap<String, usize> = BTreeMap::new();
122    for p in files.keys() {
123        let comps: Vec<&str> = p.split('/').collect();
124        let dir_comps = comps.len() - 1;
125        if dir_comps > base_depth + depth {
126            let key = comps[..base_depth + depth].join("/");
127            *deep_counts.entry(key).or_insert(0) += 1;
128        }
129    }
130
131    let mut out = format!(
132        "# workspace map · {total_files} files · {total_syms} symbols{}\n",
133        prefix.as_ref().map_or(String::new(), |p| format!(" · under {p}/"))
134    );
135    let mut printed: Vec<String> = Vec::new();
136    let mut deep_done: BTreeMap<String, bool> = BTreeMap::new();
137    let mut truncated = false;
138    for (p, (lang, names)) in &files {
139        let comps: Vec<&str> = p.split('/').collect();
140        let dir: Vec<String> = comps[..comps.len() - 1].iter().map(|s| s.to_string()).collect();
141        let limit = base_depth + depth;
142        let shown_dir: Vec<String> = dir.iter().take(limit).cloned().collect();
143        let mut common = 0;
144        while common < printed.len() && common < shown_dir.len() && printed[common] == shown_dir[common] {
145            common += 1;
146        }
147        let mut chunk = String::new();
148        for (i, d) in shown_dir.iter().enumerate().skip(common) {
149            chunk.push_str(&format!("{}{}/\n", "  ".repeat(i), d));
150        }
151        printed = shown_dir.clone();
152        if dir.len() > limit {
153            let key = shown_dir.join("/");
154            if !deep_done.contains_key(&key) {
155                deep_done.insert(key.clone(), true);
156                let n = deep_counts.get(&key).copied().unwrap_or(0);
157                chunk.push_str(&format!("{}… {n} files in deeper directories\n", "  ".repeat(shown_dir.len())));
158            }
159        } else {
160            let fname = comps[comps.len() - 1];
161            let mut shown: Vec<&str> = names.iter().take(8).map(|s| s.as_str()).collect();
162            let more = names.len().saturating_sub(8);
163            let tail = if more > 0 { format!(", +{more}") } else { String::new() };
164            if shown.is_empty() {
165                shown.push("");
166            }
167            chunk.push_str(&format!(
168                "{}{} [{}] {}{}\n",
169                "  ".repeat(shown_dir.len()),
170                fname,
171                short_lang(lang),
172                shown.join(", "),
173                tail
174            ));
175        }
176        if out.len() + chunk.len() > max_chars {
177            truncated = true;
178            break;
179        }
180        out.push_str(&chunk);
181    }
182    if truncated {
183        out.push_str("… map truncated; pass `path` to zoom into a subtree or lower `depth`\n");
184    }
185    Ok(out)
186}
187
188fn short_lang(l: &str) -> &str {
189    match l {
190        "python" => "py",
191        "typescript" => "ts",
192        "javascript" => "js",
193        "rust" => "rs",
194        other => other,
195    }
196}
197
198// ------------------------------------------------------------------- search
199
200pub fn search_symbols(conn: &Connection, query: &str, kind: Option<&str>, limit: usize) -> Result<String> {
201    let q = query.trim().to_lowercase().replace("::", ".");
202    if q.is_empty() {
203        bail!("query must not be empty");
204    }
205    let tokens: Vec<String> = q
206        .split(|c: char| c.is_whitespace() || c == '_' || c == '.' || c == '-')
207        .filter(|t| !t.is_empty())
208        .map(|t| t.to_string())
209        .collect();
210    let mut sql = format!("SELECT {SYM_COLS} FROM symbols WHERE kind<>'impl'");
211    let mut args: Vec<String> = Vec::new();
212    for t in &tokens {
213        args.push(format!("%{}%", like_escape(t)));
214        sql.push_str(&format!(" AND lower(qualname) LIKE ?{} ESCAPE '\\'", args.len()));
215    }
216    if let Some(k) = kind {
217        args.push(k.to_string());
218        sql.push_str(&format!(" AND kind=?{}", args.len()));
219    }
220    sql.push_str(" LIMIT 500");
221    let mut st = conn.prepare(&sql)?;
222    let rows: Vec<SymRow> = st
223        .query_map(params_from_iter(args.iter()), sym_from_row)?
224        .collect::<rusqlite::Result<_>>()?;
225
226    let compact: String = q.chars().filter(|c| c.is_alphanumeric()).collect();
227    let mut scored: Vec<(i32, SymRow)> = rows
228        .into_iter()
229        .map(|s| {
230            let n = s.name.to_lowercase();
231            let nc: String = n.chars().filter(|c| c.is_alphanumeric()).collect();
232            let mut score = if n == q || nc == compact {
233                100
234            } else if nc.starts_with(&compact) {
235                80
236            } else if nc.contains(&compact) {
237                60
238            } else {
239                40
240            };
241            if s.exported {
242                score += 5;
243            }
244            score -= (s.depth as i32).min(5);
245            (score, s)
246        })
247        .collect();
248    scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.file.cmp(&b.1.file)).then(a.1.start_line.cmp(&b.1.start_line)));
249    let total = scored.len();
250    let results: Vec<Value> = scored.into_iter().take(limit).map(|(_, s)| def_json(&s)).collect();
251    Ok(serde_json::to_string(&json!({ "total_matches": total, "results": results }))?)
252}
253
254// ---------------------------------------------------------------- query_graph
255
256#[derive(Default)]
257pub struct GraphFilter<'a> {
258    pub kind: Option<&'a str>,
259    pub name: Option<&'a str>,
260    pub path_prefix: Option<&'a str>,
261    pub calls: Option<&'a str>,
262    pub called_by: Option<&'a str>,
263    pub exported: Option<bool>,
264    pub limit: usize,
265}
266
267/// Name-level structural query. `calls` / `called_by` match call-site names, not
268/// resolved targets; use `trace_symbol` when exact resolution matters.
269pub fn query_graph(conn: &Connection, f: &GraphFilter<'_>) -> Result<String> {
270    let mut sql = format!("SELECT DISTINCT {SYM_COLS_S} FROM symbols s WHERE s.kind<>'impl'");
271    let mut args: Vec<String> = Vec::new();
272    if let Some(k) = f.kind {
273        args.push(k.to_string());
274        sql.push_str(&format!(" AND s.kind=?{}", args.len()));
275    }
276    if let Some(n) = f.name {
277        args.push(format!("%{}%", like_escape(&n.to_lowercase())));
278        sql.push_str(&format!(" AND lower(s.qualname) LIKE ?{} ESCAPE '\\'", args.len()));
279    }
280    if let Some(p) = f.path_prefix {
281        args.push(format!("{}%", like_escape(p.trim_start_matches("./"))));
282        sql.push_str(&format!(" AND s.file LIKE ?{} ESCAPE '\\'", args.len()));
283    }
284    if let Some(c) = f.calls {
285        args.push(c.to_string());
286        sql.push_str(&format!(
287            " AND s.id IN (SELECT r.enclosing_id FROM refs r WHERE r.name=?{} AND r.enclosing_id IS NOT NULL)",
288            args.len()
289        ));
290    }
291    if let Some(c) = f.called_by {
292        args.push(c.to_string());
293        sql.push_str(&format!(
294            " AND s.name IN (SELECT r.name FROM refs r JOIN symbols c ON c.id=r.enclosing_id WHERE c.name=?{})",
295            args.len()
296        ));
297    }
298    if let Some(e) = f.exported {
299        sql.push_str(if e { " AND s.exported=1" } else { " AND s.exported=0" });
300    }
301    let limit = f.limit.clamp(1, 200);
302    sql.push_str(&format!(" ORDER BY s.file, s.start_line LIMIT {limit}"));
303    let mut st = conn.prepare(&sql)?;
304    let rows: Vec<SymRow> = st
305        .query_map(params_from_iter(args.iter()), sym_from_row)?
306        .collect::<rusqlite::Result<_>>()?;
307    let results: Vec<Value> = rows.iter().map(def_json).collect();
308    Ok(serde_json::to_string(&json!({
309        "count": results.len(),
310        "note": "name-level match; use trace_symbol for resolved, confidence-tagged edges",
311        "results": results
312    }))?)
313}
314
315// ------------------------------------------------------------------- trace
316
317pub fn find_defs(conn: &Connection, name: &str, file: Option<&str>) -> Result<Vec<SymRow>> {
318    let clean = name.trim();
319    let norm = clean.replace("::", ".");
320    let leaf = clean
321        .rsplit("::")
322        .next()
323        .unwrap_or(clean)
324        .rsplit('.')
325        .next()
326        .unwrap_or(clean);
327
328    let mut sql = format!(
329        "SELECT {SYM_COLS} FROM symbols
330         WHERE (name=?1 OR qualname=?1 OR qualname=?2 OR name=?3 OR qualname LIKE ?4 ESCAPE '\\') AND kind<>'impl'"
331    );
332    let mut args: Vec<String> = vec![
333        clean.to_string(),
334        norm.clone(),
335        leaf.to_string(),
336        format!("%.{}", like_escape(leaf)),
337    ];
338    if let Some(f) = file {
339        args.push(format!("%{}", like_escape(f.trim_start_matches("./"))));
340        sql.push_str(&format!(" AND file LIKE ?{} ESCAPE '\\'", args.len()));
341    }
342    sql.push_str(" ORDER BY exported DESC, file, start_line LIMIT 50");
343    let mut st = conn.prepare(&sql)?;
344    let mut rows: Vec<SymRow> = st
345        .query_map(params_from_iter(args.iter()), sym_from_row)?
346        .collect::<rusqlite::Result<_>>()?;
347
348    // If exact lookup yields no matches, do a resilient substring fallback:
349    if rows.is_empty() {
350        let mut fallback_sql = format!(
351            "SELECT {SYM_COLS} FROM symbols
352             WHERE (lower(name) LIKE ?1 ESCAPE '\\' OR lower(qualname) LIKE ?1 ESCAPE '\\') AND kind<>'impl'"
353        );
354        let mut fb_args: Vec<String> = vec![format!("%{}%", like_escape(&leaf.to_lowercase()))];
355        if let Some(f) = file {
356            fb_args.push(format!("%{}", like_escape(f.trim_start_matches("./"))));
357            fallback_sql.push_str(&format!(" AND file LIKE ?{} ESCAPE '\\'", fb_args.len()));
358        }
359        fallback_sql.push_str(" ORDER BY exported DESC, file, start_line LIMIT 20");
360        let mut fb_st = conn.prepare(&fallback_sql)?;
361        rows = fb_st
362            .query_map(params_from_iter(fb_args.iter()), sym_from_row)?
363            .collect::<rusqlite::Result<_>>()?;
364    }
365
366    Ok(rows)
367}
368
369pub fn trace_symbol(
370    conn: &Connection,
371    name: &str,
372    file: Option<&str>,
373    direction: &str,
374    limit: usize,
375) -> Result<String> {
376    let defs = find_defs(conn, name, file)?;
377    if defs.is_empty() {
378        bail!("no symbol named '{name}' found; use search_symbols for fuzzy lookup");
379    }
380    let resolver = Resolver::new(conn, vec![])?;
381    let limit = limit.clamp(1, 500);
382    let mut matches = Vec::new();
383    for d in defs.iter().take(3) {
384        let mut obj = Map::new();
385        obj.insert("definition".into(), def_json(d));
386        if direction != "callees" {
387            let (hits, truncated) = resolver.callers(d)?;
388            let (mut ex, mut pr, mut he) = (0, 0, 0);
389            for h in &hits {
390                match h.confidence {
391                    Confidence::Exact => ex += 1,
392                    Confidence::Probable => pr += 1,
393                    Confidence::Heuristic => he += 1,
394                }
395            }
396            let list: Vec<Value> = hits
397                .iter()
398                .take(limit)
399                .map(|h| {
400                    json!({
401                        "file": h.file, "line": h.line, "usage": h.usage,
402                        "in": h.enclosing, "confidence": h.confidence.as_str()
403                    })
404                })
405                .collect();
406            obj.insert(
407                "callers_summary".into(),
408                json!({ "total": hits.len(), "exact": ex, "probable": pr, "heuristic": he,
409                        "candidates_truncated": truncated }),
410            );
411            obj.insert("callers".into(), Value::Array(list));
412        }
413        if direction != "callers" {
414            let callees = resolver.callees(d)?;
415            let list: Vec<Value> = callees
416                .iter()
417                .take(limit)
418                .map(|c| {
419                    json!({
420                        "name": c.name, "line": c.line,
421                        "targets": c.targets.iter().map(|(s, conf)| json!({
422                            "symbol": s.qualname, "file": s.file, "line": s.start_line,
423                            "confidence": conf.as_str()
424                        })).collect::<Vec<_>>()
425                    })
426                })
427                .collect();
428            obj.insert("callees".into(), Value::Array(list));
429        }
430        matches.push(Value::Object(obj));
431    }
432    let mut res = Map::new();
433    res.insert("matches".into(), Value::Array(matches));
434    if defs.len() > 3 {
435        res.insert(
436            "other_matches".into(),
437            Value::Array(defs[3..].iter().map(def_json).collect()),
438        );
439        res.insert(
440            "hint".into(),
441            json!("several definitions match; pass file_path to focus on one"),
442        );
443    }
444    Ok(serde_json::to_string(&Value::Object(res)).map_err(|e| anyhow!(e))?)
445}
446
447// =====================================================================
448// Read-less tools: file context, symbol source, change journal
449// =====================================================================
450
451use std::collections::HashSet;
452use std::path::Path;
453
454use crate::resolve::ImportRow;
455use crate::store::{events_since, EventRow};
456
457const MAX_SOURCE_LINES: usize = 400;
458
459pub fn event_json(e: &EventRow) -> Value {
460    json!({
461        "seq": e.seq,
462        "ts": e.ts,
463        "file": e.file,
464        "kind": e.kind,
465        "added": e.added,
466        "removed": e.removed,
467        "signature_changed": e.changed,
468    })
469}
470
471/// Raw journal. With `since` = None returns the most recent `limit` events.
472pub fn workspace_changes(
473    conn: &Connection,
474    since: Option<i64>,
475    file: Option<&str>,
476    limit: usize,
477) -> Result<String> {
478    let limit = limit.clamp(1, 200);
479    let mut events = match since {
480        Some(s) => events_since(conn, s, file, limit, false)?,
481        None => {
482            let mut v = events_since(conn, 0, file, limit, true)?;
483            v.reverse();
484            v
485        }
486    };
487    events.truncate(limit);
488    let latest = crate::store::max_seq(conn)?;
489    Ok(serde_json::to_string(&json!({
490        "latest_seq": latest,
491        "events": events.iter().map(event_json).collect::<Vec<_>>(),
492        "hint": "pass since=<latest_seq> next time to see only newer changes"
493    }))?)
494}
495
496/// Exact source lines of one symbol (not the whole file).
497pub fn symbol_source(
498    conn: &Connection,
499    root: &Path,
500    name: &str,
501    file: Option<&str>,
502    context: usize,
503) -> Result<String> {
504    let defs = find_defs(conn, name, file)?;
505    if defs.is_empty() {
506        bail!("no symbol named '{name}' found; use search_symbols for fuzzy lookup");
507    }
508    let context = context.min(20);
509    let mut out = String::new();
510    for d in defs.iter().take(3) {
511        let text = std::fs::read_to_string(root.join(&d.file))
512            .map_err(|e| anyhow!("cannot read {}: {e}", d.file))?;
513        let lines: Vec<&str> = text.lines().collect();
514        let start = (d.start_line as usize).saturating_sub(1 + context).min(lines.len());
515        let mut end = ((d.end_line as usize) + context).min(lines.len());
516        let mut truncated = false;
517        if end > start && end - start > MAX_SOURCE_LINES {
518            end = start + MAX_SOURCE_LINES;
519            truncated = true;
520        }
521        out.push_str(&format!(
522            "// {}:{}-{} · {} {}\n",
523            d.file, d.start_line, d.end_line, d.kind, d.qualname
524        ));
525        for (i, l) in lines[start..end].iter().enumerate() {
526            out.push_str(&format!("{:>5}| {}\n", start + i + 1, l));
527        }
528        if truncated {
529            out.push_str("// … truncated; use get_file_skeleton to pick a smaller symbol\n");
530        }
531        out.push('\n');
532    }
533    if defs.len() > 3 {
534        out.push_str(&format!("// {} more matches; pass file_path to focus\n", defs.len() - 3));
535    }
536    Ok(out)
537}
538
539fn file_stem_key(file: &str) -> String {
540    let (dir, fname) = file.rsplit_once('/').unwrap_or(("", file));
541    let stem = fname.rsplit_once('.').map_or(fname, |x| x.0);
542    if matches!(stem, "__init__" | "mod" | "index") {
543        dir.rsplit('/').next().unwrap_or(stem).to_string()
544    } else {
545        stem.to_string()
546    }
547}
548
549/// Everything about a file in one call: outline, what it imports, who depends on
550/// it, and what changed in it recently.
551pub fn file_context(conn: &Connection, file: &str) -> Result<String> {
552    let sk = skeleton(conn, file, None)?;
553    let resolver = Resolver::new(conn, vec![])?;
554
555    let mut st = conn.prepare(
556        "SELECT local,module,original,wildcard,line FROM imports WHERE file=?1 ORDER BY line",
557    )?;
558    let imps: Vec<ImportRow> = st
559        .query_map([file], |r| {
560            Ok(ImportRow {
561                local: r.get(0)?,
562                module: r.get(1)?,
563                original: r.get(2)?,
564                wildcard: r.get(3)?,
565                line: r.get(4)?,
566            })
567        })?
568        .collect::<rusqlite::Result<_>>()?;
569    let imports: Vec<Value> = imps
570        .iter()
571        .take(60)
572        .map(|imp| {
573            let files = resolver.import_files(file, imp);
574            json!({
575                "module": imp.module,
576                "name": if imp.wildcard { "*".to_string() } else { imp.original.clone().unwrap_or_else(|| imp.local.clone()) },
577                "line": imp.line,
578                "internal": if files.is_empty() { Value::Null } else { json!(files.iter().collect::<Vec<_>>()) },
579            })
580        })
581        .collect();
582
583    // Dependents: files whose imports resolve to this file.
584    let mut nst = conn.prepare(
585        "SELECT DISTINCT name FROM symbols WHERE file=?1 AND depth=0 AND exported=1 AND kind<>'impl' LIMIT 100",
586    )?;
587    let names: Vec<String> = nst
588        .query_map([file], |r| r.get::<_, String>(0))?
589        .collect::<rusqlite::Result<_>>()?;
590    let mut args: Vec<String> = vec![file.to_string(), format!("%{}%", like_escape(&file_stem_key(file)))];
591    let mut sql = String::from(
592        "SELECT file,local,module,original,wildcard,line FROM imports WHERE file<>?1 AND (module LIKE ?2 ESCAPE '\\'",
593    );
594    if !names.is_empty() {
595        let ph: Vec<String> = (0..names.len()).map(|i| format!("?{}", i + 3)).collect();
596        sql.push_str(&format!(" OR original IN ({})", ph.join(",")));
597        args.extend(names.iter().cloned());
598    }
599    sql.push_str(") LIMIT 5000");
600    let mut dst = conn.prepare(&sql)?;
601    let cand: Vec<(String, ImportRow)> = dst
602        .query_map(params_from_iter(args.iter()), |r| {
603            Ok((
604                r.get::<_, String>(0)?,
605                ImportRow {
606                    local: r.get(1)?,
607                    module: r.get(2)?,
608                    original: r.get(3)?,
609                    wildcard: r.get(4)?,
610                    line: r.get(5)?,
611                },
612            ))
613        })?
614        .collect::<rusqlite::Result<_>>()?;
615    let mut dependents: BTreeMap<String, u32> = BTreeMap::new();
616    let mut checked: HashSet<(String, String)> = HashSet::new();
617    for (f, imp) in &cand {
618        if !checked.insert((f.clone(), imp.module.clone())) {
619            continue;
620        }
621        if resolver.import_files(f, imp).iter().any(|x| x == file) {
622            let e = dependents.entry(f.clone()).or_insert(imp.line);
623            *e = (*e).min(imp.line);
624        }
625    }
626    let total = dependents.len();
627    let dep_list: Vec<Value> = dependents
628        .iter()
629        .take(40)
630        .map(|(f, l)| json!({ "file": f, "line": l }))
631        .collect();
632
633    let recent: Vec<Value> = events_since(conn, 0, Some(file), 5, true)?
634        .iter()
635        .map(event_json)
636        .collect();
637
638    Ok(serde_json::to_string(&json!({
639        "file": file,
640        "skeleton": sk,
641        "imports": imports,
642        "dependents": { "total": total, "files": dep_list },
643        "recent_changes": recent,
644    }))?)
645}
646
647// ----------------------------------------------------------- affected_tests
648
649pub fn is_test_file(path: &str) -> bool {
650    let p = path.replace('\\', "/").to_lowercase();
651    let file_name = p.rsplit('/').next().unwrap_or(&p);
652
653    if p.starts_with("tests/")
654        || p.contains("/tests/")
655        || p.starts_with("test/")
656        || p.contains("/test/")
657        || p.contains("/__tests__/")
658        || p.starts_with("spec/")
659        || p.contains("/spec/")
660    {
661        return true;
662    }
663
664    file_name.starts_with("test_")
665        || file_name.ends_with("_test.py")
666        || file_name.ends_with("_test.go")
667        || file_name.ends_with("_test.rs")
668        || file_name.ends_with(".test.ts")
669        || file_name.ends_with(".spec.ts")
670        || file_name.ends_with(".test.tsx")
671        || file_name.ends_with(".spec.tsx")
672        || file_name.ends_with(".test.js")
673        || file_name.ends_with(".spec.js")
674        || file_name.ends_with(".test.jsx")
675        || file_name.ends_with(".spec.jsx")
676        || file_name.ends_with("test.java")
677        || file_name.ends_with("tests.java")
678        || file_name.ends_with("testcase.java")
679        || file_name.ends_with("test.cs")
680        || file_name.ends_with("tests.cs")
681        || file_name.ends_with("test.php")
682        || file_name.ends_with("_spec.rb")
683        || file_name.ends_with("_test.rb")
684}
685
686pub fn suggested_test_command(test_file: &str, test_symbol: Option<&str>) -> String {
687    let p = test_file.replace('\\', "/");
688    let ext = p.rsplit('.').next().unwrap_or("");
689    let stem = p.rsplit('/').next().unwrap_or(&p).trim_end_matches(&format!(".{ext}"));
690
691    match ext {
692        "py" => {
693            if let Some(sym) = test_symbol {
694                format!("pytest {p} -k {sym}")
695            } else {
696                format!("pytest {p}")
697            }
698        }
699        "rs" => {
700            if let Some(sym) = test_symbol {
701                if p.starts_with("tests/") {
702                    format!("cargo test --test {stem} {sym}")
703                } else {
704                    format!("cargo test {sym}")
705                }
706            } else if p.starts_with("tests/") {
707                format!("cargo test --test {stem}")
708            } else {
709                "cargo test".to_string()
710            }
711        }
712        "go" => {
713            let dir = p.rsplit_once('/').map(|(d, _)| d).unwrap_or(".");
714            if let Some(sym) = test_symbol {
715                format!("go test ./{dir} -run {sym}")
716            } else {
717                format!("go test ./{dir}")
718            }
719        }
720        "ts" | "tsx" | "js" | "jsx" => {
721            if let Some(sym) = test_symbol {
722                format!("npm test -- {p} -t {sym}")
723            } else {
724                format!("npm test -- {p}")
725            }
726        }
727        "java" => {
728            if let Some(sym) = test_symbol {
729                format!("mvn test -Dtest={stem}#{sym}")
730            } else {
731                format!("mvn test -Dtest={stem}")
732            }
733        }
734        "cs" => {
735            if let Some(sym) = test_symbol {
736                format!("dotnet test --filter FullyQualifiedName~{sym}")
737            } else {
738                "dotnet test".to_string()
739            }
740        }
741        "php" => {
742            if let Some(sym) = test_symbol {
743                format!("vendor/bin/phpunit {p} --filter {sym}")
744            } else {
745                format!("vendor/bin/phpunit {p}")
746            }
747        }
748        "rb" => {
749            if let Some(sym) = test_symbol {
750                format!("bundle exec rspec {p} -e {sym}")
751            } else {
752                format!("bundle exec rspec {p}")
753            }
754        }
755        _ => format!("test {p}"),
756    }
757}
758
759pub fn affected_tests(
760    conn: &Connection,
761    file_path: Option<&str>,
762    symbol_name: Option<&str>,
763) -> Result<String> {
764    if file_path.is_none() && symbol_name.is_none() {
765        bail!("at least one of file_path or symbol_name must be provided");
766    }
767
768    let resolver = Resolver::new(conn, vec![])?;
769    let mut affected_files = HashSet::new();
770    let mut test_symbols = Vec::new();
771    let mut suggested_cmds = HashSet::new();
772
773    let mut defs = Vec::new();
774    if let Some(sym) = symbol_name {
775        defs = find_defs(conn, sym, file_path)?;
776    } else if let Some(f) = file_path {
777        let mut st = conn.prepare(&format!(
778            "SELECT {SYM_COLS} FROM symbols WHERE file=?1 AND kind<>'impl'"
779        ))?;
780        defs = st.query_map([f], sym_from_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
781    }
782
783    for def in &defs {
784        let (callers, _) = resolver.callers(def)?;
785        for h in callers {
786            let is_tf = is_test_file(&h.file);
787            let is_tsym = h.enclosing.as_ref().map_or(false, |e| {
788                let lower = e.to_lowercase();
789                lower.starts_with("test") || lower.contains("test")
790            });
791
792            if is_tf || is_tsym {
793                affected_files.insert(h.file.clone());
794                let enc_name = h.enclosing.clone().unwrap_or_else(|| "test".to_string());
795                suggested_cmds.insert(suggested_test_command(&h.file, Some(&enc_name)));
796                test_symbols.push(json!({
797                    "test_file": h.file,
798                    "test_symbol": enc_name,
799                    "line": h.line,
800                    "target_symbol": def.qualname,
801                    "confidence": h.confidence.as_str()
802                }));
803            }
804        }
805    }
806
807    if let Some(f) = file_path {
808        let mut ist = conn.prepare(
809            "SELECT file, line FROM imports WHERE original LIKE ?1 OR module LIKE ?1",
810        )?;
811        let stem = f.rsplit('/').next().unwrap_or(f);
812        let like_pat = format!("%{}%", like_escape(stem));
813        let imp_rows = ist.query_map([like_pat], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u32>(1)?)))?;
814        for row in imp_rows.flatten() {
815            let (imp_file, line) = row;
816            if imp_file != f && is_test_file(&imp_file) {
817                if affected_files.insert(imp_file.clone()) {
818                    suggested_cmds.insert(suggested_test_command(&imp_file, None));
819                    test_symbols.push(json!({
820                        "test_file": imp_file,
821                        "test_symbol": "<import>",
822                        "line": line,
823                        "target_symbol": f,
824                        "confidence": "probable"
825                    }));
826                }
827            }
828        }
829    }
830
831    let mut files_vec: Vec<String> = affected_files.into_iter().collect();
832    files_vec.sort();
833
834    let mut cmds_vec: Vec<String> = suggested_cmds.into_iter().collect();
835    cmds_vec.sort();
836
837    Ok(serde_json::to_string(&json!({
838        "target": {
839            "file": file_path,
840            "symbol": symbol_name
841        },
842        "affected_test_files": files_vec,
843        "affected_test_symbols": test_symbols,
844        "suggested_commands": cmds_vec,
845        "summary": format!("Found {} affected test files and {} test callers.", files_vec.len(), test_symbols.len())
846    }))?)
847}
848
849// ------------------------------------------------------------- find_dead_code
850
851pub fn find_dead_code(
852    conn: &Connection,
853    path_prefix: Option<&str>,
854    limit: usize,
855) -> Result<String> {
856    let mut sql = format!(
857        "SELECT {SYM_COLS} FROM symbols s
858         WHERE s.kind IN ('function', 'method', 'class', 'struct', 'enum', 'interface', 'trait')
859           AND s.depth <= 1"
860    );
861    let mut args: Vec<String> = Vec::new();
862    if let Some(p) = path_prefix {
863        args.push(format!("{}%", like_escape(p.trim_start_matches("./"))));
864        sql.push_str(&format!(" AND s.file LIKE ?{} ESCAPE '\\'", args.len()));
865    }
866    sql.push_str(" ORDER BY s.exported ASC, s.file, s.start_line");
867
868    let mut st = conn.prepare(&sql)?;
869    let candidates: Vec<SymRow> = st
870        .query_map(params_from_iter(args.iter()), sym_from_row)?
871        .collect::<rusqlite::Result<_>>()?;
872
873    let mut ref_st = conn.prepare("SELECT count(*) FROM refs WHERE name=?1")?;
874    let mut imp_st = conn.prepare("SELECT count(*) FROM imports WHERE original=?1 OR local=?1")?;
875
876    let ignored_names: &[&str] = &[
877        "main", "init", "run", "real_main", "new", "default", "handler", "execute",
878        "activate", "deactivate", "setup", "teardown", "start", "stop", "close",
879        "dispose", "from", "into", "as_ref", "clone", "to_string", "fmt",
880    ];
881
882    let mut dead = Vec::new();
883    for s in candidates {
884        if is_test_file(&s.file) {
885            continue;
886        }
887        let lower = s.name.to_lowercase();
888        if ignored_names.contains(&lower.as_str())
889            || lower.starts_with("test_")
890            || lower.starts_with("test")
891            || lower.starts_with("__")
892            || lower.starts_with("on_")
893            || lower.starts_with("handle_")
894        {
895            continue;
896        }
897
898        let ref_count: i64 = ref_st.query_row([&s.name], |r| r.get(0))?;
899        if ref_count > 0 {
900            continue;
901        }
902        let imp_count: i64 = imp_st.query_row([&s.name], |r| r.get(0))?;
903        if imp_count > 0 {
904            continue;
905        }
906
907        let confidence = if s.exported {
908            Confidence::Heuristic
909        } else {
910            Confidence::Probable
911        };
912
913        dead.push(json!({
914            "name": s.qualname,
915            "file": s.file,
916            "line": s.start_line,
917            "kind": s.kind,
918            "signature": s.signature,
919            "exported": s.exported,
920            "confidence": confidence.as_str()
921        }));
922
923        if dead.len() >= limit.clamp(1, 200) {
924            break;
925        }
926    }
927
928    Ok(serde_json::to_string(&json!({
929        "count": dead.len(),
930        "candidates": dead,
931        "note": "Probable indicates internal unreferenced symbols; Heuristic indicates unreferenced exported symbols."
932    }))?)
933}