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