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, add_unsupported_to_outline, format_file_skeleton,
7    render_outline_tree,
8};
9use crate::models::{BlastRadiusResult, ContextSlice, Symbol};
10use crate::queries::{self, QueryError};
11use crate::slicer::{self, SliceError};
12use crate::sync::{self, SyncError};
13use crate::workspace::{Workspace, WorkspaceError, paths_equal};
14
15#[derive(Debug, Clone)]
16pub enum SymbolSelector {
17    Name(String),
18    Id(String),
19}
20
21#[derive(Debug, Error)]
22pub enum OpError {
23    #[error("Symbol '{name}' not found in {workspace}. {hint}")]
24    SymbolNotFound {
25        name: String,
26        workspace: String,
27        hint: String,
28    },
29    #[error("File '{path}' not found in {workspace}. {hint}")]
30    FileNotFound {
31        path: String,
32        workspace: String,
33        hint: String,
34    },
35    #[error("Path '{0}' is a directory, not a file")]
36    IsADirectory(String),
37    #[error(
38        "symbol_id '{id}' is no longer indexed in {workspace}; run lookup_symbol or search_symbols and select a current id"
39    )]
40    StaleSymbolId { id: String, workspace: String },
41    #[error("A database path is required to refresh a symbol_id selector")]
42    SymbolIdRequiresDatabasePath,
43    #[error("Selected symbol is in '{actual}', not requested file '{requested}'")]
44    FileGuardMismatch { requested: String, actual: String },
45    #[error("Workspace error: {0}")]
46    Workspace(#[from] WorkspaceError),
47    #[error("Synchronization error: {0}")]
48    Sync(#[from] SyncError),
49    #[error("Query error: {0}")]
50    Query(#[from] QueryError),
51    #[error("Slice error: {0}")]
52    Slice(#[from] SliceError),
53}
54
55fn stale_symbol_id(conn: &Connection, id: &str) -> OpError {
56    OpError::StaleSymbolId {
57        id: id.to_string(),
58        workspace: queries::workspace_name(conn),
59    }
60}
61
62pub fn resolve_symbol_op(
63    workspace: &Workspace,
64    db_path: &Path,
65    conn: &Connection,
66    selector: &SymbolSelector,
67    file_path: Option<&str>,
68) -> Result<Symbol, OpError> {
69    let id_selector = matches!(selector, SymbolSelector::Id(_));
70    let guard = if let Some(file_path) = file_path {
71        let (absolute, rel) = workspace.resolve_path(Path::new(file_path))?;
72        if !absolute.exists() {
73            return Err(file_not_found(conn, &rel));
74        }
75        if absolute.is_dir() {
76            return Err(OpError::IsADirectory(rel));
77        }
78        if !id_selector {
79            sync::ensure_fresh_file(workspace, db_path, conn, &rel)?;
80        }
81        Some((absolute, rel))
82    } else {
83        None
84    };
85    let lookup = |conn: &Connection| match selector {
86        SymbolSelector::Name(name) => {
87            queries::get_symbol_by_name(conn, name, guard.as_ref().map(|(_, rel)| rel.as_str()))
88        }
89        SymbolSelector::Id(id) => queries::get_symbol_by_id(conn, id),
90    };
91    let initial = lookup(conn)?.ok_or_else(|| match selector {
92        SymbolSelector::Name(name) => {
93            symbol_not_found(conn, name, guard.as_ref().map(|(_, rel)| rel.as_str()))
94        }
95        SymbolSelector::Id(id) => stale_symbol_id(conn, id),
96    })?;
97    if !id_selector
98        && let Some((requested_abs, requested_rel)) = &guard
99        && !paths_equal(requested_abs, &workspace.canonical_root.join(&initial.path))
100    {
101        return Err(OpError::FileGuardMismatch {
102            requested: requested_rel.clone(),
103            actual: initial.path,
104        });
105    }
106    let refreshed = sync::ensure_fresh_file(workspace, db_path, conn, &initial.path)?;
107    let refreshed_symbol = if refreshed {
108        match selector {
109            SymbolSelector::Name(name) => {
110                queries::get_symbol_by_name_exact(conn, name, &initial.path)?
111            }
112            SymbolSelector::Id(id) => queries::get_symbol_by_id(conn, id)?,
113        }
114        .ok_or_else(|| match selector {
115            SymbolSelector::Name(name) => {
116                symbol_not_found(conn, name, guard.as_ref().map(|(_, rel)| rel.as_str()))
117            }
118            SymbolSelector::Id(id) => stale_symbol_id(conn, id),
119        })?
120    } else {
121        initial
122    };
123    if let Some((requested_abs, requested_rel)) = &guard
124        && !paths_equal(
125            requested_abs,
126            &workspace.canonical_root.join(&refreshed_symbol.path),
127        )
128    {
129        return Err(OpError::FileGuardMismatch {
130            requested: requested_rel.clone(),
131            actual: refreshed_symbol.path,
132        });
133    }
134    Ok(refreshed_symbol)
135}
136
137fn symbol_not_found(conn: &Connection, name: &str, path_filter: Option<&str>) -> OpError {
138    let (workspace, hint) = queries::symbol_not_found_parts(conn, name, path_filter);
139    OpError::SymbolNotFound {
140        name: name.to_string(),
141        workspace,
142        hint,
143    }
144}
145
146fn file_not_found(conn: &Connection, rel_path: &str) -> OpError {
147    let (workspace, hint) = queries::file_not_found_parts(conn, rel_path);
148    OpError::FileNotFound {
149        path: rel_path.to_string(),
150        workspace,
151        hint,
152    }
153}
154
155/// Retrieve the body of a symbol by name, guaranteeing fresh offsets and file contents.
156pub fn get_symbol_body_op(
157    workspace: &Workspace,
158    db_path: &Path,
159    conn: &Connection,
160    symbol_name: &str,
161    file_path: Option<&str>,
162) -> Result<(Symbol, String), OpError> {
163    get_symbol_body_selected_op(
164        workspace,
165        db_path,
166        conn,
167        &SymbolSelector::Name(symbol_name.to_string()),
168        file_path,
169    )
170}
171
172pub fn get_symbol_body_selected_op(
173    workspace: &Workspace,
174    db_path: &Path,
175    conn: &Connection,
176    selector: &SymbolSelector,
177    file_path: Option<&str>,
178) -> Result<(Symbol, String), OpError> {
179    let symbol = resolve_symbol_op(workspace, db_path, conn, selector, file_path)?;
180
181    let abs_file = workspace.canonical_root.join(&symbol.path);
182    let start = decorated_start(conn, &symbol).unwrap_or(symbol.start_byte);
183    let source = slicer::slice_symbol_source(&abs_file, &symbol, start)?;
184
185    Ok((symbol, source))
186}
187
188/// Where the decorators above a Python symbol begin: julie starts the symbol at its `def` or
189/// `class` line and records the decorated block as a `decorated_definition` fact.
190fn decorated_start(conn: &Connection, symbol: &Symbol) -> Option<usize> {
191    conn.query_row(
192        "SELECT MAX(start_byte) FROM structural_facts
193         WHERE path = ?1 AND pattern_id LIKE '%.decorated_definition.%'
194           AND end_byte = ?2 AND start_byte < ?3",
195        rusqlite::params![
196            symbol.path,
197            symbol.end_byte as i64,
198            symbol.start_byte as i64
199        ],
200        |row| row.get::<_, Option<i64>>(0),
201    )
202    .ok()
203    .flatten()
204    .map(|start| start as usize)
205}
206
207/// Retrieve a complete context slice for a symbol, guaranteeing fresh offsets.
208pub fn get_context_slice_op(
209    workspace: &Workspace,
210    db_path: &Path,
211    conn: &Connection,
212    symbol_name: &str,
213    file_path: Option<&str>,
214    include_external: bool,
215) -> Result<ContextSlice, OpError> {
216    get_context_slice_selected_op(
217        workspace,
218        db_path,
219        conn,
220        &SymbolSelector::Name(symbol_name.to_string()),
221        file_path,
222        include_external,
223    )
224}
225
226pub fn get_context_slice_selected_op(
227    workspace: &Workspace,
228    db_path: &Path,
229    conn: &Connection,
230    selector: &SymbolSelector,
231    file_path: Option<&str>,
232    include_external: bool,
233) -> Result<ContextSlice, OpError> {
234    let (target_symbol, target_body) =
235        get_symbol_body_selected_op(workspace, db_path, conn, selector, file_path)?;
236
237    let callee_signatures = queries::find_callee_signatures(
238        conn,
239        &target_symbol.name,
240        &target_symbol.symbol_id,
241        10,
242        include_external,
243    )?;
244
245    let related_types = queries::find_related_types(conn, &target_symbol, 10)?;
246    let related_tests = queries::find_related_tests(conn, &target_symbol, 5)?;
247
248    Ok(ContextSlice {
249        target_symbol,
250        target_body,
251        callee_signatures,
252        related_types,
253        related_tests,
254    })
255}
256
257/// Generate file skeleton with fresh file synchronization.
258pub fn file_skeleton_op(
259    workspace: &Workspace,
260    db_path: &Path,
261    conn: &Connection,
262    file_path: &str,
263) -> Result<String, OpError> {
264    let (effective_abs, rel_path) = workspace.resolve_path(Path::new(file_path))?;
265    if !effective_abs.exists() {
266        return Err(file_not_found(conn, &rel_path));
267    }
268    if effective_abs.is_dir() {
269        return codebase_outline_op(workspace, conn, 2, Some(&rel_path));
270    }
271    sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
272
273    let mut symbols = queries::load_file_symbols(conn, &rel_path)?;
274    let inherited =
275        queries::inherited_writes_among(conn, symbols.iter().map(|s| s.symbol_id.as_str()))?;
276    symbols.retain(|symbol| !inherited.contains(&symbol.symbol_id));
277    let file_meta = queries::get_file(conn, &rel_path)?;
278    let line_count = file_meta.and_then(|m| m.line_count.map(|l| l as usize));
279    let parse_errors = queries::count_parse_diagnostics(conn, &rel_path);
280
281    Ok(format_file_skeleton(
282        &rel_path,
283        &symbols,
284        line_count,
285        parse_errors,
286    ))
287}
288
289/// Generate a memory-bounded codebase outline pushed down into SQLite.
290pub fn codebase_outline_op(
291    workspace: &Workspace,
292    conn: &Connection,
293    depth: usize,
294    path_filter: Option<&str>,
295) -> Result<String, OpError> {
296    let rel_filter = path_filter.map(|p| workspace.relativize_filter(p));
297    let path_filter = rel_filter.as_deref().filter(|path| !path.is_empty());
298    let norm = path_filter
299        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
300        .filter(|p| !p.is_empty());
301    let scope = queries::OutlineScope::new(path_filter, depth);
302    let symbols_by_file = queries::load_scoped_outline_symbols(conn, path_filter, depth, 5)?;
303    let counts = queries::load_outline_counts(conn, path_filter, depth)?;
304
305    let mut root_node = OutlineNode::default();
306    let norm_filter = norm.as_deref().unwrap_or_default();
307    let mut files_found = 0;
308    let mut listed = 0;
309    let mut truncated = false;
310    queries::for_each_outline_path(conn, &scope, |file_path| {
311        files_found += 1;
312        if scope.lists(&file_path) {
313            listed += 1;
314            if listed > queries::OUTLINE_FILE_CAP {
315                truncated = true;
316                return false;
317            }
318        }
319        add_path_to_outline(
320            &mut root_node,
321            &file_path,
322            &symbols_by_file,
323            &counts,
324            depth,
325            norm_filter,
326        );
327        true
328    })?;
329
330    let mut unsupported = 0;
331    queries::for_each_unsupported_path(conn, &scope, |file_path| {
332        unsupported += 1;
333        add_unsupported_to_outline(&mut root_node, file_path, depth, norm_filter);
334    })?;
335    if let Some(filter) = path_filter
336        && files_found == 0
337        && unsupported == 0
338    {
339        return Err(file_not_found(conn, filter));
340    }
341
342    let display_root = if norm_filter.is_empty() {
343        format!("{}/", workspace.repo_name)
344    } else {
345        format!("{}/{}/", workspace.repo_name, norm_filter)
346    };
347
348    let mut out = String::new();
349    out.push_str(&format!("{display_root}\n"));
350    render_outline_tree(&mut out, &root_node, "", 0, depth);
351
352    if truncated {
353        let msg = if path_filter.is_some() {
354            "\n[Outline truncated: path matches over 1,000 files. Narrow your path filter or specify a deeper path to reduce scope.]\n"
355        } else {
356            "\n[Outline truncated: workspace contains over 1,000 files. Use a path filter (e.g. `code-kb outline <path>`) to narrow scope.]\n"
357        };
358        out.push_str(msg);
359    }
360
361    if unsupported > 0 {
362        let noun = if unsupported == 1 { "file" } else { "files" };
363        out.push_str(&format!(
364            "\n[{unsupported} unsupported {noun}: no extractor for the language]\n"
365        ));
366    }
367
368    Ok(out)
369}
370
371/// Compute blast radius and likely tests for a symbol, file, or uncommitted git changes.
372pub fn blast_radius_op(
373    workspace: &Workspace,
374    conn: &Connection,
375    symbol: Option<&str>,
376    file: Option<&str>,
377    max_depth: usize,
378    limit: usize,
379) -> Result<BlastRadiusResult, OpError> {
380    blast_radius_selected_op(
381        workspace,
382        None,
383        conn,
384        symbol.map(|s| SymbolSelector::Name(s.to_string())),
385        file,
386        max_depth,
387        limit,
388    )
389}
390
391pub fn blast_radius_selected_op(
392    workspace: &Workspace,
393    db_path: Option<&Path>,
394    conn: &Connection,
395    selector: Option<SymbolSelector>,
396    file: Option<&str>,
397    max_depth: usize,
398    limit: usize,
399) -> Result<BlastRadiusResult, OpError> {
400    if let Some(selector @ SymbolSelector::Id(_)) = selector.as_ref() {
401        let db_path = db_path.ok_or(OpError::SymbolIdRequiresDatabasePath)?;
402        let selected = resolve_symbol_op(workspace, db_path, conn, selector, file)?;
403        return Ok(queries::compute_blast_radius_scoped_with_ids(
404            conn,
405            &[],
406            &[&selected.symbol_id],
407            None,
408            &[],
409            if max_depth == 0 { 2 } else { max_depth.min(5) },
410            limit,
411        )?);
412    }
413    let symbol = selector.as_ref().and_then(|selector| match selector {
414        SymbolSelector::Name(name) => Some(name.as_str()),
415        SymbolSelector::Id(_) => None,
416    });
417    let clean_symbol = symbol.and_then(|s| {
418        let t = s.trim();
419        if t.is_empty() { None } else { Some(t) }
420    });
421    let clean_file = file.and_then(|f| {
422        let t = f.trim();
423        if t.is_empty() {
424            None
425        } else {
426            Some(workspace.relativize_filter(t))
427        }
428    });
429
430    let mut discovered = Vec::new();
431    let (seed_symbols, symbol_path_filter, seed_paths) = match (clean_symbol, clean_file) {
432        (Some(s), Some(f)) => (vec![s], Some(f), vec![]),
433        (Some(s), None) => (vec![s], None, vec![]),
434        (None, Some(f)) => (vec![], None, vec![f]),
435        (None, None) => {
436            // Zero arguments: discover uncommitted working tree changes via git status
437            let git_status = std::process::Command::new("git")
438                .args(["status", "--porcelain"])
439                .current_dir(&workspace.root)
440                .output();
441
442            if let Ok(output) = git_status
443                && output.status.success()
444            {
445                let stdout = String::from_utf8_lossy(&output.stdout);
446                for line in stdout.lines() {
447                    if line.len() > 3 {
448                        let path_part = line.get(3..).unwrap_or("").trim();
449                        let target = if let Some((_, to)) = path_part.split_once("->") {
450                            to.trim()
451                        } else {
452                            path_part
453                        };
454                        let p = target.trim_matches('"');
455                        let p_fwd = p.replace('\\', "/");
456                        if !p_fwd.is_empty() && !crate::workspace::is_hard_excluded(&p_fwd) {
457                            discovered.push(p_fwd);
458                        }
459                    }
460                }
461            }
462            (vec![], None, discovered)
463        }
464    };
465
466    let depth = if max_depth == 0 { 2 } else { max_depth.min(5) };
467    let seed_paths_refs: Vec<&str> = seed_paths.iter().map(|s| s.as_str()).collect();
468    let res = queries::compute_blast_radius_scoped(
469        conn,
470        &seed_symbols,
471        symbol_path_filter.as_deref(),
472        &seed_paths_refs,
473        depth,
474        limit,
475    )?;
476    Ok(res)
477}
478
479#[cfg(test)]
480mod tests {
481    use std::fs;
482
483    use rusqlite::Connection;
484
485    use super::codebase_outline_op;
486    use crate::workspace::Workspace;
487
488    #[test]
489    fn codebase_outline_accepts_absolute_workspace_root_filter() {
490        let temp = crate::safe_tempdir();
491        fs::write(temp.path().join("root.rs"), "pub fn root() {}\n").unwrap();
492        let workspace = Workspace::new(temp.path().to_path_buf());
493        let conn = Connection::open(temp.path().join("index.db")).unwrap();
494        conn.execute_batch(
495            "CREATE TABLE files (
496                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
497                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
498            );
499            CREATE TABLE symbols (
500                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
501                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
502                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
503                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
504                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
505                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
506                semantic_group TEXT, is_test INTEGER, test_container INTEGER
507            );
508            INSERT INTO files VALUES ('f', 'root.rs', 'rust', 'hash', 17, 1, 'now');
509            INSERT INTO symbols VALUES (
510                's', 'f', 'root.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
511                'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
512            );",
513        )
514        .unwrap();
515
516        let outline =
517            codebase_outline_op(&workspace, &conn, 1, Some(temp.path().to_str().unwrap())).unwrap();
518
519        assert!(outline.contains("root.rs"));
520
521        fs::create_dir(temp.path().join("src")).unwrap();
522        fs::write(temp.path().join("src/lib.rs"), "pub fn nested() {}\n").unwrap();
523        conn.execute_batch(
524            "INSERT INTO files VALUES ('nested-file', 'src/lib.rs', 'rust', 'hash', 19, 1, 'now');
525             INSERT INTO symbols VALUES (
526                'nested-symbol', 'nested-file', 'src/lib.rs', 'rust', 'nested', 'function',
527                'pub fn nested()', NULL, 'pub', NULL, 1, 0, 1, 18, 0, 18, 1, 0, 1, 18, 0, 18,
528                NULL, NULL, 0, 0
529             );",
530        )
531        .unwrap();
532
533        assert!(
534            codebase_outline_op(&workspace, &conn, 2, Some("."))
535                .unwrap()
536                .contains("root.rs")
537        );
538        assert!(
539            codebase_outline_op(&workspace, &conn, 1, Some("src"))
540                .unwrap()
541                .contains("lib.rs")
542        );
543        assert!(
544            codebase_outline_op(
545                &workspace,
546                &conn,
547                1,
548                Some(temp.path().join("src").to_str().unwrap()),
549            )
550            .unwrap()
551            .contains("lib.rs")
552        );
553        assert!(
554            codebase_outline_op(
555                &workspace,
556                &conn,
557                1,
558                Some(temp.path().parent().unwrap().to_str().unwrap()),
559            )
560            .is_err()
561        );
562    }
563
564    #[test]
565    fn codebase_outline_counts_unsupported_files() {
566        let temp = crate::safe_tempdir();
567        let workspace = Workspace::new(temp.path().to_path_buf());
568        let conn = Connection::open(temp.path().join("index.db")).unwrap();
569        conn.execute_batch(
570            "CREATE TABLE files (
571                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
572                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
573            );
574            CREATE TABLE symbols (
575                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
576                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
577                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
578                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
579                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
580                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
581                semantic_group TEXT, is_test INTEGER, test_container INTEGER
582            );
583            INSERT INTO files VALUES ('f1', 'src/lib.rs', 'rust', 'h', 0, 1, 'now', 'indexed');
584            INSERT INTO files VALUES ('f2', 'src/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
585            INSERT INTO files VALUES ('f3', 'docs/blob.bin', 'unknown', 'h', 0, 1, 'now', 'unsupported');
586            INSERT INTO files VALUES ('f4', 'src/assets/a.png', 'unknown', 'h', 0, 1, 'now', 'unsupported');
587            INSERT INTO symbols VALUES (
588                's', 'f1', 'src/lib.rs', 'rust', 'root', 'function', 'pub fn root()', NULL,
589                'pub', NULL, 1, 0, 1, 16, 0, 16, 1, 0, 1, 16, 0, 16, NULL, NULL, 0, 0
590            );",
591        )
592        .unwrap();
593
594        let all = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
595        assert!(all.contains("3 unsupported files"));
596        assert!(all.contains("docs/ (1 unsupported file)"), "{all}");
597        assert!(all.contains("src/ (2 unsupported files)"), "{all}");
598        assert!(!all.contains("assets/"), "{all}");
599
600        let scoped = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
601        assert!(scoped.contains("2 unsupported files:"), "{scoped}");
602    }
603
604    #[test]
605    fn codebase_outline_names_plain_files_and_shows_an_overload_once() {
606        let temp = crate::safe_tempdir();
607        let workspace = Workspace::new(temp.path().to_path_buf());
608        let conn = Connection::open(temp.path().join("index.db")).unwrap();
609        conn.execute_batch(
610            "CREATE TABLE files (
611                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
612                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
613            );
614            CREATE TABLE symbols (
615                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
616                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
617                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
618                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
619                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
620                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
621                semantic_group TEXT, is_test INTEGER, test_container INTEGER
622            );
623            INSERT INTO files VALUES ('f1', 'src/helpers.py', 'python', 'h', 0, 9, 'now', 'indexed');
624            INSERT INTO files VALUES ('f2', 'src/signals.py', 'python', 'h', 0, 3, 'now', 'indexed');
625            INSERT INTO files VALUES ('f3', 'src/py.typed', 'unknown', 'h', 0, 0, 'now', 'unsupported');
626            INSERT INTO symbols VALUES (
627                'a', 'f1', 'src/helpers.py', 'python', 'stream', 'function', 'def stream()', NULL,
628                NULL, NULL, 1, 0, 2, 0, 0, 10, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
629            );
630            INSERT INTO symbols VALUES (
631                'b', 'f1', 'src/helpers.py', 'python', 'stream', 'function', 'def stream(x)', NULL,
632                NULL, NULL, 4, 0, 5, 0, 11, 20, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
633            );
634            INSERT INTO symbols VALUES (
635                'c', 'f2', 'src/signals.py', 'python', 'started', 'variable', 'started = Signal()',
636                NULL, NULL, NULL, 1, 0, 1, 0, 0, 18, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
637            );",
638        )
639        .unwrap();
640
641        let out = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
642
643        assert!(out.contains("helpers.py [function stream]\n"), "{out}");
644        assert!(
645            out.contains("(1 file without functions or classes: signals.py)"),
646            "{out}"
647        );
648        assert!(!out.contains("py.typed"), "{out}");
649    }
650
651    #[test]
652    fn codebase_outline_lists_namespaced_classes_past_deep_files_and_caps_each_subfolder() {
653        let temp = crate::safe_tempdir();
654        let workspace = Workspace::new(temp.path().to_path_buf());
655        let conn = Connection::open(temp.path().join("index.db")).unwrap();
656        conn.execute_batch(
657            "CREATE TABLE files (
658                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
659                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT, status TEXT
660            );
661            CREATE TABLE symbols (
662                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
663                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
664                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
665                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
666                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
667                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
668                semantic_group TEXT, is_test INTEGER, test_container INTEGER
669            );
670            INSERT INTO files VALUES ('z', 'z/Late.cs', 'csharp', 'h', 0, 9, 'now', 'indexed');
671            INSERT INTO symbols VALUES (
672                'ns', 'z', 'z/Late.cs', 'csharp', 'App', 'namespace', 'namespace App', NULL,
673                NULL, NULL, 1, 0, 9, 0, 0, 90, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
674            );
675            INSERT INTO symbols VALUES (
676                'late', 'z', 'z/Late.cs', 'csharp', 'Late', 'class', 'class Late', NULL,
677                NULL, 'ns', 2, 0, 8, 0, 20, 80, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
678            );",
679        )
680        .unwrap();
681        let tx = conn.unchecked_transaction().unwrap();
682        for i in 0..1005 {
683            tx.execute(
684                "INSERT INTO files VALUES (?1, ?2, 'rust', 'h', 0, 1, 'now', 'indexed')",
685                rusqlite::params![format!("d{i}"), format!("a/deep/x/file_{i}.rs")],
686            )
687            .unwrap();
688        }
689        for i in 10..55 {
690            let path = format!("m/mod_{i}.rs");
691            tx.execute(
692                "INSERT INTO files VALUES (?1, ?2, 'rust', 'h', 0, 1, 'now', 'indexed')",
693                rusqlite::params![format!("m{i}"), path],
694            )
695            .unwrap();
696            tx.execute(
697                "INSERT INTO symbols VALUES (?1, ?2, ?3, 'rust', ?4, 'function', 'fn f()', NULL,
698                 NULL, NULL, 1, 0, 1, 0, 0, 9, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0)",
699                rusqlite::params![format!("s{i}"), format!("m{i}"), path, format!("f{i}")],
700            )
701            .unwrap();
702        }
703        tx.commit().unwrap();
704
705        let out = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
706
707        assert!(out.contains("deep/ (1005 indexed files)"), "{out}");
708        assert!(out.contains("Late.cs [class Late]"), "{out}");
709        assert!(out.contains("mod_49.rs [function f49]"), "{out}");
710        assert!(!out.contains("mod_50.rs"), "{out}");
711        assert!(
712            out.contains("(+5 more files with functions or classes; pass this folder as the path"),
713            "{out}"
714        );
715        assert!(!out.contains("truncated"), "{out}");
716
717        let scoped = codebase_outline_op(&workspace, &conn, 2, Some("m")).unwrap();
718        assert!(scoped.contains("mod_54.rs [function f54]"), "{scoped}");
719    }
720
721    #[test]
722    fn codebase_outline_truncates_over_1000_files() {
723        let temp = crate::safe_tempdir();
724        let workspace = Workspace::new(temp.path().to_path_buf());
725        let conn = Connection::open(temp.path().join("index.db")).unwrap();
726        conn.execute_batch(
727            "CREATE TABLE files (
728                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
729                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
730            );
731            CREATE TABLE symbols (
732                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
733                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
734                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
735                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
736                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
737                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
738                semantic_group TEXT, is_test INTEGER, test_container INTEGER
739            );",
740        )
741        .unwrap();
742
743        let tx = conn.unchecked_transaction().unwrap();
744        for i in 1..=1005 {
745            tx.execute(
746                "INSERT INTO files VALUES (?1, ?2, 'rust', 'hash', 10, 1, 'now')",
747                rusqlite::params![format!("f{i}"), format!("src/file_{i}.rs")],
748            )
749            .unwrap();
750        }
751        tx.commit().unwrap();
752
753        let outline = codebase_outline_op(&workspace, &conn, 2, None).unwrap();
754        assert!(outline.contains("[Outline truncated: workspace contains over 1,000 files."));
755
756        let scoped_outline = codebase_outline_op(&workspace, &conn, 2, Some("src")).unwrap();
757        assert!(scoped_outline.contains("[Outline truncated: path matches over 1,000 files."));
758    }
759}