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