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, 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
154/// Retrieve the body of a symbol by name, guaranteeing fresh offsets and file contents.
155pub 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
186/// Retrieve a complete context slice for a symbol, guaranteeing fresh offsets.
187pub 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    // Find related types
225    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
244/// Generate file skeleton with fresh file synchronization.
245pub 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
273/// Generate a memory-bounded codebase outline pushed down into SQLite.
274pub 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
384/// Compute blast radius and likely tests for a symbol, file, or uncommitted git changes.
385pub 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            // Zero arguments: discover uncommitted working tree changes via git status
450            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}