1use rusqlite::Connection;
2use std::path::Path;
3use thiserror::Error;
4
5use crate::formatters::{
6 OutlineNode, add_path_to_outline, add_unsupported_to_outline, format_file_skeleton,
7 render_outline_tree,
8};
9use crate::models::{BlastRadiusResult, ContextSlice, Symbol};
10use crate::queries::{self, QueryError};
11use crate::slicer::{self, SliceError};
12use crate::sync::{self, SyncError};
13use crate::workspace::{Workspace, WorkspaceError, paths_equal};
14
15#[derive(Debug, Clone)]
16pub enum SymbolSelector {
17 Name(String),
18 Id(String),
19}
20
21#[derive(Debug, Error)]
22pub enum OpError {
23 #[error("Symbol '{name}' not found in {workspace}. {hint}")]
24 SymbolNotFound {
25 name: String,
26 workspace: String,
27 hint: String,
28 },
29 #[error("File '{path}' not found in {workspace}. {hint}")]
30 FileNotFound {
31 path: String,
32 workspace: String,
33 hint: String,
34 },
35 #[error(
36 "symbol_id '{id}' is no longer indexed in {workspace}; run lookup_symbol or search_symbols and select a current id"
37 )]
38 StaleSymbolId { id: String, workspace: String },
39 #[error("A database path is required to refresh a symbol_id selector")]
40 SymbolIdRequiresDatabasePath,
41 #[error("Selected symbol is in '{actual}', not requested file '{requested}'")]
42 FileGuardMismatch { requested: String, actual: String },
43 #[error("Workspace error: {0}")]
44 Workspace(#[from] WorkspaceError),
45 #[error("Synchronization error: {0}")]
46 Sync(#[from] SyncError),
47 #[error("Query error: {0}")]
48 Query(#[from] QueryError),
49 #[error("Slice error: {0}")]
50 Slice(#[from] SliceError),
51}
52
53fn stale_symbol_id(conn: &Connection, id: &str) -> OpError {
54 OpError::StaleSymbolId {
55 id: id.to_string(),
56 workspace: queries::workspace_name(conn),
57 }
58}
59
60pub fn resolve_symbol_op(
61 workspace: &Workspace,
62 db_path: &Path,
63 conn: &Connection,
64 selector: &SymbolSelector,
65 file_path: Option<&str>,
66) -> Result<Symbol, OpError> {
67 let id_selector = matches!(selector, SymbolSelector::Id(_));
68 let mut folder = None;
69 let guard = if let Some(file_path) = file_path {
70 let (absolute, rel) = workspace.resolve_path(Path::new(file_path))?;
71 if !absolute.exists() {
72 return Err(file_not_found(conn, &rel));
73 }
74 if absolute.is_dir() {
75 folder = Some(rel);
76 None
77 } else {
78 if !id_selector {
79 sync::ensure_fresh_file(workspace, db_path, conn, &rel)?;
80 }
81 Some((absolute, rel))
82 }
83 } else {
84 None
85 };
86 let scope = guard
87 .as_ref()
88 .map(|(_, rel)| rel.as_str())
89 .or(folder.as_deref());
90 let lookup = |conn: &Connection| match selector {
91 SymbolSelector::Name(name) => queries::get_symbol_by_name(conn, name, scope),
92 SymbolSelector::Id(id) => queries::get_symbol_by_id(conn, id),
93 };
94 let initial = lookup(conn)?.ok_or_else(|| match selector {
95 SymbolSelector::Name(name) => symbol_not_found(conn, name, scope),
96 SymbolSelector::Id(id) => stale_symbol_id(conn, id),
97 })?;
98 if !id_selector
99 && let Some((requested_abs, requested_rel)) = &guard
100 && !paths_equal(requested_abs, &workspace.canonical_root.join(&initial.path))
101 {
102 return Err(OpError::FileGuardMismatch {
103 requested: requested_rel.clone(),
104 actual: initial.path,
105 });
106 }
107 let refreshed = sync::ensure_fresh_file(workspace, db_path, conn, &initial.path)?;
108 let refreshed_symbol = if refreshed {
109 match selector {
110 SymbolSelector::Name(name) => {
111 queries::get_symbol_by_name_exact(conn, name, &initial.path)?
112 }
113 SymbolSelector::Id(id) => queries::get_symbol_by_id(conn, id)?,
114 }
115 .ok_or_else(|| match selector {
116 SymbolSelector::Name(name) => symbol_not_found(conn, name, scope),
117 SymbolSelector::Id(id) => stale_symbol_id(conn, id),
118 })?
119 } else {
120 initial
121 };
122 if let Some((requested_abs, requested_rel)) = &guard
123 && !paths_equal(
124 requested_abs,
125 &workspace.canonical_root.join(&refreshed_symbol.path),
126 )
127 {
128 return Err(OpError::FileGuardMismatch {
129 requested: requested_rel.clone(),
130 actual: refreshed_symbol.path,
131 });
132 }
133 Ok(refreshed_symbol)
134}
135
136fn symbol_not_found(conn: &Connection, name: &str, path_filter: Option<&str>) -> OpError {
137 let (workspace, hint) = queries::symbol_not_found_parts(conn, name, path_filter);
138 OpError::SymbolNotFound {
139 name: name.to_string(),
140 workspace,
141 hint,
142 }
143}
144
145fn file_not_found(conn: &Connection, rel_path: &str) -> OpError {
146 let (workspace, hint) = queries::file_not_found_parts(conn, rel_path);
147 OpError::FileNotFound {
148 path: rel_path.to_string(),
149 workspace,
150 hint,
151 }
152}
153
154pub fn get_symbol_body_op(
156 workspace: &Workspace,
157 db_path: &Path,
158 conn: &Connection,
159 symbol_name: &str,
160 file_path: Option<&str>,
161) -> Result<(Symbol, String), OpError> {
162 get_symbol_body_selected_op(
163 workspace,
164 db_path,
165 conn,
166 &SymbolSelector::Name(symbol_name.to_string()),
167 file_path,
168 )
169}
170
171pub fn get_symbol_body_selected_op(
172 workspace: &Workspace,
173 db_path: &Path,
174 conn: &Connection,
175 selector: &SymbolSelector,
176 file_path: Option<&str>,
177) -> Result<(Symbol, String), OpError> {
178 let symbol = resolve_symbol_op(workspace, db_path, conn, selector, file_path)?;
179
180 let abs_file = workspace.canonical_root.join(&symbol.path);
181 let start = decorated_start(conn, &symbol).unwrap_or(symbol.start_byte);
182 let source = slicer::slice_symbol_source(&abs_file, &symbol, start)?;
183
184 Ok((symbol, source))
185}
186
187pub const MAX_CONTEXT_OVERLOADS: usize = 4;
189
190pub fn get_symbol_bodies_selected_op(
193 workspace: &Workspace,
194 db_path: &Path,
195 conn: &Connection,
196 selector: &SymbolSelector,
197 file_path: Option<&str>,
198) -> Result<Vec<(Symbol, String)>, OpError> {
199 match get_symbol_body_selected_op(workspace, db_path, conn, selector, file_path) {
200 Err(ambiguity @ OpError::Query(QueryError::AmbiguousSymbol(..))) => overload_selectors(
201 workspace,
202 db_path,
203 conn,
204 selector,
205 file_path,
206 ambiguity,
207 usize::MAX,
208 )?
209 .iter()
210 .map(|overload| get_symbol_body_selected_op(workspace, db_path, conn, overload, None))
211 .collect(),
212 result => result.map(|body| vec![body]),
213 }
214}
215
216pub fn get_context_slices_selected_op(
219 workspace: &Workspace,
220 db_path: &Path,
221 conn: &Connection,
222 selector: &SymbolSelector,
223 file_path: Option<&str>,
224 include_external: bool,
225) -> Result<Vec<ContextSlice>, OpError> {
226 let slice = |selector: &SymbolSelector, file_path: Option<&str>| {
227 get_context_slice_selected_op(
228 workspace,
229 db_path,
230 conn,
231 selector,
232 file_path,
233 include_external,
234 )
235 };
236 match slice(selector, file_path) {
237 Err(ambiguity @ OpError::Query(QueryError::AmbiguousSymbol(..))) => overload_selectors(
238 workspace,
239 db_path,
240 conn,
241 selector,
242 file_path,
243 ambiguity,
244 MAX_CONTEXT_OVERLOADS,
245 )?
246 .iter()
247 .map(|overload| slice(overload, None))
248 .collect(),
249 result => result.map(|slice| vec![slice]),
250 }
251}
252
253fn overload_selectors(
256 workspace: &Workspace,
257 db_path: &Path,
258 conn: &Connection,
259 selector: &SymbolSelector,
260 file_path: Option<&str>,
261 ambiguity: OpError,
262 max: usize,
263) -> Result<Vec<SymbolSelector>, OpError> {
264 let SymbolSelector::Name(name) = selector else {
265 return Err(ambiguity);
266 };
267 let scope = file_path
268 .map(|path| workspace.resolve_path(Path::new(path)))
269 .transpose()?
270 .map(|(_, rel)| rel);
271 let mut overloads = queries::get_symbol_overloads(conn, name, scope.as_deref())?;
272 if let Some(path) = overloads.first().map(|symbol| symbol.path.clone())
273 && sync::ensure_fresh_file(workspace, db_path, conn, &path)?
274 {
275 overloads = queries::get_symbol_overloads(conn, name, scope.as_deref())?;
276 }
277 if overloads.is_empty() || overloads.len() > max {
278 return Err(ambiguity);
279 }
280 Ok(overloads
281 .into_iter()
282 .map(|symbol| SymbolSelector::Id(symbol.symbol_id))
283 .collect())
284}
285
286fn decorated_start(conn: &Connection, symbol: &Symbol) -> Option<usize> {
289 conn.query_row(
290 "SELECT MAX(start_byte) FROM structural_facts
291 WHERE path = ?1 AND pattern_id LIKE '%.decorated_definition.%'
292 AND end_byte = ?2 AND start_byte < ?3",
293 rusqlite::params![
294 symbol.path,
295 symbol.end_byte as i64,
296 symbol.start_byte as i64
297 ],
298 |row| row.get::<_, Option<i64>>(0),
299 )
300 .ok()
301 .flatten()
302 .map(|start| start as usize)
303}
304
305pub fn get_context_slice_op(
307 workspace: &Workspace,
308 db_path: &Path,
309 conn: &Connection,
310 symbol_name: &str,
311 file_path: Option<&str>,
312 include_external: bool,
313) -> Result<ContextSlice, OpError> {
314 get_context_slice_selected_op(
315 workspace,
316 db_path,
317 conn,
318 &SymbolSelector::Name(symbol_name.to_string()),
319 file_path,
320 include_external,
321 )
322}
323
324pub fn get_context_slice_selected_op(
325 workspace: &Workspace,
326 db_path: &Path,
327 conn: &Connection,
328 selector: &SymbolSelector,
329 file_path: Option<&str>,
330 include_external: bool,
331) -> Result<ContextSlice, OpError> {
332 let (target_symbol, target_body) =
333 get_symbol_body_selected_op(workspace, db_path, conn, selector, file_path)?;
334
335 let callee_signatures = queries::find_callee_signatures(
336 conn,
337 &target_symbol.name,
338 &target_symbol.symbol_id,
339 10,
340 include_external,
341 )?;
342
343 let related_types = queries::find_related_types(conn, &target_symbol, 10)?;
344 let related_tests = queries::find_related_tests(conn, &target_symbol, 5)?;
345
346 Ok(ContextSlice {
347 target_symbol,
348 target_body,
349 callee_signatures,
350 related_types,
351 related_tests,
352 })
353}
354
355pub fn file_skeleton_op(
357 workspace: &Workspace,
358 db_path: &Path,
359 conn: &Connection,
360 file_path: &str,
361) -> Result<String, OpError> {
362 let (effective_abs, rel_path) = workspace.resolve_path(Path::new(file_path))?;
363 if !effective_abs.exists() {
364 return Err(file_not_found(conn, &rel_path));
365 }
366 if effective_abs.is_dir() {
367 return codebase_outline_op(workspace, conn, 2, Some(&rel_path));
368 }
369 sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
370
371 let mut symbols = queries::load_file_symbols(conn, &rel_path)?;
372 let inherited =
373 queries::inherited_writes_among(conn, symbols.iter().map(|s| s.symbol_id.as_str()))?;
374 symbols.retain(|symbol| !inherited.contains(&symbol.symbol_id));
375 let file_meta = queries::get_file(conn, &rel_path)?;
376 let line_count = file_meta.and_then(|m| m.line_count.map(|l| l as usize));
377 let parse_errors = queries::count_parse_diagnostics(conn, &rel_path);
378
379 Ok(format_file_skeleton(
380 &rel_path,
381 &symbols,
382 line_count,
383 parse_errors,
384 ))
385}
386
387pub fn codebase_outline_op(
389 workspace: &Workspace,
390 conn: &Connection,
391 depth: usize,
392 path_filter: Option<&str>,
393) -> Result<String, OpError> {
394 let rel_filter = path_filter.map(|p| workspace.relativize_filter(p));
395 let path_filter = rel_filter.as_deref().filter(|path| !path.is_empty());
396 let norm = path_filter
397 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
398 .filter(|p| !p.is_empty());
399 let scope = queries::OutlineScope::new(path_filter, depth);
400 let symbols_by_file = queries::load_scoped_outline_symbols(conn, path_filter, depth, 5)?;
401 let counts = queries::load_outline_counts(conn, path_filter, depth)?;
402
403 let mut root_node = OutlineNode::default();
404 let norm_filter = norm.as_deref().unwrap_or_default();
405 let mut files_found = 0;
406 let mut listed = 0;
407 let mut truncated = false;
408 queries::for_each_outline_path(conn, &scope, |file_path| {
409 files_found += 1;
410 if scope.lists(&file_path) {
411 listed += 1;
412 if listed > queries::OUTLINE_FILE_CAP {
413 truncated = true;
414 return false;
415 }
416 }
417 add_path_to_outline(
418 &mut root_node,
419 &file_path,
420 &symbols_by_file,
421 &counts,
422 depth,
423 norm_filter,
424 );
425 true
426 })?;
427
428 let mut unsupported = 0;
429 queries::for_each_unsupported_path(conn, &scope, |file_path| {
430 unsupported += 1;
431 add_unsupported_to_outline(&mut root_node, file_path, depth, norm_filter);
432 })?;
433 if let Some(filter) = path_filter
434 && files_found == 0
435 && unsupported == 0
436 {
437 return Err(file_not_found(conn, filter));
438 }
439
440 let display_root = if norm_filter.is_empty() {
441 format!("{}/", workspace.repo_name)
442 } else {
443 format!("{}/{}/", workspace.repo_name, norm_filter)
444 };
445
446 let mut out = String::new();
447 out.push_str(&format!("{display_root}\n"));
448 render_outline_tree(&mut out, &root_node, "", 0, depth);
449
450 if truncated {
451 let msg = if path_filter.is_some() {
452 "\n[Outline truncated: path matches over 1,000 files. Narrow your path filter or specify a deeper path to reduce scope.]\n"
453 } else {
454 "\n[Outline truncated: workspace contains over 1,000 files. Use a path filter (e.g. `code-kb outline <path>`) to narrow scope.]\n"
455 };
456 out.push_str(msg);
457 }
458
459 if unsupported > 0 {
460 let noun = if unsupported == 1 { "file" } else { "files" };
461 out.push_str(&format!(
462 "\n[{unsupported} unsupported {noun}: no extractor for the language]\n"
463 ));
464 }
465
466 Ok(out)
467}
468
469pub fn blast_radius_op(
471 workspace: &Workspace,
472 conn: &Connection,
473 symbol: Option<&str>,
474 file: Option<&str>,
475 max_depth: usize,
476 limit: usize,
477) -> Result<BlastRadiusResult, OpError> {
478 blast_radius_selected_op(
479 workspace,
480 None,
481 conn,
482 symbol.map(|s| SymbolSelector::Name(s.to_string())),
483 file,
484 max_depth,
485 limit,
486 )
487}
488
489pub fn blast_radius_selected_op(
490 workspace: &Workspace,
491 db_path: Option<&Path>,
492 conn: &Connection,
493 selector: Option<SymbolSelector>,
494 file: Option<&str>,
495 max_depth: usize,
496 limit: usize,
497) -> Result<BlastRadiusResult, OpError> {
498 if let Some(selector @ SymbolSelector::Id(_)) = selector.as_ref() {
499 let db_path = db_path.ok_or(OpError::SymbolIdRequiresDatabasePath)?;
500 let selected = resolve_symbol_op(workspace, db_path, conn, selector, file)?;
501 return Ok(queries::compute_blast_radius_scoped_with_ids(
502 conn,
503 &[],
504 &[&selected.symbol_id],
505 None,
506 &[],
507 if max_depth == 0 { 2 } else { max_depth.min(5) },
508 limit,
509 )?);
510 }
511 let symbol = selector.as_ref().and_then(|selector| match selector {
512 SymbolSelector::Name(name) => Some(name.as_str()),
513 SymbolSelector::Id(_) => None,
514 });
515 let clean_symbol = symbol.and_then(|s| {
516 let t = s.trim();
517 if t.is_empty() { None } else { Some(t) }
518 });
519 let clean_file = file.and_then(|f| {
520 let t = f.trim();
521 if t.is_empty() {
522 None
523 } else {
524 Some(workspace.relativize_filter(t))
525 }
526 });
527
528 let mut discovered = Vec::new();
529 let (seed_symbols, symbol_path_filter, seed_paths) = match (clean_symbol, clean_file) {
530 (Some(s), Some(f)) => (vec![s], Some(f), vec![]),
531 (Some(s), None) => (vec![s], None, vec![]),
532 (None, Some(f)) => (vec![], None, vec![f]),
533 (None, None) => {
534 let git_status = std::process::Command::new("git")
536 .args(["status", "--porcelain"])
537 .current_dir(&workspace.root)
538 .output();
539
540 if let Ok(output) = git_status
541 && output.status.success()
542 {
543 let stdout = String::from_utf8_lossy(&output.stdout);
544 for line in stdout.lines() {
545 if line.len() > 3 {
546 let path_part = line.get(3..).unwrap_or("").trim();
547 let target = if let Some((_, to)) = path_part.split_once("->") {
548 to.trim()
549 } else {
550 path_part
551 };
552 let p = target.trim_matches('"');
553 let p_fwd = p.replace('\\', "/");
554 if !p_fwd.is_empty() && !crate::workspace::is_hard_excluded(&p_fwd) {
555 discovered.push(p_fwd);
556 }
557 }
558 }
559 }
560 (vec![], None, discovered)
561 }
562 };
563
564 let depth = if max_depth == 0 { 2 } else { max_depth.min(5) };
565 let seed_paths_refs: Vec<&str> = seed_paths.iter().map(|s| s.as_str()).collect();
566 let res = queries::compute_blast_radius_scoped(
567 conn,
568 &seed_symbols,
569 symbol_path_filter.as_deref(),
570 &seed_paths_refs,
571 depth,
572 limit,
573 )?;
574 Ok(res)
575}
576
577#[cfg(test)]
578mod tests {
579 use std::fs;
580
581 use rusqlite::Connection;
582
583 use super::codebase_outline_op;
584 use crate::workspace::Workspace;
585
586 #[test]
587 fn codebase_outline_accepts_absolute_workspace_root_filter() {
588 let temp = crate::safe_tempdir();
589 fs::write(temp.path().join("root.rs"), "pub fn root() {}\n").unwrap();
590 let workspace = Workspace::new(temp.path().to_path_buf());
591 let conn = Connection::open(temp.path().join("index.db")).unwrap();
592 conn.execute_batch(
593 "CREATE TABLE files (
594 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
595 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
596 );
597 CREATE TABLE symbols (
598 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
599 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
600 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
601 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
602 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
603 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
604 semantic_group TEXT, is_test INTEGER, test_container INTEGER
605 );
606 INSERT INTO files VALUES ('f', 'root.rs', 'rust', 'hash', 17, 1, 'now');
607 INSERT INTO symbols VALUES (
608 's', 'f', 'root.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
609 'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
610 );",
611 )
612 .unwrap();
613
614 let outline =
615 codebase_outline_op(&workspace, &conn, 1, Some(temp.path().to_str().unwrap())).unwrap();
616
617 assert!(outline.contains("root.rs"));
618
619 fs::create_dir(temp.path().join("src")).unwrap();
620 fs::write(temp.path().join("src/lib.rs"), "pub fn nested() {}\n").unwrap();
621 conn.execute_batch(
622 "INSERT INTO files VALUES ('nested-file', 'src/lib.rs', 'rust', 'hash', 19, 1, 'now');
623 INSERT INTO symbols VALUES (
624 'nested-symbol', 'nested-file', 'src/lib.rs', 'rust', 'nested', 'function',
625 'pub fn nested()', NULL, 'pub', NULL, 1, 0, 1, 18, 0, 18, 1, 0, 1, 18, 0, 18,
626 NULL, NULL, 0, 0
627 );",
628 )
629 .unwrap();
630
631 assert!(
632 codebase_outline_op(&workspace, &conn, 2, Some("."))
633 .unwrap()
634 .contains("root.rs")
635 );
636 assert!(
637 codebase_outline_op(&workspace, &conn, 1, Some("src"))
638 .unwrap()
639 .contains("lib.rs")
640 );
641 assert!(
642 codebase_outline_op(
643 &workspace,
644 &conn,
645 1,
646 Some(temp.path().join("src").to_str().unwrap()),
647 )
648 .unwrap()
649 .contains("lib.rs")
650 );
651 assert!(
652 codebase_outline_op(
653 &workspace,
654 &conn,
655 1,
656 Some(temp.path().parent().unwrap().to_str().unwrap()),
657 )
658 .is_err()
659 );
660 }
661
662 #[test]
663 fn codebase_outline_counts_unsupported_files() {
664 let temp = crate::safe_tempdir();
665 let workspace = Workspace::new(temp.path().to_path_buf());
666 let conn = Connection::open(temp.path().join("index.db")).unwrap();
667 conn.execute_batch(
668 "CREATE TABLE files (
669 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
670 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
671 );
672 CREATE TABLE symbols (
673 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
674 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
675 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
676 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
677 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
678 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
679 semantic_group TEXT, is_test INTEGER, test_container INTEGER
680 );
681 INSERT INTO files VALUES ('f1', 'src/lib.rs', 'rust', 'h', 0, 1, 'now', 'indexed');
682 INSERT INTO files VALUES ('f2', 'src/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
683 INSERT INTO files VALUES ('f3', 'docs/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
684 INSERT INTO files VALUES ('f4', 'src/assets/a.png', 'unknown', 'h', 0, 1, 'now', 'unsupported');
685 INSERT INTO symbols VALUES (
686 's', 'f1', 'src/lib.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
687 'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
688 );",
689 )
690 .unwrap();
691
692 let all = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
693 assert!(all.contains("3 unsupported files"));
694 assert!(all.contains("docs/ (1 unsupported file)"), "{all}");
695 assert!(all.contains("src/ (2 unsupported files)"), "{all}");
696 assert!(!all.contains("assets/"), "{all}");
697
698 let scoped = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
699 assert!(scoped.contains("2 unsupported files:"), "{scoped}");
700 }
701
702 #[test]
703 fn codebase_outline_names_plain_files_and_shows_an_overload_once() {
704 let temp = crate::safe_tempdir();
705 let workspace = Workspace::new(temp.path().to_path_buf());
706 let conn = Connection::open(temp.path().join("index.db")).unwrap();
707 conn.execute_batch(
708 "CREATE TABLE files (
709 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
710 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
711 );
712 CREATE TABLE symbols (
713 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
714 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
715 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
716 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
717 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
718 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
719 semantic_group TEXT, is_test INTEGER, test_container INTEGER
720 );
721 INSERT INTO files VALUES ('f1', 'src/helpers.py', 'python', 'h', 0, 9, 'now', 'indexed');
722 INSERT INTO files VALUES ('f2', 'src/signals.py', 'python', 'h', 0, 3, 'now', 'indexed');
723 INSERT INTO files VALUES ('f3', 'src/py.typed', 'unknown', 'h', 0, 0, 'now', 'unsupported');
724 INSERT INTO symbols VALUES (
725 'a', 'f1', 'src/helpers.py', 'python', 'stream', 'function', 'def stream()', NULL,
726 NULL, NULL, 1, 0, 2, 0, 0, 10, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
727 );
728 INSERT INTO symbols VALUES (
729 'b', 'f1', 'src/helpers.py', 'python', 'stream', 'function', 'def stream(x)', NULL,
730 NULL, NULL, 4, 0, 5, 0, 11, 20, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
731 );
732 INSERT INTO symbols VALUES (
733 'c', 'f2', 'src/signals.py', 'python', 'started', 'variable', 'started = Signal()',
734 NULL, NULL, NULL, 1, 0, 1, 0, 0, 18, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
735 );",
736 )
737 .unwrap();
738
739 let out = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
740
741 assert!(out.contains("helpers.py [function stream]\n"), "{out}");
742 assert!(
743 out.contains("(1 file without functions or classes: signals.py)"),
744 "{out}"
745 );
746 assert!(!out.contains("py.typed"), "{out}");
747 }
748
749 #[test]
750 fn codebase_outline_lists_namespaced_classes_past_deep_files_and_caps_each_subfolder() {
751 let temp = crate::safe_tempdir();
752 let workspace = Workspace::new(temp.path().to_path_buf());
753 let conn = Connection::open(temp.path().join("index.db")).unwrap();
754 conn.execute_batch(
755 "CREATE TABLE files (
756 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
757 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
758 );
759 CREATE TABLE symbols (
760 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
761 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
762 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
763 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
764 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
765 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
766 semantic_group TEXT, is_test INTEGER, test_container INTEGER
767 );
768 INSERT INTO files VALUES ('z', 'z/Late.cs', 'csharp', 'h', 0, 9, 'now', 'indexed');
769 INSERT INTO symbols VALUES (
770 'ns', 'z', 'z/Late.cs', 'csharp', 'App', 'namespace', 'namespace App', NULL,
771 NULL, NULL, 1, 0, 9, 0, 0, 90, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
772 );
773 INSERT INTO symbols VALUES (
774 'late', 'z', 'z/Late.cs', 'csharp', 'Late', 'class', 'class Late', NULL,
775 NULL, 'ns', 2, 0, 8, 0, 20, 80, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
776 );",
777 )
778 .unwrap();
779 let tx = conn.unchecked_transaction().unwrap();
780 for i in 0..1005 {
781 tx.execute(
782 "INSERT INTO files VALUES (?1, ?2, 'rust', 'h', 0, 1, 'now', 'indexed')",
783 rusqlite::params![format!("d{i}"), format!("a/deep/x/file_{i}.rs")],
784 )
785 .unwrap();
786 }
787 for i in 10..55 {
788 let path = format!("m/mod_{i}.rs");
789 tx.execute(
790 "INSERT INTO files VALUES (?1, ?2, 'rust', 'h', 0, 1, 'now', 'indexed')",
791 rusqlite::params![format!("m{i}"), path],
792 )
793 .unwrap();
794 tx.execute(
795 "INSERT INTO symbols VALUES (?1, ?2, ?3, 'rust', ?4, 'function', 'fn f()', NULL,
796 NULL, NULL, 1, 0, 1, 0, 0, 9, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0)",
797 rusqlite::params![format!("s{i}"), format!("m{i}"), path, format!("f{i}")],
798 )
799 .unwrap();
800 }
801 tx.commit().unwrap();
802
803 let out = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
804
805 assert!(out.contains("deep/ (1005 indexed files)"), "{out}");
806 assert!(out.contains("Late.cs [class Late]"), "{out}");
807 assert!(out.contains("mod_49.rs [function f49]"), "{out}");
808 assert!(!out.contains("mod_50.rs"), "{out}");
809 assert!(
810 out.contains("(+5 more files with functions or classes; pass this folder as the path"),
811 "{out}"
812 );
813 assert!(!out.contains("truncated"), "{out}");
814
815 let scoped = codebase_outline_op(&workspace, &conn, 2, Some("m")).unwrap();
816 assert!(scoped.contains("mod_54.rs [function f54]"), "{scoped}");
817 }
818
819 #[test]
820 fn codebase_outline_truncates_over_1000_files() {
821 let temp = crate::safe_tempdir();
822 let workspace = Workspace::new(temp.path().to_path_buf());
823 let conn = Connection::open(temp.path().join("index.db")).unwrap();
824 conn.execute_batch(
825 "CREATE TABLE files (
826 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
827 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
828 );
829 CREATE TABLE symbols (
830 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
831 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
832 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
833 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
834 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
835 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
836 semantic_group TEXT, is_test INTEGER, test_container INTEGER
837 );",
838 )
839 .unwrap();
840
841 let tx = conn.unchecked_transaction().unwrap();
842 for i in 1..=1005 {
843 tx.execute(
844 "INSERT INTO files VALUES (?1, ?2, 'rust', 'hash', 10, 1, 'now')",
845 rusqlite::params![format!("f{i}"), format!("src/file_{i}.rs")],
846 )
847 .unwrap();
848 }
849 tx.commit().unwrap();
850
851 let outline = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
852 assert!(outline.contains("[Outline truncated: workspace contains over 1,000 files."));
853
854 let scoped_outline = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
855 assert!(scoped_outline.contains("[Outline truncated: path matches over 1,000 files."));
856 }
857}