Skip to main content

code_kb_core/
ops.rs

1use rusqlite::Connection;
2use std::path::Path;
3use thiserror::Error;
4
5use crate::db::{DbError, ensure_fts_index_path};
6use crate::edit_check::{self, BaseRequest, EditCheck, EditCheckError, EditLimits, PendingCheck};
7use crate::formatters::{
8    OutlineNode, add_path_to_outline, add_unsupported_to_outline, format_file_skeleton,
9    format_find_symbol_results, render_outline_tree,
10};
11use crate::models::{BlastRadiusResult, ContextSlice, Symbol, SymbolSearchResult, TestTarget};
12use crate::queries::{self, QueryError};
13use crate::reference_report::{reference_report_for_edit, select_reference_target};
14use crate::slicer::{self, SliceError};
15use crate::sync::{self, SyncError};
16use crate::workspace::{Workspace, WorkspaceError, paths_equal};
17
18#[derive(Debug, Clone)]
19pub enum SymbolSelector {
20    Name(String),
21    Id(String),
22}
23
24#[derive(Debug, Error)]
25pub enum OpError {
26    #[error("Symbol '{name}' not found in {workspace}. {hint}")]
27    SymbolNotFound {
28        name: String,
29        workspace: String,
30        hint: String,
31    },
32    #[error("File '{path}' not found in {workspace}. {hint}")]
33    FileNotFound {
34        path: String,
35        workspace: String,
36        hint: String,
37    },
38    #[error(
39        "symbol_id '{id}' is no longer indexed in {workspace}; run lookup_symbol and select a current id"
40    )]
41    StaleSymbolId { id: String, workspace: String },
42    #[error("A database path is required to refresh a symbol_id selector")]
43    SymbolIdRequiresDatabasePath,
44    #[error("Selected symbol is in '{actual}', not requested file '{requested}'")]
45    FileGuardMismatch { requested: String, actual: String },
46    #[error("Workspace error: {0}")]
47    Workspace(#[from] WorkspaceError),
48    #[error("Synchronization error: {0}")]
49    Sync(#[from] SyncError),
50    #[error("Query error: {0}")]
51    Query(#[from] QueryError),
52    #[error("Search index is not ready: {0}")]
53    SearchIndex(#[from] DbError),
54    #[error("Slice error: {0}")]
55    Slice(#[from] SliceError),
56    #[error(transparent)]
57    EditCheck(#[from] EditCheckError),
58}
59
60/// What `lookup_symbol` found for one query.
61#[derive(Debug, Clone, Default, serde::Serialize)]
62pub struct SymbolLookup {
63    /// Rows named like the query, or the members of the owner it names.
64    pub names: Vec<Symbol>,
65    /// Ranked matches by name words, signatures, and docs that `names` does not already show.
66    pub ranked: Vec<SymbolSearchResult>,
67    /// The text for a query that finds nothing: the bound workspace and close names.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub miss: Option<String>,
70}
71
72/// Looks up `query` by name. A plain query that no row names exactly also gets the ranked search,
73/// so one call answers a name and a concept.
74pub fn lookup_symbols_op(
75    db_path: &Path,
76    conn: &Connection,
77    query: &str,
78    kind: Option<&str>,
79    path_filter: Option<&str>,
80    include_tests: bool,
81    limit: usize,
82) -> Result<SymbolLookup, OpError> {
83    let names =
84        queries::search_symbols_scoped(conn, query, kind, path_filter, include_tests, limit)?;
85    let plain = !query.contains('.') && !query.contains("::");
86    let named_exactly = names.iter().any(|s| s.name.eq_ignore_ascii_case(query));
87    let mut ranked = Vec::new();
88    if limit > 0 && plain && !named_exactly {
89        ensure_fts_index_path(db_path)?;
90        ranked = queries::fts_search_symbols_scoped(
91            conn,
92            query,
93            kind,
94            path_filter,
95            include_tests,
96            limit + names.len(),
97        )?;
98        ranked.retain(|hit| names.iter().all(|s| s.symbol_id != hit.symbol.symbol_id));
99        ranked.truncate(limit);
100    }
101    let miss = (limit > 0 && names.is_empty() && ranked.is_empty()).then(|| {
102        let (workspace, hint) = queries::symbol_not_found_parts(conn, query, path_filter);
103        format!("No symbols found matching \"{query}\" in {workspace}. {hint}\n")
104    });
105    Ok(SymbolLookup {
106        names,
107        ranked,
108        miss,
109    })
110}
111
112/// The text of a lookup. Members show as `Owner.name`; the rows in `lookup` keep their raw names.
113pub fn render_symbol_lookup(
114    conn: &Connection,
115    query: &str,
116    lookup: &SymbolLookup,
117    limit: usize,
118) -> Result<String, QueryError> {
119    if let Some(miss) = &lookup.miss {
120        return Ok(miss.clone());
121    }
122    let mut names = lookup.names.clone();
123    let mut ranked = lookup.ranked.clone();
124    queries::qualify_members(
125        conn,
126        names
127            .iter_mut()
128            .chain(ranked.iter_mut().map(|hit| &mut hit.symbol)),
129    )?;
130    Ok(format_find_symbol_results(query, &names, &ranked, limit))
131}
132
133fn stale_symbol_id(conn: &Connection, id: &str) -> OpError {
134    OpError::StaleSymbolId {
135        id: id.to_string(),
136        workspace: queries::workspace_name(conn),
137    }
138}
139
140pub fn resolve_symbol_op(
141    workspace: &Workspace,
142    db_path: &Path,
143    conn: &Connection,
144    selector: &SymbolSelector,
145    file_path: Option<&str>,
146) -> Result<Symbol, OpError> {
147    let id_selector = matches!(selector, SymbolSelector::Id(_));
148    let mut folder = None;
149    let guard = if let Some(file_path) = file_path {
150        let (absolute, rel) = workspace.resolve_path(Path::new(file_path))?;
151        if !absolute.exists() {
152            return Err(file_not_found(conn, &rel));
153        }
154        if absolute.is_dir() {
155            folder = Some(rel);
156            None
157        } else {
158            if !id_selector {
159                sync::ensure_fresh_file(workspace, db_path, conn, &rel)?;
160            }
161            Some((absolute, rel))
162        }
163    } else {
164        None
165    };
166    let scope = guard
167        .as_ref()
168        .map(|(_, rel)| rel.as_str())
169        .or(folder.as_deref());
170    let lookup = |conn: &Connection| match selector {
171        SymbolSelector::Name(name) => queries::get_symbol_by_name(conn, name, scope),
172        SymbolSelector::Id(id) => queries::get_symbol_by_id(conn, id),
173    };
174    let initial = lookup(conn)?.ok_or_else(|| match selector {
175        SymbolSelector::Name(name) => symbol_not_found(conn, name, scope),
176        SymbolSelector::Id(id) => stale_symbol_id(conn, id),
177    })?;
178    if !id_selector
179        && let Some((requested_abs, requested_rel)) = &guard
180        && !paths_equal(requested_abs, &workspace.canonical_root.join(&initial.path))
181    {
182        return Err(OpError::FileGuardMismatch {
183            requested: requested_rel.clone(),
184            actual: initial.path,
185        });
186    }
187    let refreshed = sync::ensure_fresh_file(workspace, db_path, conn, &initial.path)?;
188    let refreshed_symbol = if refreshed {
189        match selector {
190            SymbolSelector::Name(name) => {
191                queries::get_symbol_by_name_exact(conn, name, &initial.path)?
192            }
193            SymbolSelector::Id(id) => queries::get_symbol_by_id(conn, id)?,
194        }
195        .ok_or_else(|| match selector {
196            SymbolSelector::Name(name) => symbol_not_found(conn, name, scope),
197            SymbolSelector::Id(id) => stale_symbol_id(conn, id),
198        })?
199    } else {
200        initial
201    };
202    if let Some((requested_abs, requested_rel)) = &guard
203        && !paths_equal(
204            requested_abs,
205            &workspace.canonical_root.join(&refreshed_symbol.path),
206        )
207    {
208        return Err(OpError::FileGuardMismatch {
209            requested: requested_rel.clone(),
210            actual: refreshed_symbol.path,
211        });
212    }
213    Ok(refreshed_symbol)
214}
215
216fn symbol_not_found(conn: &Connection, name: &str, path_filter: Option<&str>) -> OpError {
217    let (workspace, hint) = queries::symbol_not_found_parts(conn, name, path_filter);
218    OpError::SymbolNotFound {
219        name: name.to_string(),
220        workspace,
221        hint,
222    }
223}
224
225fn file_not_found(conn: &Connection, rel_path: &str) -> OpError {
226    let (workspace, hint) = queries::file_not_found_parts(conn, rel_path);
227    OpError::FileNotFound {
228        path: rel_path.to_string(),
229        workspace,
230        hint,
231    }
232}
233
234/// Retrieve the body of a symbol by name, guaranteeing fresh offsets and file contents.
235pub fn get_symbol_body_op(
236    workspace: &Workspace,
237    db_path: &Path,
238    conn: &Connection,
239    symbol_name: &str,
240    file_path: Option<&str>,
241) -> Result<(Symbol, String), OpError> {
242    get_symbol_body_selected_op(
243        workspace,
244        db_path,
245        conn,
246        &SymbolSelector::Name(symbol_name.to_string()),
247        file_path,
248    )
249}
250
251pub fn get_symbol_body_selected_op(
252    workspace: &Workspace,
253    db_path: &Path,
254    conn: &Connection,
255    selector: &SymbolSelector,
256    file_path: Option<&str>,
257) -> Result<(Symbol, String), OpError> {
258    let symbol = resolve_symbol_op(workspace, db_path, conn, selector, file_path)?;
259
260    let abs_file = workspace.canonical_root.join(&symbol.path);
261    let start = decorated_start(conn, &symbol).unwrap_or(symbol.start_byte);
262    let source = slicer::slice_symbol_source(&abs_file, &symbol, start)?;
263
264    Ok((symbol, source))
265}
266
267/// A context read builds callees, types, and tests for each overload, so it stops at this many.
268pub const MAX_CONTEXT_OVERLOADS: usize = 4;
269
270/// Like [`get_symbol_body_selected_op`], but a name that selects several overloads of one owner
271/// in one file returns the body of each overload instead of an ambiguity error.
272pub fn get_symbol_bodies_selected_op(
273    workspace: &Workspace,
274    db_path: &Path,
275    conn: &Connection,
276    selector: &SymbolSelector,
277    file_path: Option<&str>,
278) -> Result<Vec<(Symbol, String)>, OpError> {
279    match get_symbol_body_selected_op(workspace, db_path, conn, selector, file_path) {
280        Err(ambiguity @ OpError::Query(QueryError::AmbiguousSymbol(..))) => overload_selectors(
281            workspace,
282            db_path,
283            conn,
284            selector,
285            file_path,
286            ambiguity,
287            usize::MAX,
288        )?
289        .iter()
290        .map(|overload| get_symbol_body_selected_op(workspace, db_path, conn, overload, None))
291        .collect(),
292        result => result.map(|body| vec![body]),
293    }
294}
295
296/// Like [`get_context_slice_selected_op`], but a name that selects up to
297/// [`MAX_CONTEXT_OVERLOADS`] overloads of one owner in one file returns a slice for each overload.
298pub fn get_context_slices_selected_op(
299    workspace: &Workspace,
300    db_path: &Path,
301    conn: &Connection,
302    selector: &SymbolSelector,
303    file_path: Option<&str>,
304    include_external: bool,
305) -> Result<Vec<ContextSlice>, OpError> {
306    let slice = |selector: &SymbolSelector, file_path: Option<&str>| {
307        get_context_slice_selected_op(
308            workspace,
309            db_path,
310            conn,
311            selector,
312            file_path,
313            include_external,
314        )
315    };
316    match slice(selector, file_path) {
317        Err(ambiguity @ OpError::Query(QueryError::AmbiguousSymbol(..))) => overload_selectors(
318            workspace,
319            db_path,
320            conn,
321            selector,
322            file_path,
323            ambiguity,
324            MAX_CONTEXT_OVERLOADS,
325        )?
326        .iter()
327        .map(|overload| slice(overload, None))
328        .collect(),
329        result => result.map(|slice| vec![slice]),
330    }
331}
332
333/// The current ids of the overloads a name selects, after their file is refreshed. Returns
334/// `ambiguity` for an id selector, for more than `max` overloads, or when the refresh left none.
335fn overload_selectors(
336    workspace: &Workspace,
337    db_path: &Path,
338    conn: &Connection,
339    selector: &SymbolSelector,
340    file_path: Option<&str>,
341    ambiguity: OpError,
342    max: usize,
343) -> Result<Vec<SymbolSelector>, OpError> {
344    let SymbolSelector::Name(name) = selector else {
345        return Err(ambiguity);
346    };
347    let scope = file_path
348        .map(|path| workspace.resolve_path(Path::new(path)))
349        .transpose()?
350        .map(|(_, rel)| rel);
351    let mut overloads = queries::get_symbol_overloads(conn, name, scope.as_deref())?;
352    if let Some(path) = overloads.first().map(|symbol| symbol.path.clone())
353        && sync::ensure_fresh_file(workspace, db_path, conn, &path)?
354    {
355        overloads = queries::get_symbol_overloads(conn, name, scope.as_deref())?;
356    }
357    if overloads.is_empty() || overloads.len() > max {
358        return Err(ambiguity);
359    }
360    Ok(overloads
361        .into_iter()
362        .map(|symbol| SymbolSelector::Id(symbol.symbol_id))
363        .collect())
364}
365
366/// Where the decorators above a Python symbol begin: julie starts the symbol at its `def` or
367/// `class` line and records the decorated block as a `decorated_definition` fact.
368fn decorated_start(conn: &Connection, symbol: &Symbol) -> Option<usize> {
369    conn.query_row(
370        "SELECT MAX(start_byte) FROM structural_facts
371         WHERE path = ?1 AND pattern_id LIKE '%.decorated_definition.%'
372           AND end_byte = ?2 AND start_byte < ?3",
373        rusqlite::params![
374            symbol.path,
375            symbol.end_byte as i64,
376            symbol.start_byte as i64
377        ],
378        |row| row.get::<_, Option<i64>>(0),
379    )
380    .ok()
381    .flatten()
382    .map(|start| start as usize)
383}
384
385/// Retrieve a complete context slice for a symbol, guaranteeing fresh offsets.
386pub fn get_context_slice_op(
387    workspace: &Workspace,
388    db_path: &Path,
389    conn: &Connection,
390    symbol_name: &str,
391    file_path: Option<&str>,
392    include_external: bool,
393) -> Result<ContextSlice, OpError> {
394    get_context_slice_selected_op(
395        workspace,
396        db_path,
397        conn,
398        &SymbolSelector::Name(symbol_name.to_string()),
399        file_path,
400        include_external,
401    )
402}
403
404pub fn get_context_slice_selected_op(
405    workspace: &Workspace,
406    db_path: &Path,
407    conn: &Connection,
408    selector: &SymbolSelector,
409    file_path: Option<&str>,
410    include_external: bool,
411) -> Result<ContextSlice, OpError> {
412    let (target_symbol, target_body) =
413        get_symbol_body_selected_op(workspace, db_path, conn, selector, file_path)?;
414
415    let callee_signatures = queries::find_callee_signatures(
416        conn,
417        &target_symbol.name,
418        &target_symbol.symbol_id,
419        10,
420        include_external,
421    )?;
422
423    let related_types = queries::find_related_types(conn, &target_symbol, 10)?;
424    let related_tests = queries::find_related_tests(conn, &target_symbol, 5)?;
425
426    Ok(ContextSlice {
427        target_symbol,
428        target_body,
429        callee_signatures,
430        related_types,
431        related_tests,
432    })
433}
434
435/// Generate file skeleton with fresh file synchronization.
436pub fn file_skeleton_op(
437    workspace: &Workspace,
438    db_path: &Path,
439    conn: &Connection,
440    file_path: &str,
441) -> Result<String, OpError> {
442    let (effective_abs, rel_path) = workspace.resolve_path(Path::new(file_path))?;
443    if !effective_abs.exists() {
444        return Err(file_not_found(conn, &rel_path));
445    }
446    if effective_abs.is_dir() {
447        return codebase_outline_op(workspace, conn, 2, Some(&rel_path));
448    }
449    sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
450
451    let mut symbols = queries::load_file_symbols(conn, &rel_path)?;
452    let inherited =
453        queries::inherited_writes_among(conn, symbols.iter().map(|s| s.symbol_id.as_str()))?;
454    symbols.retain(|symbol| !inherited.contains(&symbol.symbol_id));
455    let file_meta = queries::get_file(conn, &rel_path)?;
456    let line_count = file_meta.and_then(|m| m.line_count.map(|l| l as usize));
457    let parse_errors = queries::count_parse_diagnostics(conn, &rel_path);
458
459    Ok(format_file_skeleton(
460        &rel_path,
461        &symbols,
462        line_count,
463        parse_errors,
464    ))
465}
466
467/// Generate a memory-bounded codebase outline pushed down into SQLite.
468pub fn codebase_outline_op(
469    workspace: &Workspace,
470    conn: &Connection,
471    depth: usize,
472    path_filter: Option<&str>,
473) -> Result<String, OpError> {
474    let rel_filter = path_filter.map(|p| workspace.relativize_filter(p));
475    let path_filter = rel_filter.as_deref().filter(|path| !path.is_empty());
476    let norm = path_filter
477        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
478        .filter(|p| !p.is_empty());
479    let scope = queries::OutlineScope::new(path_filter, depth);
480    let symbols_by_file = queries::load_scoped_outline_symbols(conn, path_filter, depth, 5)?;
481    let counts = queries::load_outline_counts(conn, path_filter, depth)?;
482
483    let mut root_node = OutlineNode::default();
484    let norm_filter = norm.as_deref().unwrap_or_default();
485    let mut files_found = 0;
486    let mut listed = 0;
487    let mut truncated = false;
488    queries::for_each_outline_path(conn, &scope, |file_path| {
489        files_found += 1;
490        if scope.lists(&file_path) {
491            listed += 1;
492            if listed > queries::OUTLINE_FILE_CAP {
493                truncated = true;
494                return false;
495            }
496        }
497        add_path_to_outline(
498            &mut root_node,
499            &file_path,
500            &symbols_by_file,
501            &counts,
502            depth,
503            norm_filter,
504        );
505        true
506    })?;
507
508    let mut unsupported = 0;
509    queries::for_each_unsupported_path(conn, &scope, |file_path| {
510        unsupported += 1;
511        add_unsupported_to_outline(&mut root_node, file_path, depth, norm_filter);
512    })?;
513    if let Some(filter) = path_filter
514        && files_found == 0
515        && unsupported == 0
516    {
517        return Err(file_not_found(conn, filter));
518    }
519
520    let display_root = if norm_filter.is_empty() {
521        format!("{}/", workspace.repo_name)
522    } else {
523        format!("{}/{}/", workspace.repo_name, norm_filter)
524    };
525
526    let mut out = String::new();
527    out.push_str(&format!("{display_root}\n"));
528    render_outline_tree(&mut out, &root_node, "", 0, depth);
529
530    if truncated {
531        let msg = if path_filter.is_some() {
532            "\n[Outline truncated: path matches over 1,000 files. Narrow your path filter or specify a deeper path to reduce scope.]\n"
533        } else {
534            "\n[Outline truncated: workspace contains over 1,000 files. Use a path filter (e.g. `code-kb outline <path>`) to narrow scope.]\n"
535        };
536        out.push_str(msg);
537    }
538
539    if unsupported > 0 {
540        let noun = if unsupported == 1 { "file" } else { "files" };
541        out.push_str(&format!(
542            "\n[{unsupported} unsupported {noun}: no extractor for the language]\n"
543        ));
544    }
545
546    Ok(out)
547}
548
549/// Checks the working tree against `base`, else the session base of the project, else `HEAD`:
550/// the symbols whose shape changed and the sites of each that the diff did not reach.
551pub fn edit_check_op(
552    workspace: &Workspace,
553    db_path: &Path,
554    conn: &Connection,
555    base: Option<&str>,
556) -> Result<EditCheck, OpError> {
557    match base.map(str::trim).filter(|rev| !rev.is_empty()) {
558        Some(rev) => edit_check_with(
559            workspace,
560            db_path,
561            conn,
562            BaseRequest::Given(rev),
563            EditLimits::default(),
564        ),
565        None => {
566            let session_base = edit_check::latest_session_base(&workspace.canonical_root);
567            edit_check_session_op(workspace, db_path, conn, session_base.as_deref())
568        }
569    }
570}
571
572/// Checks the working tree against the base a session recorded, else `HEAD`. A recorded base
573/// that git no longer has counts as none.
574pub fn edit_check_session_op(
575    workspace: &Workspace,
576    db_path: &Path,
577    conn: &Connection,
578    session_base: Option<&str>,
579) -> Result<EditCheck, OpError> {
580    Ok(edit_check_session_diff(workspace, conn, session_base)?.run(workspace, db_path, conn)?)
581}
582
583/// The first half of [`edit_check_session_op`]: the diff against the session base and its hash,
584/// before any scan of the base. [`PendingCheck::run`] finishes the check.
585pub fn edit_check_session_diff(
586    workspace: &Workspace,
587    conn: &Connection,
588    session_base: Option<&str>,
589) -> Result<PendingCheck, OpError> {
590    let limits = EditLimits::default();
591    if let Some(base) = session_base {
592        match edit_check::prepare(workspace, conn, BaseRequest::Session(base), limits) {
593            Err(EditCheckError::UnknownRevision(_)) => {}
594            prepared => return Ok(prepared?),
595        }
596    }
597    Ok(edit_check::prepare(
598        workspace,
599        conn,
600        BaseRequest::Head,
601        limits,
602    )?)
603}
604
605/// Like [`edit_check_op`], with the base named by its source and the cuts given.
606pub fn edit_check_with(
607    workspace: &Workspace,
608    db_path: &Path,
609    conn: &Connection,
610    request: BaseRequest<'_>,
611    limits: EditLimits,
612) -> Result<EditCheck, OpError> {
613    Ok(edit_check::run(workspace, db_path, conn, request, limits)?)
614}
615
616/// Compute blast radius and likely tests for a symbol, file, or uncommitted git changes.
617pub fn blast_radius_op(
618    workspace: &Workspace,
619    conn: &Connection,
620    symbol: Option<&str>,
621    file: Option<&str>,
622    max_depth: usize,
623    limit: usize,
624) -> Result<BlastRadiusResult, OpError> {
625    blast_radius_selected_op(
626        workspace,
627        None,
628        conn,
629        symbol.map(|s| SymbolSelector::Name(s.to_string())),
630        file,
631        max_depth,
632        limit,
633    )
634}
635
636pub fn blast_radius_selected_op(
637    workspace: &Workspace,
638    db_path: Option<&Path>,
639    conn: &Connection,
640    selector: Option<SymbolSelector>,
641    file: Option<&str>,
642    max_depth: usize,
643    limit: usize,
644) -> Result<BlastRadiusResult, OpError> {
645    if let Some(selector @ SymbolSelector::Id(_)) = selector.as_ref() {
646        let db_path = db_path.ok_or(OpError::SymbolIdRequiresDatabasePath)?;
647        let selected = resolve_symbol_op(workspace, db_path, conn, selector, file)?;
648        let res = queries::compute_blast_radius_scoped_with_ids(
649            conn,
650            &[],
651            &[&selected.symbol_id],
652            None,
653            &[],
654            if max_depth == 0 { 2 } else { max_depth.min(5) },
655            limit,
656        )?;
657        return with_calling_tests(workspace, conn, Some(selected), res, limit);
658    }
659    let symbol = selector.as_ref().and_then(|selector| match selector {
660        SymbolSelector::Name(name) => Some(name.as_str()),
661        SymbolSelector::Id(_) => None,
662    });
663    let clean_symbol = symbol.and_then(|s| {
664        let t = s.trim();
665        if t.is_empty() { None } else { Some(t) }
666    });
667    let clean_file = file.and_then(|f| {
668        let t = f.trim();
669        if t.is_empty() {
670            None
671        } else {
672            Some(workspace.relativize_filter(t))
673        }
674    });
675
676    let mut discovered = Vec::new();
677    let (seed_symbols, symbol_path_filter, seed_paths) = match (clean_symbol, clean_file) {
678        (Some(s), Some(f)) => (vec![s], Some(f), vec![]),
679        (Some(s), None) => (vec![s], None, vec![]),
680        (None, Some(f)) => (vec![], None, vec![f]),
681        (None, None) => {
682            // Zero arguments: discover uncommitted working tree changes via git status
683            let git_status = std::process::Command::new("git")
684                .args(["status", "--porcelain"])
685                .current_dir(&workspace.root)
686                .output();
687
688            if let Ok(output) = git_status
689                && output.status.success()
690            {
691                let stdout = String::from_utf8_lossy(&output.stdout);
692                for line in stdout.lines() {
693                    if line.len() > 3 {
694                        let path_part = line.get(3..).unwrap_or("").trim();
695                        let target = if let Some((_, to)) = path_part.split_once("->") {
696                            to.trim()
697                        } else {
698                            path_part
699                        };
700                        let p = target.trim_matches('"');
701                        let p_fwd = p.replace('\\', "/");
702                        if !p_fwd.is_empty() && !crate::workspace::is_hard_excluded(&p_fwd) {
703                            discovered.push(p_fwd);
704                        }
705                    }
706                }
707            }
708            (vec![], None, discovered)
709        }
710    };
711
712    let depth = if max_depth == 0 { 2 } else { max_depth.min(5) };
713    let seed_paths_refs: Vec<&str> = seed_paths.iter().map(|s| s.as_str()).collect();
714    let res = queries::compute_blast_radius_scoped(
715        conn,
716        &seed_symbols,
717        symbol_path_filter.as_deref(),
718        &seed_paths_refs,
719        depth,
720        limit,
721    )?;
722    let target = match seed_symbols.as_slice() {
723        [name] => select_reference_target(conn, name, symbol_path_filter.as_deref()).ok(),
724        _ => None,
725    };
726    with_calling_tests(workspace, conn, target, res, limit)
727}
728
729/// The tests of the callers report of `target` when the walk found none, or `target` itself when
730/// it is a test: the walk follows resolved and pending rows by name, and the report also proves a
731/// call through a receiver's type.
732fn with_calling_tests(
733    workspace: &Workspace,
734    conn: &Connection,
735    target: Option<Symbol>,
736    mut res: BlastRadiusResult,
737    limit: usize,
738) -> Result<BlastRadiusResult, OpError> {
739    let Some(target) = target else {
740        return Ok(res);
741    };
742    if !res.likely_tests.is_empty() || res.likely_tests_truncated || res.traversal_ceiling_reached {
743        return Ok(res);
744    }
745    // The walk's answer stands alone; an index too old for the report keeps it.
746    let Ok(report) = reference_report_for_edit(conn, &workspace.root, &target) else {
747        return Ok(res);
748    };
749    res.likely_tests = report
750        .tests
751        .into_iter()
752        .map(|test| TestTarget {
753            reason: if test.name_only {
754                "calls it by name only (find_references)"
755            } else {
756                "calls it (find_references)"
757            }
758            .to_string(),
759            name: test.name,
760            path: test.path,
761            line: test.line,
762        })
763        .collect();
764    if res.likely_tests.is_empty() && (target.is_test || target.test_container) {
765        res.likely_tests.push(TestTarget {
766            name: target.name,
767            path: target.path.replace('\\', "/"),
768            line: target.start_line,
769            reason: "the target is a test".to_string(),
770        });
771    }
772    res.likely_tests_found = res.likely_tests.len();
773    res.likely_tests_truncated = res.likely_tests.len() > limit;
774    res.likely_tests.truncate(limit);
775    Ok(res)
776}
777
778#[cfg(test)]
779mod tests {
780    use std::fs;
781
782    use rusqlite::Connection;
783
784    use super::codebase_outline_op;
785    use crate::workspace::Workspace;
786
787    #[test]
788    fn codebase_outline_accepts_absolute_workspace_root_filter() {
789        let temp = crate::safe_tempdir();
790        fs::write(temp.path().join("root.rs"), "pub fn root() {}\n").unwrap();
791        let workspace = Workspace::new(temp.path().to_path_buf());
792        let conn = Connection::open(temp.path().join("index.db")).unwrap();
793        conn.execute_batch(
794            "CREATE TABLE files (
795                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
796                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
797            );
798            CREATE TABLE symbols (
799                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
800                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
801                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
802                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
803                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
804                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
805                semantic_group TEXT, is_test INTEGER, test_container INTEGER
806            );
807            INSERT INTO files VALUES ('f', 'root.rs', 'rust', 'hash', 17, 1, 'now');
808            INSERT INTO symbols VALUES (
809                's', 'f', 'root.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
810                'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
811            );",
812        )
813        .unwrap();
814
815        let outline =
816            codebase_outline_op(&workspace, &conn, 1, Some(temp.path().to_str().unwrap())).unwrap();
817
818        assert!(outline.contains("root.rs"));
819
820        fs::create_dir(temp.path().join("src")).unwrap();
821        fs::write(temp.path().join("src/lib.rs"), "pub fn nested() {}\n").unwrap();
822        conn.execute_batch(
823            "INSERT INTO files VALUES ('nested-file', 'src/lib.rs', 'rust', 'hash', 19, 1, 'now');
824             INSERT INTO symbols VALUES (
825                'nested-symbol', 'nested-file', 'src/lib.rs', 'rust', 'nested', 'function',
826                'pub fn nested()', NULL, 'pub', NULL, 1, 0, 1, 18, 0, 18, 1, 0, 1, 18, 0, 18,
827                NULL, NULL, 0, 0
828             );",
829        )
830        .unwrap();
831
832        assert!(
833            codebase_outline_op(&workspace, &conn, 2, Some("."))
834                .unwrap()
835                .contains("root.rs")
836        );
837        assert!(
838            codebase_outline_op(&workspace, &conn, 1, Some("src"))
839                .unwrap()
840                .contains("lib.rs")
841        );
842        assert!(
843            codebase_outline_op(
844                &workspace,
845                &conn,
846                1,
847                Some(temp.path().join("src").to_str().unwrap()),
848            )
849            .unwrap()
850            .contains("lib.rs")
851        );
852        assert!(
853            codebase_outline_op(
854                &workspace,
855                &conn,
856                1,
857                Some(temp.path().parent().unwrap().to_str().unwrap()),
858            )
859            .is_err()
860        );
861    }
862
863    #[test]
864    fn codebase_outline_counts_unsupported_files() {
865        let temp = crate::safe_tempdir();
866        let workspace = Workspace::new(temp.path().to_path_buf());
867        let conn = Connection::open(temp.path().join("index.db")).unwrap();
868        conn.execute_batch(
869            "CREATE TABLE files (
870                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
871                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
872            );
873            CREATE TABLE symbols (
874                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
875                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
876                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
877                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
878                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
879                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
880                semantic_group TEXT, is_test INTEGER, test_container INTEGER
881            );
882            INSERT INTO files VALUES ('f1', 'src/lib.rs', 'rust', 'h', 0, 1, 'now', 'indexed');
883            INSERT INTO files VALUES ('f2', 'src/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
884            INSERT INTO files VALUES ('f3', 'docs/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
885            INSERT INTO files VALUES ('f4', 'src/assets/a.png', 'unknown', 'h', 0, 1, 'now', 'unsupported');
886            INSERT INTO symbols VALUES (
887                's', 'f1', 'src/lib.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
888                'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
889            );",
890        )
891        .unwrap();
892
893        let all = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
894        assert!(all.contains("3 unsupported files"));
895        assert!(all.contains("docs/ (1 unsupported file)"), "{all}");
896        assert!(all.contains("src/ (2 unsupported files)"), "{all}");
897        assert!(!all.contains("assets/"), "{all}");
898
899        let scoped = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
900        assert!(scoped.contains("2 unsupported files:"), "{scoped}");
901    }
902
903    #[test]
904    fn codebase_outline_names_plain_files_and_shows_an_overload_once() {
905        let temp = crate::safe_tempdir();
906        let workspace = Workspace::new(temp.path().to_path_buf());
907        let conn = Connection::open(temp.path().join("index.db")).unwrap();
908        conn.execute_batch(
909            "CREATE TABLE files (
910                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
911                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
912            );
913            CREATE TABLE symbols (
914                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
915                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
916                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
917                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
918                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
919                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
920                semantic_group TEXT, is_test INTEGER, test_container INTEGER
921            );
922            INSERT INTO files VALUES ('f1', 'src/helpers.py', 'python', 'h', 0, 9, 'now', 'indexed');
923            INSERT INTO files VALUES ('f2', 'src/signals.py', 'python', 'h', 0, 3, 'now', 'indexed');
924            INSERT INTO files VALUES ('f3', 'src/py.typed', 'unknown', 'h', 0, 0, 'now', 'unsupported');
925            INSERT INTO symbols VALUES (
926                'a', 'f1', 'src/helpers.py', 'python', 'stream', 'function', 'def stream()', NULL,
927                NULL, NULL, 1, 0, 2, 0, 0, 10, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
928            );
929            INSERT INTO symbols VALUES (
930                'b', 'f1', 'src/helpers.py', 'python', 'stream', 'function', 'def stream(x)', NULL,
931                NULL, NULL, 4, 0, 5, 0, 11, 20, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
932            );
933            INSERT INTO symbols VALUES (
934                'c', 'f2', 'src/signals.py', 'python', 'started', 'variable', 'started = Signal()',
935                NULL, NULL, NULL, 1, 0, 1, 0, 0, 18, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
936            );",
937        )
938        .unwrap();
939
940        let out = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
941
942        assert!(out.contains("helpers.py [function stream]\n"), "{out}");
943        assert!(
944            out.contains("(1 file without functions or classes: signals.py)"),
945            "{out}"
946        );
947        assert!(!out.contains("py.typed"), "{out}");
948    }
949
950    #[test]
951    fn codebase_outline_lists_namespaced_classes_past_deep_files_and_caps_each_subfolder() {
952        let temp = crate::safe_tempdir();
953        let workspace = Workspace::new(temp.path().to_path_buf());
954        let conn = Connection::open(temp.path().join("index.db")).unwrap();
955        conn.execute_batch(
956            "CREATE TABLE files (
957                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
958                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
959            );
960            CREATE TABLE symbols (
961                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
962                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
963                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
964                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
965                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
966                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
967                semantic_group TEXT, is_test INTEGER, test_container INTEGER
968            );
969            INSERT INTO files VALUES ('z', 'z/Late.cs', 'csharp', 'h', 0, 9, 'now', 'indexed');
970            INSERT INTO symbols VALUES (
971                'ns', 'z', 'z/Late.cs', 'csharp', 'App', 'namespace', 'namespace App', NULL,
972                NULL, NULL, 1, 0, 9, 0, 0, 90, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
973            );
974            INSERT INTO symbols VALUES (
975                'late', 'z', 'z/Late.cs', 'csharp', 'Late', 'class', 'class Late', NULL,
976                NULL, 'ns', 2, 0, 8, 0, 20, 80, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
977            );",
978        )
979        .unwrap();
980        let tx = conn.unchecked_transaction().unwrap();
981        for i in 0..1005 {
982            tx.execute(
983                "INSERT INTO files VALUES (?1, ?2, 'rust', 'h', 0, 1, 'now', 'indexed')",
984                rusqlite::params![format!("d{i}"), format!("a/deep/x/file_{i}.rs")],
985            )
986            .unwrap();
987        }
988        for i in 10..55 {
989            let path = format!("m/mod_{i}.rs");
990            tx.execute(
991                "INSERT INTO files VALUES (?1, ?2, 'rust', 'h', 0, 1, 'now', 'indexed')",
992                rusqlite::params![format!("m{i}"), path],
993            )
994            .unwrap();
995            tx.execute(
996                "INSERT INTO symbols VALUES (?1, ?2, ?3, 'rust', ?4, 'function', 'fn f()', NULL,
997                 NULL, NULL, 1, 0, 1, 0, 0, 9, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0)",
998                rusqlite::params![format!("s{i}"), format!("m{i}"), path, format!("f{i}")],
999            )
1000            .unwrap();
1001        }
1002        tx.commit().unwrap();
1003
1004        let out = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
1005
1006        assert!(out.contains("deep/ (1005 indexed files)"), "{out}");
1007        assert!(out.contains("Late.cs [class Late]"), "{out}");
1008        assert!(out.contains("mod_49.rs [function f49]"), "{out}");
1009        assert!(!out.contains("mod_50.rs"), "{out}");
1010        assert!(
1011            out.contains("(+5 more files with functions or classes; pass this folder as the path"),
1012            "{out}"
1013        );
1014        assert!(!out.contains("truncated"), "{out}");
1015
1016        let scoped = codebase_outline_op(&workspace, &conn, 2, Some("m")).unwrap();
1017        assert!(scoped.contains("mod_54.rs [function f54]"), "{scoped}");
1018    }
1019
1020    #[test]
1021    fn codebase_outline_truncates_over_1000_files() {
1022        let temp = crate::safe_tempdir();
1023        let workspace = Workspace::new(temp.path().to_path_buf());
1024        let conn = Connection::open(temp.path().join("index.db")).unwrap();
1025        conn.execute_batch(
1026            "CREATE TABLE files (
1027                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
1028                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
1029            );
1030            CREATE TABLE symbols (
1031                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
1032                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
1033                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
1034                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
1035                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
1036                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
1037                semantic_group TEXT, is_test INTEGER, test_container INTEGER
1038            );",
1039        )
1040        .unwrap();
1041
1042        let tx = conn.unchecked_transaction().unwrap();
1043        for i in 1..=1005 {
1044            tx.execute(
1045                "INSERT INTO files VALUES (?1, ?2, 'rust', 'hash', 10, 1, 'now')",
1046                rusqlite::params![format!("f{i}"), format!("src/file_{i}.rs")],
1047            )
1048            .unwrap();
1049        }
1050        tx.commit().unwrap();
1051
1052        let outline = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
1053        assert!(outline.contains("[Outline truncated: workspace contains over 1,000 files."));
1054
1055        let scoped_outline = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
1056        assert!(scoped_outline.contains("[Outline truncated: path matches over 1,000 files."));
1057    }
1058}