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