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 '{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
58/// Retrieve the body of a symbol by name, guaranteeing fresh offsets and file contents.
59pub 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    // 1. If file_path is provided, resolve and refresh file BEFORE querying the symbol
67    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    // 2. Query symbol from database
82    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    // 3. If file_path was not provided initially, refresh the file found from the symbol
87    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            // CRUCIAL: Reload symbol after re-indexing so we have fresh offsets!
92            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
107/// Retrieve a complete context slice for a symbol, guaranteeing fresh offsets.
108pub 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    // Find related types
128    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
147/// Generate file skeleton with fresh file synchronization.
148pub 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
176/// Generate a memory-bounded codebase outline pushed down into SQLite.
177pub 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
287/// Compute blast radius and likely tests for a symbol, file, or uncommitted git changes.
288pub 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            // Zero arguments: discover uncommitted working tree changes via git status
316            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}