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 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
267pub const MAX_CONTEXT_OVERLOADS: usize = 4;
269
270pub 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
296pub 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
333fn 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
366fn 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
385pub 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
435pub 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
467pub 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
549pub 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
572pub 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
583pub 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
605pub 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
616pub 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 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
729fn 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 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}