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#[derive(Debug, Clone, Default, serde::Serialize)]
62pub struct SymbolLookup {
63 pub names: Vec<Symbol>,
65 pub ranked: Vec<SymbolSearchResult>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub miss: Option<String>,
70}
71
72pub 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
112pub 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
234pub 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
270pub const MAX_CONTEXT_OVERLOADS: usize = 4;
272
273pub 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
299pub 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
336fn 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
369fn 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
392pub 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
442pub 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
474pub 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
556pub 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
579pub 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
590pub 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
612pub 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
623pub 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 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
736fn 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 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}