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};
13
14#[derive(Debug, Error)]
15pub enum OpError {
16 #[error("Symbol '{0}' not found")]
17 SymbolNotFound(String),
18 #[error("Symbol '{0}' not found. Did you mean one of:\n{1}")]
19 SymbolNotFoundWithSuggestions(String, String),
20 #[error("File '{0}' not found")]
21 FileNotFound(String),
22 #[error("Path '{0}' is a directory, not a file")]
23 IsADirectory(String),
24 #[error("Workspace error: {0}")]
25 Workspace(#[from] WorkspaceError),
26 #[error("Synchronization error: {0}")]
27 Sync(#[from] SyncError),
28 #[error("Query error: {0}")]
29 Query(#[from] QueryError),
30 #[error("Slice error: {0}")]
31 Slice(#[from] SliceError),
32}
33
34pub fn get_symbol_body_op(
36 workspace: &Workspace,
37 db_path: &Path,
38 conn: &Connection,
39 symbol_name: &str,
40 file_path: Option<&str>,
41) -> Result<(Symbol, String), OpError> {
42 let resolved_rel = if let Some(fp) = file_path {
44 let (effective_abs, rel) = workspace.resolve_path(Path::new(fp))?;
45 if !effective_abs.exists() {
46 return Err(OpError::FileNotFound(rel));
47 }
48 if effective_abs.is_dir() {
49 return Err(OpError::IsADirectory(rel));
50 }
51 sync::ensure_fresh_file(workspace, db_path, conn, &rel)?;
52 Some(rel)
53 } else {
54 None
55 };
56
57 let initial_symbol = queries::get_symbol_by_name(conn, symbol_name, resolved_rel.as_deref())?
59 .ok_or_else(|| {
60 let suggestions = queries::search_symbols_scoped(
61 conn,
62 symbol_name,
63 None,
64 resolved_rel.as_deref(),
65 false,
66 3,
67 )
68 .unwrap_or_default();
69 if suggestions.is_empty() {
70 OpError::SymbolNotFound(symbol_name.to_string())
71 } else {
72 let list = suggestions
73 .into_iter()
74 .map(|s| format!(" - {} `{}` ({}:{})", s.kind, s.name, s.path, s.start_line))
75 .collect::<Vec<_>>()
76 .join("\n");
77 OpError::SymbolNotFoundWithSuggestions(symbol_name.to_string(), list)
78 }
79 })?;
80
81 let symbol = if resolved_rel.is_none() {
83 let was_refreshed =
84 sync::ensure_fresh_file(workspace, db_path, conn, &initial_symbol.path)?;
85 if was_refreshed {
86 queries::get_symbol_by_name_exact(conn, symbol_name, &initial_symbol.path)?
88 .ok_or_else(|| OpError::SymbolNotFound(symbol_name.to_string()))?
89 } else {
90 initial_symbol
91 }
92 } else {
93 initial_symbol
94 };
95
96 let abs_file = workspace.canonical_root.join(&symbol.path);
97 let body = slicer::slice_symbol_body(&abs_file, &symbol)?;
98
99 Ok((symbol, body))
100}
101
102pub fn get_context_slice_op(
104 workspace: &Workspace,
105 db_path: &Path,
106 conn: &Connection,
107 symbol_name: &str,
108 file_path: Option<&str>,
109 include_external: bool,
110) -> Result<ContextSlice, OpError> {
111 let (target_symbol, target_body) =
112 get_symbol_body_op(workspace, db_path, conn, symbol_name, file_path)?;
113
114 let callee_signatures = queries::find_callee_signatures(
115 conn,
116 &target_symbol.name,
117 &target_symbol.symbol_id,
118 10,
119 include_external,
120 )?;
121
122 let mut related_types = Vec::new();
124 let types = queries::find_type_facts(conn, &target_symbol.symbol_id)?;
125 for t in types {
126 if !related_types.contains(&t.resolved_type) {
127 related_types.push(t.resolved_type);
128 }
129 }
130
131 let related_tests = queries::find_related_tests(conn, &target_symbol, 5)?;
132
133 Ok(ContextSlice {
134 target_symbol,
135 target_body,
136 callee_signatures,
137 related_types,
138 related_tests,
139 })
140}
141
142pub fn file_skeleton_op(
144 workspace: &Workspace,
145 db_path: &Path,
146 conn: &Connection,
147 file_path: &str,
148) -> Result<String, OpError> {
149 let (effective_abs, rel_path) = workspace.resolve_path(Path::new(file_path))?;
150 if !effective_abs.exists() {
151 return Err(OpError::FileNotFound(rel_path));
152 }
153 if effective_abs.is_dir() {
154 return codebase_outline_op(workspace, conn, 2, Some(&rel_path));
155 }
156 sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
157
158 let symbols = queries::load_file_symbols(conn, &rel_path)?;
159 let file_meta = queries::get_file(conn, &rel_path)?;
160 let line_count = file_meta.and_then(|m| m.line_count.map(|l| l as usize));
161 let parse_errors = queries::count_parse_diagnostics(conn, &rel_path);
162
163 Ok(format_file_skeleton(
164 &rel_path,
165 &symbols,
166 line_count,
167 parse_errors,
168 ))
169}
170
171pub fn codebase_outline_op(
173 workspace: &Workspace,
174 conn: &Connection,
175 depth: usize,
176 path_filter: Option<&str>,
177) -> Result<String, OpError> {
178 let rel_filter = path_filter.map(|p| workspace.relativize_filter(p));
179 let path_filter = rel_filter.as_deref().filter(|path| !path.is_empty());
180 let norm = path_filter
181 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
182 .filter(|p| !p.is_empty());
183 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
184 let prefix = norm
185 .as_ref()
186 .map(|path| format!("{}/%", queries::escape_like(path)));
187 let prefix_bs = norm_bs
188 .as_ref()
189 .map(|path| format!("{}\\\\%", queries::escape_like(path)));
190
191 let mut stmt = conn
192 .prepare(
193 "SELECT path FROM files
194 WHERE (:path IS NULL
195 OR path = :path COLLATE NOCASE
196 OR path = :path_bs COLLATE NOCASE
197 OR path LIKE :path_prefix ESCAPE '\\'
198 OR path LIKE :path_prefix_bs ESCAPE '\\')
199 ORDER BY path ASC
200 LIMIT 1001",
201 )
202 .map_err(QueryError::Sqlite)?;
203
204 let mut rows = stmt
205 .query(rusqlite::named_params! {
206 ":path": norm.as_deref(),
207 ":path_bs": norm_bs.as_deref(),
208 ":path_prefix": prefix.as_deref(),
209 ":path_prefix_bs": prefix_bs.as_deref(),
210 })
211 .map_err(QueryError::Sqlite)?;
212
213 let mut file_paths = Vec::new();
214 let mut files_found = 0;
215 let mut truncated = false;
216
217 while let Some(row) = rows.next().map_err(QueryError::Sqlite)? {
218 files_found += 1;
219 if files_found > 1000 {
220 truncated = true;
221 break;
222 }
223 let file_path: String = row.get(0).map_err(QueryError::Sqlite)?;
224 file_paths.push(file_path);
225 }
226
227 if let Some(filter) = path_filter
228 && files_found == 0
229 {
230 return Err(OpError::FileNotFound(filter.to_string()));
231 }
232
233 let symbols_by_file = if file_paths.is_empty() {
234 std::collections::HashMap::new()
235 } else {
236 queries::load_scoped_outline_symbols(conn, path_filter, depth, 5)?
237 };
238
239 let mut root_node = OutlineNode::default();
240 let norm_filter = norm.as_deref().unwrap_or_default();
241
242 for file_path in &file_paths {
243 add_path_to_outline(
244 &mut root_node,
245 file_path,
246 &symbols_by_file,
247 depth,
248 norm_filter,
249 );
250 }
251
252 let display_root = if norm_filter.is_empty() {
253 format!("{}/", workspace.repo_name)
254 } else {
255 format!("{}/{}/", workspace.repo_name, norm_filter)
256 };
257
258 let mut out = String::new();
259 out.push_str(&format!("{display_root}\n"));
260 render_outline_tree(&mut out, &root_node, "", 0, depth);
261
262 if truncated {
263 let msg = if path_filter.is_some() {
264 "\n[Outline truncated: path matches over 1,000 files. Narrow your path filter or specify a deeper path to reduce scope.]\n"
265 } else {
266 "\n[Outline truncated: workspace contains over 1,000 files. Use a path filter (e.g. `code-kb outline <path>`) to narrow scope.]\n"
267 };
268 out.push_str(msg);
269 }
270
271 let unsupported = queries::count_unsupported_files(conn, norm.as_deref());
272 if unsupported > 0 {
273 let noun = if unsupported == 1 { "file" } else { "files" };
274 out.push_str(&format!(
275 "\n[{unsupported} unsupported {noun}: no extractor for the language]\n"
276 ));
277 }
278
279 Ok(out)
280}
281
282pub fn blast_radius_op(
284 workspace: &Workspace,
285 conn: &Connection,
286 symbol: Option<&str>,
287 file: Option<&str>,
288 max_depth: usize,
289 limit: usize,
290) -> Result<BlastRadiusResult, OpError> {
291 let clean_symbol = symbol.and_then(|s| {
292 let t = s.trim();
293 if t.is_empty() { None } else { Some(t) }
294 });
295 let clean_file = file.and_then(|f| {
296 let t = f.trim();
297 if t.is_empty() {
298 None
299 } else {
300 Some(workspace.relativize_filter(t))
301 }
302 });
303
304 let mut discovered = Vec::new();
305 let (seed_symbols, symbol_path_filter, seed_paths) = match (clean_symbol, clean_file) {
306 (Some(s), Some(f)) => (vec![s], Some(f), vec![]),
307 (Some(s), None) => (vec![s], None, vec![]),
308 (None, Some(f)) => (vec![], None, vec![f]),
309 (None, None) => {
310 let git_status = std::process::Command::new("git")
312 .args(["status", "--porcelain"])
313 .current_dir(&workspace.root)
314 .output();
315
316 if let Ok(output) = git_status
317 && output.status.success()
318 {
319 let stdout = String::from_utf8_lossy(&output.stdout);
320 for line in stdout.lines() {
321 if line.len() > 3 {
322 let path_part = line.get(3..).unwrap_or("").trim();
323 let target = if let Some((_, to)) = path_part.split_once("->") {
324 to.trim()
325 } else {
326 path_part
327 };
328 let p = target.trim_matches('"');
329 let p_fwd = p.replace('\\', "/");
330 if !p_fwd.is_empty() && !crate::workspace::is_hard_excluded(&p_fwd) {
331 discovered.push(p_fwd);
332 }
333 }
334 }
335 }
336 (vec![], None, discovered)
337 }
338 };
339
340 let depth = if max_depth == 0 { 2 } else { max_depth.min(5) };
341 let row_limit = if limit == 0 { 20 } else { limit };
342
343 let seed_paths_refs: Vec<&str> = seed_paths.iter().map(|s| s.as_str()).collect();
344 let res = queries::compute_blast_radius_scoped(
345 conn,
346 &seed_symbols,
347 symbol_path_filter.as_deref(),
348 &seed_paths_refs,
349 depth,
350 row_limit,
351 )?;
352 Ok(res)
353}
354
355#[cfg(test)]
356mod tests {
357 use std::fs;
358
359 use rusqlite::Connection;
360
361 use super::codebase_outline_op;
362 use crate::workspace::Workspace;
363
364 #[test]
365 fn codebase_outline_accepts_absolute_workspace_root_filter() {
366 let temp = crate::safe_tempdir();
367 fs::write(temp.path().join("root.rs"), "pub fn root() {}\n").unwrap();
368 let workspace = Workspace::new(temp.path().to_path_buf());
369 let conn = Connection::open(temp.path().join("index.db")).unwrap();
370 conn.execute_batch(
371 "CREATE TABLE files (
372 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
373 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
374 );
375 CREATE TABLE symbols (
376 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
377 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
378 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
379 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
380 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
381 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
382 semantic_group TEXT, is_test INTEGER, test_container INTEGER
383 );
384 INSERT INTO files VALUES ('f', 'root.rs', 'rust', 'hash', 17, 1, 'now');
385 INSERT INTO symbols VALUES (
386 's', 'f', 'root.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
387 'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
388 );",
389 )
390 .unwrap();
391
392 let outline =
393 codebase_outline_op(&workspace, &conn, 1, Some(temp.path().to_str().unwrap())).unwrap();
394
395 assert!(outline.contains("root.rs"));
396
397 fs::create_dir(temp.path().join("src")).unwrap();
398 fs::write(temp.path().join("src/lib.rs"), "pub fn nested() {}\n").unwrap();
399 conn.execute_batch(
400 "INSERT INTO files VALUES ('nested-file', 'src/lib.rs', 'rust', 'hash', 19, 1, 'now');
401 INSERT INTO symbols VALUES (
402 'nested-symbol', 'nested-file', 'src/lib.rs', 'rust', 'nested', 'function',
403 'pub fn nested()', NULL, 'pub', NULL, 1, 0, 1, 18, 0, 18, 1, 0, 1, 18, 0, 18,
404 NULL, NULL, 0, 0
405 );",
406 )
407 .unwrap();
408
409 assert!(
410 codebase_outline_op(&workspace, &conn, 2, Some("."))
411 .unwrap()
412 .contains("root.rs")
413 );
414 assert!(
415 codebase_outline_op(&workspace, &conn, 1, Some("src"))
416 .unwrap()
417 .contains("lib.rs")
418 );
419 assert!(
420 codebase_outline_op(
421 &workspace,
422 &conn,
423 1,
424 Some(temp.path().join("src").to_str().unwrap()),
425 )
426 .unwrap()
427 .contains("lib.rs")
428 );
429 assert!(
430 codebase_outline_op(
431 &workspace,
432 &conn,
433 1,
434 Some(temp.path().parent().unwrap().to_str().unwrap()),
435 )
436 .is_err()
437 );
438 }
439
440 #[test]
441 fn codebase_outline_counts_unsupported_files() {
442 let temp = crate::safe_tempdir();
443 let workspace = Workspace::new(temp.path().to_path_buf());
444 let conn = Connection::open(temp.path().join("index.db")).unwrap();
445 conn.execute_batch(
446 "CREATE TABLE files (
447 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
448 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
449 );
450 CREATE TABLE symbols (
451 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
452 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
453 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
454 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
455 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
456 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
457 semantic_group TEXT, is_test INTEGER, test_container INTEGER
458 );
459 INSERT INTO files VALUES ('f1', 'src/lib.rs', 'rust', 'h', 0, 1, 'now', 'indexed');
460 INSERT INTO files VALUES ('f2', 'src/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
461 INSERT INTO files VALUES ('f3', 'docs/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
462 INSERT INTO symbols VALUES (
463 's', 'f1', 'src/lib.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
464 'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
465 );",
466 )
467 .unwrap();
468
469 let all = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
470 assert!(all.contains("2 unsupported files"));
471
472 let scoped = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
473 assert!(scoped.contains("1 unsupported file:"));
474 }
475
476 #[test]
477 fn codebase_outline_truncates_over_1000_files() {
478 let temp = crate::safe_tempdir();
479 let workspace = Workspace::new(temp.path().to_path_buf());
480 let conn = Connection::open(temp.path().join("index.db")).unwrap();
481 conn.execute_batch(
482 "CREATE TABLE files (
483 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
484 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
485 );
486 CREATE TABLE symbols (
487 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
488 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
489 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
490 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
491 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
492 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
493 semantic_group TEXT, is_test INTEGER, test_container INTEGER
494 );",
495 )
496 .unwrap();
497
498 for i in 1..=1005 {
499 conn.execute(
500 "INSERT INTO files VALUES (?1, ?2, 'rust', 'hash', 10, 1, 'now')",
501 rusqlite::params![format!("f{i}"), format!("src/file_{i}.rs")],
502 )
503 .unwrap();
504 }
505
506 let outline = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
507 assert!(outline.contains("[Outline truncated: workspace contains over 1,000 files."));
508
509 let scoped_outline = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
510 assert!(scoped_outline.contains("[Outline truncated: path matches over 1,000 files."));
511 }
512}