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 mut symbol = resolve_symbol_op(workspace, db_path, conn, selector, file_path)?;
259    if let Some((start_byte, start_line)) = decorated_start(conn, &symbol) {
260        symbol.start_byte = start_byte;
261        symbol.start_line = start_line;
262    }
263
264    let abs_file = workspace.canonical_root.join(&symbol.path);
265    let source = slicer::slice_symbol_source(&abs_file, &symbol, symbol.start_byte)?;
266
267    Ok((symbol, source))
268}
269
270/// A context read builds callees, types, and tests for each overload, so it stops at this many.
271pub const MAX_CONTEXT_OVERLOADS: usize = 4;
272
273/// Like [`get_symbol_body_selected_op`], but a name that selects several overloads of one owner
274/// in one file returns the body of each overload instead of an ambiguity error.
275pub fn get_symbol_bodies_selected_op(
276    workspace: &Workspace,
277    db_path: &Path,
278    conn: &Connection,
279    selector: &SymbolSelector,
280    file_path: Option<&str>,
281) -> Result<Vec<(Symbol, String)>, OpError> {
282    match get_symbol_body_selected_op(workspace, db_path, conn, selector, file_path) {
283        Err(ambiguity @ OpError::Query(QueryError::AmbiguousSymbol(..))) => overload_selectors(
284            workspace,
285            db_path,
286            conn,
287            selector,
288            file_path,
289            ambiguity,
290            usize::MAX,
291        )?
292        .iter()
293        .map(|overload| get_symbol_body_selected_op(workspace, db_path, conn, overload, None))
294        .collect(),
295        result => result.map(|body| vec![body]),
296    }
297}
298
299/// Like [`get_context_slice_selected_op`], but a name that selects up to
300/// [`MAX_CONTEXT_OVERLOADS`] overloads of one owner in one file returns a slice for each overload.
301pub fn get_context_slices_selected_op(
302    workspace: &Workspace,
303    db_path: &Path,
304    conn: &Connection,
305    selector: &SymbolSelector,
306    file_path: Option<&str>,
307    include_external: bool,
308) -> Result<Vec<ContextSlice>, OpError> {
309    let slice = |selector: &SymbolSelector, file_path: Option<&str>| {
310        get_context_slice_selected_op(
311            workspace,
312            db_path,
313            conn,
314            selector,
315            file_path,
316            include_external,
317        )
318    };
319    match slice(selector, file_path) {
320        Err(ambiguity @ OpError::Query(QueryError::AmbiguousSymbol(..))) => overload_selectors(
321            workspace,
322            db_path,
323            conn,
324            selector,
325            file_path,
326            ambiguity,
327            MAX_CONTEXT_OVERLOADS,
328        )?
329        .iter()
330        .map(|overload| slice(overload, None))
331        .collect(),
332        result => result.map(|slice| vec![slice]),
333    }
334}
335
336/// The current ids of the overloads a name selects, after their file is refreshed. Returns
337/// `ambiguity` for an id selector, for more than `max` overloads, or when the refresh left none.
338fn overload_selectors(
339    workspace: &Workspace,
340    db_path: &Path,
341    conn: &Connection,
342    selector: &SymbolSelector,
343    file_path: Option<&str>,
344    ambiguity: OpError,
345    max: usize,
346) -> Result<Vec<SymbolSelector>, OpError> {
347    let SymbolSelector::Name(name) = selector else {
348        return Err(ambiguity);
349    };
350    let scope = file_path
351        .map(|path| workspace.resolve_path(Path::new(path)))
352        .transpose()?
353        .map(|(_, rel)| rel);
354    let mut overloads = queries::get_symbol_overloads(conn, name, scope.as_deref())?;
355    if let Some(path) = overloads.first().map(|symbol| symbol.path.clone())
356        && sync::ensure_fresh_file(workspace, db_path, conn, &path)?
357    {
358        overloads = queries::get_symbol_overloads(conn, name, scope.as_deref())?;
359    }
360    if overloads.is_empty() || overloads.len() > max {
361        return Err(ambiguity);
362    }
363    Ok(overloads
364        .into_iter()
365        .map(|symbol| SymbolSelector::Id(symbol.symbol_id))
366        .collect())
367}
368
369/// The byte and line where the decorators above a Python symbol begin: julie starts the symbol at
370/// its `def` or `class` line and records the decorated block as a `decorated_definition` fact.
371fn decorated_start(conn: &Connection, symbol: &Symbol) -> Option<(usize, usize)> {
372    conn.query_row(
373        "SELECT start_byte, start_line FROM structural_facts
374         WHERE path = ?1 AND pattern_id LIKE '%.decorated_definition.%'
375           AND end_byte = ?2 AND start_byte < ?3
376         ORDER BY start_byte DESC LIMIT 1",
377        rusqlite::params![
378            symbol.path,
379            symbol.end_byte as i64,
380            symbol.start_byte as i64
381        ],
382        |row| {
383            Ok((
384                row.get::<_, i64>(0)? as usize,
385                row.get::<_, i64>(1)? as usize,
386            ))
387        },
388    )
389    .ok()
390}
391
392/// Retrieve a complete context slice for a symbol, guaranteeing fresh offsets.
393pub fn get_context_slice_op(
394    workspace: &Workspace,
395    db_path: &Path,
396    conn: &Connection,
397    symbol_name: &str,
398    file_path: Option<&str>,
399    include_external: bool,
400) -> Result<ContextSlice, OpError> {
401    get_context_slice_selected_op(
402        workspace,
403        db_path,
404        conn,
405        &SymbolSelector::Name(symbol_name.to_string()),
406        file_path,
407        include_external,
408    )
409}
410
411pub fn get_context_slice_selected_op(
412    workspace: &Workspace,
413    db_path: &Path,
414    conn: &Connection,
415    selector: &SymbolSelector,
416    file_path: Option<&str>,
417    include_external: bool,
418) -> Result<ContextSlice, OpError> {
419    let (target_symbol, target_body) =
420        get_symbol_body_selected_op(workspace, db_path, conn, selector, file_path)?;
421
422    let callee_signatures = queries::find_callee_signatures(
423        conn,
424        &target_symbol.name,
425        &target_symbol.symbol_id,
426        10,
427        include_external,
428    )?;
429
430    let related_types = queries::find_related_types(conn, &target_symbol, 10)?;
431    let related_tests = queries::find_related_tests(conn, &target_symbol, 5)?;
432
433    Ok(ContextSlice {
434        target_symbol,
435        target_body,
436        callee_signatures,
437        related_types,
438        related_tests,
439    })
440}
441
442/// Generate file skeleton with fresh file synchronization.
443pub fn file_skeleton_op(
444    workspace: &Workspace,
445    db_path: &Path,
446    conn: &Connection,
447    file_path: &str,
448) -> Result<String, OpError> {
449    let (effective_abs, rel_path) = workspace.resolve_path(Path::new(file_path))?;
450    if !effective_abs.exists() {
451        return Err(file_not_found(conn, &rel_path));
452    }
453    if effective_abs.is_dir() {
454        return codebase_outline_op(workspace, conn, 2, Some(&rel_path));
455    }
456    sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
457
458    let mut symbols = queries::load_file_symbols(conn, &rel_path)?;
459    let inherited =
460        queries::inherited_writes_among(conn, symbols.iter().map(|s| s.symbol_id.as_str()))?;
461    symbols.retain(|symbol| !inherited.contains(&symbol.symbol_id));
462    let file_meta = queries::get_file(conn, &rel_path)?;
463    let line_count = file_meta.and_then(|m| m.line_count.map(|l| l as usize));
464    let parse_errors = queries::count_parse_diagnostics(conn, &rel_path);
465
466    Ok(format_file_skeleton(
467        &rel_path,
468        &symbols,
469        line_count,
470        parse_errors,
471    ))
472}
473
474/// Generate a memory-bounded codebase outline pushed down into SQLite.
475pub fn codebase_outline_op(
476    workspace: &Workspace,
477    conn: &Connection,
478    depth: usize,
479    path_filter: Option<&str>,
480) -> Result<String, OpError> {
481    let rel_filter = path_filter.map(|p| workspace.relativize_filter(p));
482    let path_filter = rel_filter.as_deref().filter(|path| !path.is_empty());
483    let norm = path_filter
484        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
485        .filter(|p| !p.is_empty());
486    let scope = queries::OutlineScope::new(path_filter, depth);
487    let symbols_by_file = queries::load_scoped_outline_symbols(conn, path_filter, depth, 5)?;
488    let counts = queries::load_outline_counts(conn, path_filter, depth)?;
489
490    let mut root_node = OutlineNode::default();
491    let norm_filter = norm.as_deref().unwrap_or_default();
492    let mut files_found = 0;
493    let mut listed = 0;
494    let mut truncated = false;
495    queries::for_each_outline_path(conn, &scope, |file_path| {
496        files_found += 1;
497        if scope.lists(&file_path) {
498            listed += 1;
499            if listed > queries::OUTLINE_FILE_CAP {
500                truncated = true;
501                return false;
502            }
503        }
504        add_path_to_outline(
505            &mut root_node,
506            &file_path,
507            &symbols_by_file,
508            &counts,
509            depth,
510            norm_filter,
511        );
512        true
513    })?;
514
515    let mut unsupported = 0;
516    queries::for_each_unsupported_path(conn, &scope, |file_path| {
517        unsupported += 1;
518        add_unsupported_to_outline(&mut root_node, file_path, depth, norm_filter);
519    })?;
520    if let Some(filter) = path_filter
521        && files_found == 0
522        && unsupported == 0
523    {
524        return Err(file_not_found(conn, filter));
525    }
526
527    let display_root = if norm_filter.is_empty() {
528        format!("{}/", workspace.repo_name)
529    } else {
530        format!("{}/{}/", workspace.repo_name, norm_filter)
531    };
532
533    let mut out = String::new();
534    out.push_str(&format!("{display_root}\n"));
535    render_outline_tree(&mut out, &root_node, "", 0, depth);
536
537    if truncated {
538        let msg = if path_filter.is_some() {
539            "\n[Outline truncated: path matches over 1,000 files. Narrow your path filter or specify a deeper path to reduce scope.]\n"
540        } else {
541            "\n[Outline truncated: workspace contains over 1,000 files. Use a path filter (e.g. `code-kb outline <path>`) to narrow scope.]\n"
542        };
543        out.push_str(msg);
544    }
545
546    if unsupported > 0 {
547        let noun = if unsupported == 1 { "file" } else { "files" };
548        out.push_str(&format!(
549            "\n[{unsupported} unsupported {noun}: no extractor for the language]\n"
550        ));
551    }
552
553    Ok(out)
554}
555
556/// Checks the working tree against `base`, else the session base of the project, else `HEAD`:
557/// the symbols whose shape changed and the sites of each that the diff did not reach.
558pub fn edit_check_op(
559    workspace: &Workspace,
560    db_path: &Path,
561    conn: &Connection,
562    base: Option<&str>,
563) -> Result<EditCheck, OpError> {
564    match base.map(str::trim).filter(|rev| !rev.is_empty()) {
565        Some(rev) => edit_check_with(
566            workspace,
567            db_path,
568            conn,
569            BaseRequest::Given(rev),
570            EditLimits::default(),
571        ),
572        None => {
573            let session_base = edit_check::latest_session_base(&workspace.canonical_root);
574            edit_check_session_op(workspace, db_path, conn, session_base.as_deref())
575        }
576    }
577}
578
579/// Checks the working tree against the base a session recorded, else `HEAD`. A recorded base
580/// that git no longer has counts as none.
581pub fn edit_check_session_op(
582    workspace: &Workspace,
583    db_path: &Path,
584    conn: &Connection,
585    session_base: Option<&str>,
586) -> Result<EditCheck, OpError> {
587    Ok(edit_check_session_diff(workspace, conn, session_base)?.run(workspace, db_path, conn)?)
588}
589
590/// The first half of [`edit_check_session_op`]: the diff against the session base and its hash,
591/// before any scan of the base. [`PendingCheck::run`] finishes the check.
592pub fn edit_check_session_diff(
593    workspace: &Workspace,
594    conn: &Connection,
595    session_base: Option<&str>,
596) -> Result<PendingCheck, OpError> {
597    let limits = EditLimits::default();
598    if let Some(base) = session_base {
599        match edit_check::prepare(workspace, conn, BaseRequest::Session(base), limits) {
600            Err(EditCheckError::UnknownRevision(_)) => {}
601            prepared => return Ok(prepared?),
602        }
603    }
604    Ok(edit_check::prepare(
605        workspace,
606        conn,
607        BaseRequest::Head,
608        limits,
609    )?)
610}
611
612/// Like [`edit_check_op`], with the base named by its source and the cuts given.
613pub fn edit_check_with(
614    workspace: &Workspace,
615    db_path: &Path,
616    conn: &Connection,
617    request: BaseRequest<'_>,
618    limits: EditLimits,
619) -> Result<EditCheck, OpError> {
620    Ok(edit_check::run(workspace, db_path, conn, request, limits)?)
621}
622
623/// Compute blast radius and likely tests for a symbol, file, or uncommitted git changes.
624pub fn blast_radius_op(
625    workspace: &Workspace,
626    conn: &Connection,
627    symbol: Option<&str>,
628    file: Option<&str>,
629    max_depth: usize,
630    limit: usize,
631) -> Result<BlastRadiusResult, OpError> {
632    blast_radius_selected_op(
633        workspace,
634        None,
635        conn,
636        symbol.map(|s| SymbolSelector::Name(s.to_string())),
637        file,
638        max_depth,
639        limit,
640    )
641}
642
643pub fn blast_radius_selected_op(
644    workspace: &Workspace,
645    db_path: Option<&Path>,
646    conn: &Connection,
647    selector: Option<SymbolSelector>,
648    file: Option<&str>,
649    max_depth: usize,
650    limit: usize,
651) -> Result<BlastRadiusResult, OpError> {
652    if let Some(selector @ SymbolSelector::Id(_)) = selector.as_ref() {
653        let db_path = db_path.ok_or(OpError::SymbolIdRequiresDatabasePath)?;
654        let selected = resolve_symbol_op(workspace, db_path, conn, selector, file)?;
655        let res = queries::compute_blast_radius_scoped_with_ids(
656            conn,
657            &[],
658            &[&selected.symbol_id],
659            None,
660            &[],
661            if max_depth == 0 { 2 } else { max_depth.min(5) },
662            limit,
663        )?;
664        return with_calling_tests(workspace, conn, Some(selected), res, limit);
665    }
666    let symbol = selector.as_ref().and_then(|selector| match selector {
667        SymbolSelector::Name(name) => Some(name.as_str()),
668        SymbolSelector::Id(_) => None,
669    });
670    let clean_symbol = symbol.and_then(|s| {
671        let t = s.trim();
672        if t.is_empty() { None } else { Some(t) }
673    });
674    let clean_file = file.and_then(|f| {
675        let t = f.trim();
676        if t.is_empty() {
677            None
678        } else {
679            Some(workspace.relativize_filter(t))
680        }
681    });
682
683    let mut discovered = Vec::new();
684    let (seed_symbols, symbol_path_filter, seed_paths) = match (clean_symbol, clean_file) {
685        (Some(s), Some(f)) => (vec![s], Some(f), vec![]),
686        (Some(s), None) => (vec![s], None, vec![]),
687        (None, Some(f)) => (vec![], None, vec![f]),
688        (None, None) => {
689            // Zero arguments: discover uncommitted working tree changes via git status
690            let git_status = std::process::Command::new("git")
691                .args(["status", "--porcelain"])
692                .current_dir(&workspace.root)
693                .output();
694
695            if let Ok(output) = git_status
696                && output.status.success()
697            {
698                let stdout = String::from_utf8_lossy(&output.stdout);
699                for line in stdout.lines() {
700                    if line.len() > 3 {
701                        let path_part = line.get(3..).unwrap_or("").trim();
702                        let target = if let Some((_, to)) = path_part.split_once("->") {
703                            to.trim()
704                        } else {
705                            path_part
706                        };
707                        let p = target.trim_matches('"');
708                        let p_fwd = p.replace('\\', "/");
709                        if !p_fwd.is_empty() && !crate::workspace::is_hard_excluded(&p_fwd) {
710                            discovered.push(p_fwd);
711                        }
712                    }
713                }
714            }
715            (vec![], None, discovered)
716        }
717    };
718
719    let depth = if max_depth == 0 { 2 } else { max_depth.min(5) };
720    let seed_paths_refs: Vec<&str> = seed_paths.iter().map(|s| s.as_str()).collect();
721    let res = queries::compute_blast_radius_scoped(
722        conn,
723        &seed_symbols,
724        symbol_path_filter.as_deref(),
725        &seed_paths_refs,
726        depth,
727        limit,
728    )?;
729    let target = match seed_symbols.as_slice() {
730        [name] => select_reference_target(conn, name, symbol_path_filter.as_deref()).ok(),
731        _ => None,
732    };
733    with_calling_tests(workspace, conn, target, res, limit)
734}
735
736/// The tests of the callers report of `target` when the walk found none, or `target` itself when
737/// it is a test: the walk follows resolved and pending rows by name, and the report also proves a
738/// call through a receiver's type.
739fn with_calling_tests(
740    workspace: &Workspace,
741    conn: &Connection,
742    target: Option<Symbol>,
743    mut res: BlastRadiusResult,
744    limit: usize,
745) -> Result<BlastRadiusResult, OpError> {
746    let Some(target) = target else {
747        return Ok(res);
748    };
749    if !res.likely_tests.is_empty() || res.likely_tests_truncated || res.traversal_ceiling_reached {
750        return Ok(res);
751    }
752    // The walk's answer stands alone; an index too old for the report keeps it.
753    let Ok(report) = reference_report_for_edit(conn, &workspace.root, &target, None) else {
754        return Ok(res);
755    };
756    res.likely_tests = report
757        .tests
758        .into_iter()
759        .map(|test| TestTarget {
760            reason: if test.name_only {
761                "calls it by name only (find_references)"
762            } else {
763                "calls it (find_references)"
764            }
765            .to_string(),
766            name: test.name,
767            path: test.path,
768            line: test.line,
769        })
770        .collect();
771    if res.likely_tests.is_empty() && (target.is_test || target.test_container) {
772        res.likely_tests.push(TestTarget {
773            name: target.name,
774            path: target.path.replace('\\', "/"),
775            line: target.start_line,
776            reason: "the target is a test".to_string(),
777        });
778    }
779    res.likely_tests_found = res.likely_tests.len();
780    res.likely_tests_truncated = res.likely_tests.len() > limit;
781    res.likely_tests.truncate(limit);
782    Ok(res)
783}
784
785#[cfg(test)]
786mod tests {
787    use std::fs;
788
789    use rusqlite::Connection;
790
791    use super::codebase_outline_op;
792    use crate::workspace::Workspace;
793
794    #[test]
795    fn codebase_outline_accepts_absolute_workspace_root_filter() {
796        let temp = crate::safe_tempdir();
797        fs::write(temp.path().join("root.rs"), "pub fn root() {}\n").unwrap();
798        let workspace = Workspace::new(temp.path().to_path_buf());
799        let conn = Connection::open(temp.path().join("index.db")).unwrap();
800        conn.execute_batch(
801            "CREATE TABLE files (
802                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
803                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
804            );
805            CREATE TABLE symbols (
806                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
807                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
808                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
809                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
810                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
811                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
812                semantic_group TEXT, is_test INTEGER, test_container INTEGER
813            );
814            INSERT INTO files VALUES ('f', 'root.rs', 'rust', 'hash', 17, 1, 'now');
815            INSERT INTO symbols VALUES (
816                's', 'f', 'root.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
817                'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
818            );",
819        )
820        .unwrap();
821
822        let outline =
823            codebase_outline_op(&workspace, &conn, 1, Some(temp.path().to_str().unwrap())).unwrap();
824
825        assert!(outline.contains("root.rs"));
826
827        fs::create_dir(temp.path().join("src")).unwrap();
828        fs::write(temp.path().join("src/lib.rs"), "pub fn nested() {}\n").unwrap();
829        conn.execute_batch(
830            "INSERT INTO files VALUES ('nested-file', 'src/lib.rs', 'rust', 'hash', 19, 1, 'now');
831             INSERT INTO symbols VALUES (
832                'nested-symbol', 'nested-file', 'src/lib.rs', 'rust', 'nested', 'function',
833                'pub fn nested()', NULL, 'pub', NULL, 1, 0, 1, 18, 0, 18, 1, 0, 1, 18, 0, 18,
834                NULL, NULL, 0, 0
835             );",
836        )
837        .unwrap();
838
839        assert!(
840            codebase_outline_op(&workspace, &conn, 2, Some("."))
841                .unwrap()
842                .contains("root.rs")
843        );
844        assert!(
845            codebase_outline_op(&workspace, &conn, 1, Some("src"))
846                .unwrap()
847                .contains("lib.rs")
848        );
849        assert!(
850            codebase_outline_op(
851                &workspace,
852                &conn,
853                1,
854                Some(temp.path().join("src").to_str().unwrap()),
855            )
856            .unwrap()
857            .contains("lib.rs")
858        );
859        assert!(
860            codebase_outline_op(
861                &workspace,
862                &conn,
863                1,
864                Some(temp.path().parent().unwrap().to_str().unwrap()),
865            )
866            .is_err()
867        );
868    }
869
870    #[test]
871    fn codebase_outline_counts_unsupported_files() {
872        let temp = crate::safe_tempdir();
873        let workspace = Workspace::new(temp.path().to_path_buf());
874        let conn = Connection::open(temp.path().join("index.db")).unwrap();
875        conn.execute_batch(
876            "CREATE TABLE files (
877                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
878                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
879            );
880            CREATE TABLE symbols (
881                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
882                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
883                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
884                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
885                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
886                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
887                semantic_group TEXT, is_test INTEGER, test_container INTEGER
888            );
889            INSERT INTO files VALUES ('f1', 'src/lib.rs', 'rust', 'h', 0, 1, 'now', 'indexed');
890            INSERT INTO files VALUES ('f2', 'src/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
891            INSERT INTO files VALUES ('f3', 'docs/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
892            INSERT INTO files VALUES ('f4', 'src/assets/a.png', 'unknown', 'h', 0, 1, 'now', 'unsupported');
893            INSERT INTO symbols VALUES (
894                's', 'f1', 'src/lib.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
895                'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
896            );",
897        )
898        .unwrap();
899
900        let all = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
901        assert!(all.contains("3 unsupported files"));
902        assert!(all.contains("docs/ (1 unsupported file)"), "{all}");
903        assert!(all.contains("src/ (2 unsupported files)"), "{all}");
904        assert!(!all.contains("assets/"), "{all}");
905
906        let scoped = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
907        assert!(scoped.contains("2 unsupported files:"), "{scoped}");
908    }
909
910    #[test]
911    fn codebase_outline_names_plain_files_and_shows_an_overload_once() {
912        let temp = crate::safe_tempdir();
913        let workspace = Workspace::new(temp.path().to_path_buf());
914        let conn = Connection::open(temp.path().join("index.db")).unwrap();
915        conn.execute_batch(
916            "CREATE TABLE files (
917                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
918                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
919            );
920            CREATE TABLE symbols (
921                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
922                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
923                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
924                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
925                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
926                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
927                semantic_group TEXT, is_test INTEGER, test_container INTEGER
928            );
929            INSERT INTO files VALUES ('f1', 'src/helpers.py', 'python', 'h', 0, 9, 'now', 'indexed');
930            INSERT INTO files VALUES ('f2', 'src/signals.py', 'python', 'h', 0, 3, 'now', 'indexed');
931            INSERT INTO files VALUES ('f3', 'src/py.typed', 'unknown', 'h', 0, 0, 'now', 'unsupported');
932            INSERT INTO symbols VALUES (
933                'a', 'f1', 'src/helpers.py', 'python', 'stream', 'function', 'def stream()', NULL,
934                NULL, NULL, 1, 0, 2, 0, 0, 10, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
935            );
936            INSERT INTO symbols VALUES (
937                'b', 'f1', 'src/helpers.py', 'python', 'stream', 'function', 'def stream(x)', NULL,
938                NULL, NULL, 4, 0, 5, 0, 11, 20, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
939            );
940            INSERT INTO symbols VALUES (
941                'c', 'f2', 'src/signals.py', 'python', 'started', 'variable', 'started = Signal()',
942                NULL, NULL, NULL, 1, 0, 1, 0, 0, 18, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
943            );",
944        )
945        .unwrap();
946
947        let out = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
948
949        assert!(out.contains("helpers.py [function stream]\n"), "{out}");
950        assert!(
951            out.contains("(1 file without functions or classes: signals.py)"),
952            "{out}"
953        );
954        assert!(!out.contains("py.typed"), "{out}");
955    }
956
957    #[test]
958    fn codebase_outline_lists_namespaced_classes_past_deep_files_and_caps_each_subfolder() {
959        let temp = crate::safe_tempdir();
960        let workspace = Workspace::new(temp.path().to_path_buf());
961        let conn = Connection::open(temp.path().join("index.db")).unwrap();
962        conn.execute_batch(
963            "CREATE TABLE files (
964                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
965                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
966            );
967            CREATE TABLE symbols (
968                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
969                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
970                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
971                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
972                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
973                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
974                semantic_group TEXT, is_test INTEGER, test_container INTEGER
975            );
976            INSERT INTO files VALUES ('z', 'z/Late.cs', 'csharp', 'h', 0, 9, 'now', 'indexed');
977            INSERT INTO symbols VALUES (
978                'ns', 'z', 'z/Late.cs', 'csharp', 'App', 'namespace', 'namespace App', NULL,
979                NULL, NULL, 1, 0, 9, 0, 0, 90, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
980            );
981            INSERT INTO symbols VALUES (
982                'late', 'z', 'z/Late.cs', 'csharp', 'Late', 'class', 'class Late', NULL,
983                NULL, 'ns', 2, 0, 8, 0, 20, 80, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
984            );",
985        )
986        .unwrap();
987        let tx = conn.unchecked_transaction().unwrap();
988        for i in 0..1005 {
989            tx.execute(
990                "INSERT INTO files VALUES (?1, ?2, 'rust', 'h', 0, 1, 'now', 'indexed')",
991                rusqlite::params![format!("d{i}"), format!("a/deep/x/file_{i}.rs")],
992            )
993            .unwrap();
994        }
995        for i in 10..55 {
996            let path = format!("m/mod_{i}.rs");
997            tx.execute(
998                "INSERT INTO files VALUES (?1, ?2, 'rust', 'h', 0, 1, 'now', 'indexed')",
999                rusqlite::params![format!("m{i}"), path],
1000            )
1001            .unwrap();
1002            tx.execute(
1003                "INSERT INTO symbols VALUES (?1, ?2, ?3, 'rust', ?4, 'function', 'fn f()', NULL,
1004                 NULL, NULL, 1, 0, 1, 0, 0, 9, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0)",
1005                rusqlite::params![format!("s{i}"), format!("m{i}"), path, format!("f{i}")],
1006            )
1007            .unwrap();
1008        }
1009        tx.commit().unwrap();
1010
1011        let out = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
1012
1013        assert!(out.contains("deep/ (1005 indexed files)"), "{out}");
1014        assert!(out.contains("Late.cs [class Late]"), "{out}");
1015        assert!(out.contains("mod_49.rs [function f49]"), "{out}");
1016        assert!(!out.contains("mod_50.rs"), "{out}");
1017        assert!(
1018            out.contains("(+5 more files with functions or classes; pass this folder as the path"),
1019            "{out}"
1020        );
1021        assert!(!out.contains("truncated"), "{out}");
1022
1023        let scoped = codebase_outline_op(&workspace, &conn, 2, Some("m")).unwrap();
1024        assert!(scoped.contains("mod_54.rs [function f54]"), "{scoped}");
1025    }
1026
1027    #[test]
1028    fn codebase_outline_truncates_over_1000_files() {
1029        let temp = crate::safe_tempdir();
1030        let workspace = Workspace::new(temp.path().to_path_buf());
1031        let conn = Connection::open(temp.path().join("index.db")).unwrap();
1032        conn.execute_batch(
1033            "CREATE TABLE files (
1034                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
1035                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
1036            );
1037            CREATE TABLE symbols (
1038                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
1039                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
1040                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
1041                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
1042                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
1043                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
1044                semantic_group TEXT, is_test INTEGER, test_container INTEGER
1045            );",
1046        )
1047        .unwrap();
1048
1049        let tx = conn.unchecked_transaction().unwrap();
1050        for i in 1..=1005 {
1051            tx.execute(
1052                "INSERT INTO files VALUES (?1, ?2, 'rust', 'hash', 10, 1, 'now')",
1053                rusqlite::params![format!("f{i}"), format!("src/file_{i}.rs")],
1054            )
1055            .unwrap();
1056        }
1057        tx.commit().unwrap();
1058
1059        let outline = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
1060        assert!(outline.contains("[Outline truncated: workspace contains over 1,000 files."));
1061
1062        let scoped_outline = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
1063        assert!(scoped_outline.contains("[Outline truncated: path matches over 1,000 files."));
1064    }
1065}