1use rusqlite::Connection;
2use std::path::Path;
3use thiserror::Error;
4
5use crate::formatters::{
6 OutlineNode, add_path_to_outline, format_file_skeleton, render_outline_tree,
7};
8use crate::models::{BlastRadiusResult, ContextSlice, Symbol};
9use crate::queries::{self, QueryError};
10use crate::slicer::{self, SliceError};
11use crate::sync::{self, SyncError};
12use crate::workspace::{Workspace, WorkspaceError, paths_equal};
13
14#[derive(Debug, Clone)]
15pub enum SymbolSelector {
16 Name(String),
17 Id(String),
18}
19
20#[derive(Debug, Error)]
21pub enum OpError {
22 #[error("Symbol '{name}' not found in {workspace}. {hint}")]
23 SymbolNotFound {
24 name: String,
25 workspace: String,
26 hint: String,
27 },
28 #[error("File '{path}' not found in {workspace}. {hint}")]
29 FileNotFound {
30 path: String,
31 workspace: String,
32 hint: String,
33 },
34 #[error("Path '{0}' is a directory, not a file")]
35 IsADirectory(String),
36 #[error(
37 "symbol_id '{id}' is no longer indexed in {workspace}; run lookup_symbol or search_symbols and select a current id"
38 )]
39 StaleSymbolId { id: String, workspace: String },
40 #[error("A database path is required to refresh a symbol_id selector")]
41 SymbolIdRequiresDatabasePath,
42 #[error("Selected symbol is in '{actual}', not requested file '{requested}'")]
43 FileGuardMismatch { requested: String, actual: String },
44 #[error("Workspace error: {0}")]
45 Workspace(#[from] WorkspaceError),
46 #[error("Synchronization error: {0}")]
47 Sync(#[from] SyncError),
48 #[error("Query error: {0}")]
49 Query(#[from] QueryError),
50 #[error("Slice error: {0}")]
51 Slice(#[from] SliceError),
52}
53
54fn stale_symbol_id(conn: &Connection, id: &str) -> OpError {
55 OpError::StaleSymbolId {
56 id: id.to_string(),
57 workspace: queries::workspace_name(conn),
58 }
59}
60
61pub fn resolve_symbol_op(
62 workspace: &Workspace,
63 db_path: &Path,
64 conn: &Connection,
65 selector: &SymbolSelector,
66 file_path: Option<&str>,
67) -> Result<Symbol, OpError> {
68 let id_selector = matches!(selector, SymbolSelector::Id(_));
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 return Err(OpError::IsADirectory(rel));
76 }
77 if !id_selector {
78 sync::ensure_fresh_file(workspace, db_path, conn, &rel)?;
79 }
80 Some((absolute, rel))
81 } else {
82 None
83 };
84 let lookup = |conn: &Connection| match selector {
85 SymbolSelector::Name(name) => {
86 queries::get_symbol_by_name(conn, name, guard.as_ref().map(|(_, rel)| rel.as_str()))
87 }
88 SymbolSelector::Id(id) => queries::get_symbol_by_id(conn, id),
89 };
90 let initial = lookup(conn)?.ok_or_else(|| match selector {
91 SymbolSelector::Name(name) => {
92 symbol_not_found(conn, name, guard.as_ref().map(|(_, rel)| rel.as_str()))
93 }
94 SymbolSelector::Id(id) => stale_symbol_id(conn, id),
95 })?;
96 if !id_selector
97 && let Some((requested_abs, requested_rel)) = &guard
98 && !paths_equal(requested_abs, &workspace.canonical_root.join(&initial.path))
99 {
100 return Err(OpError::FileGuardMismatch {
101 requested: requested_rel.clone(),
102 actual: initial.path,
103 });
104 }
105 let refreshed = sync::ensure_fresh_file(workspace, db_path, conn, &initial.path)?;
106 let refreshed_symbol = if refreshed {
107 match selector {
108 SymbolSelector::Name(name) => {
109 queries::get_symbol_by_name_exact(conn, name, &initial.path)?
110 }
111 SymbolSelector::Id(id) => queries::get_symbol_by_id(conn, id)?,
112 }
113 .ok_or_else(|| match selector {
114 SymbolSelector::Name(name) => {
115 symbol_not_found(conn, name, guard.as_ref().map(|(_, rel)| rel.as_str()))
116 }
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 body = slicer::slice_symbol_body(&abs_file, &symbol)?;
182
183 Ok((symbol, body))
184}
185
186pub fn get_context_slice_op(
188 workspace: &Workspace,
189 db_path: &Path,
190 conn: &Connection,
191 symbol_name: &str,
192 file_path: Option<&str>,
193 include_external: bool,
194) -> Result<ContextSlice, OpError> {
195 get_context_slice_selected_op(
196 workspace,
197 db_path,
198 conn,
199 &SymbolSelector::Name(symbol_name.to_string()),
200 file_path,
201 include_external,
202 )
203}
204
205pub fn get_context_slice_selected_op(
206 workspace: &Workspace,
207 db_path: &Path,
208 conn: &Connection,
209 selector: &SymbolSelector,
210 file_path: Option<&str>,
211 include_external: bool,
212) -> Result<ContextSlice, OpError> {
213 let (target_symbol, target_body) =
214 get_symbol_body_selected_op(workspace, db_path, conn, selector, file_path)?;
215
216 let callee_signatures = queries::find_callee_signatures(
217 conn,
218 &target_symbol.name,
219 &target_symbol.symbol_id,
220 10,
221 include_external,
222 )?;
223
224 let mut related_types = Vec::new();
226 let types = queries::find_type_facts(conn, &target_symbol.symbol_id)?;
227 for t in types {
228 if !related_types.contains(&t.resolved_type) {
229 related_types.push(t.resolved_type);
230 }
231 }
232
233 let related_tests = queries::find_related_tests(conn, &target_symbol, 5)?;
234
235 Ok(ContextSlice {
236 target_symbol,
237 target_body,
238 callee_signatures,
239 related_types,
240 related_tests,
241 })
242}
243
244pub fn file_skeleton_op(
246 workspace: &Workspace,
247 db_path: &Path,
248 conn: &Connection,
249 file_path: &str,
250) -> Result<String, OpError> {
251 let (effective_abs, rel_path) = workspace.resolve_path(Path::new(file_path))?;
252 if !effective_abs.exists() {
253 return Err(file_not_found(conn, &rel_path));
254 }
255 if effective_abs.is_dir() {
256 return codebase_outline_op(workspace, conn, 2, Some(&rel_path));
257 }
258 sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
259
260 let symbols = queries::load_file_symbols(conn, &rel_path)?;
261 let file_meta = queries::get_file(conn, &rel_path)?;
262 let line_count = file_meta.and_then(|m| m.line_count.map(|l| l as usize));
263 let parse_errors = queries::count_parse_diagnostics(conn, &rel_path);
264
265 Ok(format_file_skeleton(
266 &rel_path,
267 &symbols,
268 line_count,
269 parse_errors,
270 ))
271}
272
273pub fn codebase_outline_op(
275 workspace: &Workspace,
276 conn: &Connection,
277 depth: usize,
278 path_filter: Option<&str>,
279) -> Result<String, OpError> {
280 let rel_filter = path_filter.map(|p| workspace.relativize_filter(p));
281 let path_filter = rel_filter.as_deref().filter(|path| !path.is_empty());
282 let norm = path_filter
283 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
284 .filter(|p| !p.is_empty());
285 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
286 let prefix = norm
287 .as_ref()
288 .map(|path| format!("{}/%", queries::escape_like(path)));
289 let prefix_bs = norm_bs
290 .as_ref()
291 .map(|path| format!("{}\\\\%", queries::escape_like(path)));
292
293 let mut stmt = conn
294 .prepare(
295 "SELECT path FROM files
296 WHERE (:path IS NULL
297 OR path = :path COLLATE NOCASE
298 OR path = :path_bs COLLATE NOCASE
299 OR path LIKE :path_prefix ESCAPE '\\'
300 OR path LIKE :path_prefix_bs ESCAPE '\\')
301 ORDER BY path ASC
302 LIMIT 1001",
303 )
304 .map_err(QueryError::Sqlite)?;
305
306 let mut rows = stmt
307 .query(rusqlite::named_params! {
308 ":path": norm.as_deref(),
309 ":path_bs": norm_bs.as_deref(),
310 ":path_prefix": prefix.as_deref(),
311 ":path_prefix_bs": prefix_bs.as_deref(),
312 })
313 .map_err(QueryError::Sqlite)?;
314
315 let mut file_paths = Vec::new();
316 let mut files_found = 0;
317 let mut truncated = false;
318
319 while let Some(row) = rows.next().map_err(QueryError::Sqlite)? {
320 files_found += 1;
321 if files_found > 1000 {
322 truncated = true;
323 break;
324 }
325 let file_path: String = row.get(0).map_err(QueryError::Sqlite)?;
326 file_paths.push(file_path);
327 }
328
329 if let Some(filter) = path_filter
330 && files_found == 0
331 {
332 return Err(file_not_found(conn, filter));
333 }
334
335 let symbols_by_file = if file_paths.is_empty() {
336 std::collections::HashMap::new()
337 } else {
338 queries::load_scoped_outline_symbols(conn, path_filter, depth, 5)?
339 };
340
341 let mut root_node = OutlineNode::default();
342 let norm_filter = norm.as_deref().unwrap_or_default();
343
344 for file_path in &file_paths {
345 add_path_to_outline(
346 &mut root_node,
347 file_path,
348 &symbols_by_file,
349 depth,
350 norm_filter,
351 );
352 }
353
354 let display_root = if norm_filter.is_empty() {
355 format!("{}/", workspace.repo_name)
356 } else {
357 format!("{}/{}/", workspace.repo_name, norm_filter)
358 };
359
360 let mut out = String::new();
361 out.push_str(&format!("{display_root}\n"));
362 render_outline_tree(&mut out, &root_node, "", 0, depth);
363
364 if truncated {
365 let msg = if path_filter.is_some() {
366 "\n[Outline truncated: path matches over 1,000 files. Narrow your path filter or specify a deeper path to reduce scope.]\n"
367 } else {
368 "\n[Outline truncated: workspace contains over 1,000 files. Use a path filter (e.g. `code-kb outline <path>`) to narrow scope.]\n"
369 };
370 out.push_str(msg);
371 }
372
373 let unsupported = queries::count_unsupported_files(conn, norm.as_deref());
374 if unsupported > 0 {
375 let noun = if unsupported == 1 { "file" } else { "files" };
376 out.push_str(&format!(
377 "\n[{unsupported} unsupported {noun}: no extractor for the language]\n"
378 ));
379 }
380
381 Ok(out)
382}
383
384pub fn blast_radius_op(
386 workspace: &Workspace,
387 conn: &Connection,
388 symbol: Option<&str>,
389 file: Option<&str>,
390 max_depth: usize,
391 limit: usize,
392) -> Result<BlastRadiusResult, OpError> {
393 blast_radius_selected_op(
394 workspace,
395 None,
396 conn,
397 symbol.map(|s| SymbolSelector::Name(s.to_string())),
398 file,
399 max_depth,
400 limit,
401 )
402}
403
404pub fn blast_radius_selected_op(
405 workspace: &Workspace,
406 db_path: Option<&Path>,
407 conn: &Connection,
408 selector: Option<SymbolSelector>,
409 file: Option<&str>,
410 max_depth: usize,
411 limit: usize,
412) -> Result<BlastRadiusResult, OpError> {
413 if let Some(selector @ SymbolSelector::Id(_)) = selector.as_ref() {
414 let db_path = db_path.ok_or(OpError::SymbolIdRequiresDatabasePath)?;
415 let selected = resolve_symbol_op(workspace, db_path, conn, selector, file)?;
416 return Ok(queries::compute_blast_radius_scoped_with_ids(
417 conn,
418 &[],
419 &[&selected.symbol_id],
420 None,
421 &[],
422 if max_depth == 0 { 2 } else { max_depth.min(5) },
423 limit,
424 )?);
425 }
426 let symbol = selector.as_ref().and_then(|selector| match selector {
427 SymbolSelector::Name(name) => Some(name.as_str()),
428 SymbolSelector::Id(_) => None,
429 });
430 let clean_symbol = symbol.and_then(|s| {
431 let t = s.trim();
432 if t.is_empty() { None } else { Some(t) }
433 });
434 let clean_file = file.and_then(|f| {
435 let t = f.trim();
436 if t.is_empty() {
437 None
438 } else {
439 Some(workspace.relativize_filter(t))
440 }
441 });
442
443 let mut discovered = Vec::new();
444 let (seed_symbols, symbol_path_filter, seed_paths) = match (clean_symbol, clean_file) {
445 (Some(s), Some(f)) => (vec![s], Some(f), vec![]),
446 (Some(s), None) => (vec![s], None, vec![]),
447 (None, Some(f)) => (vec![], None, vec![f]),
448 (None, None) => {
449 let git_status = std::process::Command::new("git")
451 .args(["status", "--porcelain"])
452 .current_dir(&workspace.root)
453 .output();
454
455 if let Ok(output) = git_status
456 && output.status.success()
457 {
458 let stdout = String::from_utf8_lossy(&output.stdout);
459 for line in stdout.lines() {
460 if line.len() > 3 {
461 let path_part = line.get(3..).unwrap_or("").trim();
462 let target = if let Some((_, to)) = path_part.split_once("->") {
463 to.trim()
464 } else {
465 path_part
466 };
467 let p = target.trim_matches('"');
468 let p_fwd = p.replace('\\', "/");
469 if !p_fwd.is_empty() && !crate::workspace::is_hard_excluded(&p_fwd) {
470 discovered.push(p_fwd);
471 }
472 }
473 }
474 }
475 (vec![], None, discovered)
476 }
477 };
478
479 let depth = if max_depth == 0 { 2 } else { max_depth.min(5) };
480 let seed_paths_refs: Vec<&str> = seed_paths.iter().map(|s| s.as_str()).collect();
481 let res = queries::compute_blast_radius_scoped(
482 conn,
483 &seed_symbols,
484 symbol_path_filter.as_deref(),
485 &seed_paths_refs,
486 depth,
487 limit,
488 )?;
489 Ok(res)
490}
491
492#[cfg(test)]
493mod tests {
494 use std::fs;
495
496 use rusqlite::Connection;
497
498 use super::codebase_outline_op;
499 use crate::workspace::Workspace;
500
501 #[test]
502 fn codebase_outline_accepts_absolute_workspace_root_filter() {
503 let temp = crate::safe_tempdir();
504 fs::write(temp.path().join("root.rs"), "pub fn root() {}\n").unwrap();
505 let workspace = Workspace::new(temp.path().to_path_buf());
506 let conn = Connection::open(temp.path().join("index.db")).unwrap();
507 conn.execute_batch(
508 "CREATE TABLE files (
509 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
510 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
511 );
512 CREATE TABLE symbols (
513 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
514 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
515 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
516 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
517 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
518 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
519 semantic_group TEXT, is_test INTEGER, test_container INTEGER
520 );
521 INSERT INTO files VALUES ('f', 'root.rs', 'rust', 'hash', 17, 1, 'now');
522 INSERT INTO symbols VALUES (
523 's', 'f', 'root.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
524 'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
525 );",
526 )
527 .unwrap();
528
529 let outline =
530 codebase_outline_op(&workspace, &conn, 1, Some(temp.path().to_str().unwrap())).unwrap();
531
532 assert!(outline.contains("root.rs"));
533
534 fs::create_dir(temp.path().join("src")).unwrap();
535 fs::write(temp.path().join("src/lib.rs"), "pub fn nested() {}\n").unwrap();
536 conn.execute_batch(
537 "INSERT INTO files VALUES ('nested-file', 'src/lib.rs', 'rust', 'hash', 19, 1, 'now');
538 INSERT INTO symbols VALUES (
539 'nested-symbol', 'nested-file', 'src/lib.rs', 'rust', 'nested', 'function',
540 'pub fn nested()', NULL, 'pub', NULL, 1, 0, 1, 18, 0, 18, 1, 0, 1, 18, 0, 18,
541 NULL, NULL, 0, 0
542 );",
543 )
544 .unwrap();
545
546 assert!(
547 codebase_outline_op(&workspace, &conn, 2, Some("."))
548 .unwrap()
549 .contains("root.rs")
550 );
551 assert!(
552 codebase_outline_op(&workspace, &conn, 1, Some("src"))
553 .unwrap()
554 .contains("lib.rs")
555 );
556 assert!(
557 codebase_outline_op(
558 &workspace,
559 &conn,
560 1,
561 Some(temp.path().join("src").to_str().unwrap()),
562 )
563 .unwrap()
564 .contains("lib.rs")
565 );
566 assert!(
567 codebase_outline_op(
568 &workspace,
569 &conn,
570 1,
571 Some(temp.path().parent().unwrap().to_str().unwrap()),
572 )
573 .is_err()
574 );
575 }
576
577 #[test]
578 fn codebase_outline_counts_unsupported_files() {
579 let temp = crate::safe_tempdir();
580 let workspace = Workspace::new(temp.path().to_path_buf());
581 let conn = Connection::open(temp.path().join("index.db")).unwrap();
582 conn.execute_batch(
583 "CREATE TABLE files (
584 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
585 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
586 );
587 CREATE TABLE symbols (
588 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
589 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
590 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
591 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
592 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
593 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
594 semantic_group TEXT, is_test INTEGER, test_container INTEGER
595 );
596 INSERT INTO files VALUES ('f1', 'src/lib.rs', 'rust', 'h', 0, 1, 'now', 'indexed');
597 INSERT INTO files VALUES ('f2', 'src/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
598 INSERT INTO files VALUES ('f3', 'docs/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
599 INSERT INTO symbols VALUES (
600 's', 'f1', 'src/lib.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
601 'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
602 );",
603 )
604 .unwrap();
605
606 let all = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
607 assert!(all.contains("2 unsupported files"));
608
609 let scoped = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
610 assert!(scoped.contains("1 unsupported file:"));
611 }
612
613 #[test]
614 fn codebase_outline_truncates_over_1000_files() {
615 let temp = crate::safe_tempdir();
616 let workspace = Workspace::new(temp.path().to_path_buf());
617 let conn = Connection::open(temp.path().join("index.db")).unwrap();
618 conn.execute_batch(
619 "CREATE TABLE files (
620 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
621 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
622 );
623 CREATE TABLE symbols (
624 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
625 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
626 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
627 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
628 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
629 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
630 semantic_group TEXT, is_test INTEGER, test_container INTEGER
631 );",
632 )
633 .unwrap();
634
635 for i in 1..=1005 {
636 conn.execute(
637 "INSERT INTO files VALUES (?1, ?2, 'rust', 'hash', 10, 1, 'now')",
638 rusqlite::params![format!("f{i}"), format!("src/file_{i}.rs")],
639 )
640 .unwrap();
641 }
642
643 let outline = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
644 assert!(outline.contains("[Outline truncated: workspace contains over 1,000 files."));
645
646 let scoped_outline = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
647 assert!(scoped_outline.contains("[Outline truncated: path matches over 1,000 files."));
648 }
649}