Skip to main content

code_kb_core/
ops.rs

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
34/// Retrieve the body of a symbol by name, guaranteeing fresh offsets and file contents.
35pub 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    // 1. If file_path is provided, resolve and refresh file BEFORE querying the symbol
43    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    // 2. Query symbol from database
58    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    // 3. If file_path was not provided initially, refresh the file found from the symbol
82    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            // CRUCIAL: Reload symbol after re-indexing so we have fresh offsets!
87            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
102/// Retrieve a complete context slice for a symbol, guaranteeing fresh offsets.
103pub 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    // Find related types
123    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
142/// Generate file skeleton with fresh file synchronization.
143pub 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
171/// Generate a memory-bounded codebase outline pushed down into SQLite.
172pub 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
282/// Compute blast radius and likely tests for a symbol, file, or uncommitted git changes.
283pub 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            // Zero arguments: discover uncommitted working tree changes via git status
311            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}