Skip to main content

gobby_code/commands/
symbols.rs

1use std::collections::{BTreeMap, HashSet};
2
3use gobby_core::ai::{daemon::generate_via_daemon, effective_route, text::generate_text};
4use gobby_core::ai_context::{AiConfigSource, AiContext, PostgresAiConfigSource};
5use gobby_core::config::{AiCapability, AiRouting};
6
7use crate::commands::scope;
8use crate::config::{self, Context};
9use crate::db;
10use crate::index::languages;
11use crate::models::Symbol;
12use crate::output::{self, Format};
13use crate::savings;
14use crate::secrets;
15use crate::utils::short_id;
16use crate::visibility;
17
18const OUTLINE_SYSTEM_PROMPT: &str = "You write concise code outlines for developers. Return a compact natural-language outline focused on responsibilities, main symbols, and notable control flow. Do not include markdown fences.";
19const OUTLINE_SUMMARY_MAX_BYTES: u64 = 1024 * 1024;
20
21pub fn outline(
22    ctx: &Context,
23    file: &str,
24    format: Format,
25    verbose: bool,
26    summarize: bool,
27) -> anyhow::Result<()> {
28    let mut conn = db::connect_readonly(&ctx.database_url)?;
29    let file = scope::normalize_file_arg(ctx, file);
30    let symbols = visibility::visible_symbols_for_file(&mut conn, ctx, &file)?;
31
32    if symbols.is_empty() && !ctx.quiet {
33        eprintln!("{}", outline_missing_diagnostic(&mut conn, ctx, &file));
34    }
35
36    if summarize && let Some(summary) = summarize_outline(ctx, Some(&mut conn), &file, &symbols) {
37        return output::print_text(&summary);
38    }
39
40    // Report savings: outline bytes vs full file bytes
41    let file_path = ctx.project_root.join(&file);
42    if let Ok(meta) = file_path.metadata() {
43        let file_bytes = meta.len() as usize;
44        let outline_bytes: usize = symbols
45            .iter()
46            .map(|s| {
47                // Approximate outline size: name + kind + line numbers + signature
48                s.qualified_name.len()
49                    + s.kind.len()
50                    + s.signature.as_ref().map_or(0, |sig| sig.len())
51                    + 20 // line numbers, separators
52            })
53            .sum();
54        if outline_bytes > 0
55            && file_bytes > outline_bytes
56            && let Some(url) = savings::resolve_daemon_url(None)
57        {
58            savings::report_savings(&url, file_bytes, outline_bytes);
59        }
60    }
61
62    match format {
63        Format::Json => {
64            if verbose {
65                output::print_json(&symbols)
66            } else {
67                let slim: Vec<_> = symbols.iter().map(|s| s.to_outline()).collect();
68                output::print_json(&slim)
69            }
70        }
71        Format::Text => {
72            let outline = render_outline_text(&symbols);
73            if outline.is_empty() {
74                Ok(())
75            } else {
76                output::print_text(&outline)
77            }
78        }
79    }
80}
81
82fn summarize_outline(
83    ctx: &Context,
84    conn: Option<&mut postgres::Client>,
85    file: &str,
86    symbols: &[Symbol],
87) -> Option<String> {
88    let path = ctx.project_root.join(file);
89    let metadata = path.metadata().ok()?;
90    if metadata.len() > OUTLINE_SUMMARY_MAX_BYTES {
91        return None;
92    }
93    let code = std::fs::read_to_string(path).ok()?;
94    let ai_context = resolve_outline_ai_context(ctx, conn).ok()?;
95    let route = effective_route(&ai_context, AiCapability::TextGenerate);
96
97    summarize_outline_with(file, &code, symbols, |prompt, system| {
98        let result = match route {
99            AiRouting::Daemon => generate_via_daemon(&ai_context, prompt, Some(system)),
100            AiRouting::Direct => generate_text(&ai_context, prompt, Some(system)),
101            AiRouting::Off | AiRouting::Auto => return None,
102        };
103        result.ok().map(|result| result.text)
104    })
105}
106
107fn resolve_outline_ai_context(
108    ctx: &Context,
109    conn: Option<&mut postgres::Client>,
110) -> anyhow::Result<AiContext> {
111    let standalone = config::read_standalone_config_optional();
112    if let Some(conn) = conn {
113        let primary = PostgresAiConfigSource::new(conn, secrets::resolve_config_value);
114        let mut source = AiConfigSource::with_primary(primary, standalone);
115        return Ok(AiContext::resolve(
116            Some(ctx.project_id.clone()),
117            &mut source,
118        ));
119    }
120
121    let mut conn = db::connect_readonly(&ctx.database_url)?;
122    let primary = PostgresAiConfigSource::new(&mut conn, secrets::resolve_config_value);
123    let mut source = AiConfigSource::with_primary(primary, standalone);
124    Ok(AiContext::resolve(
125        Some(ctx.project_id.clone()),
126        &mut source,
127    ))
128}
129
130fn summarize_outline_with(
131    file: &str,
132    code: &str,
133    symbols: &[Symbol],
134    generate: impl FnOnce(&str, &str) -> Option<String>,
135) -> Option<String> {
136    if code.trim().is_empty() {
137        return None;
138    }
139    let prompt = outline_summary_prompt(file, code, symbols);
140    generate(&prompt, OUTLINE_SYSTEM_PROMPT).and_then(|summary| {
141        let summary = summary.trim();
142        (!summary.is_empty()).then(|| summary.to_string())
143    })
144}
145
146fn outline_summary_prompt(file: &str, code: &str, symbols: &[Symbol]) -> String {
147    let mut prompt = format!("File: {file}\n\nSymbols:\n");
148    if symbols.is_empty() {
149        prompt.push_str("- No indexed symbols.\n");
150    } else {
151        for symbol in symbols {
152            prompt.push_str(&format!(
153                "- {} [{}] lines {}-{}",
154                symbol.qualified_name, symbol.kind, symbol.line_start, symbol.line_end
155            ));
156            if let Some(signature) = symbol
157                .signature
158                .as_deref()
159                .filter(|value| !value.is_empty())
160            {
161                prompt.push_str(&format!(": {signature}"));
162            }
163            prompt.push('\n');
164        }
165    }
166    prompt.push_str("\nCode:\n");
167    prompt.push_str(code);
168    prompt
169}
170
171fn render_outline_text(symbols: &[Symbol]) -> String {
172    let parent_by_id = symbols
173        .iter()
174        .map(|symbol| (symbol.id.as_str(), symbol.parent_symbol_id.as_deref()))
175        .collect::<BTreeMap<_, _>>();
176
177    symbols
178        .iter()
179        .map(|s| {
180            let indent = "  ".repeat(outline_depth(s, &parent_by_id));
181            format!("{indent}{}", format_outline_text_line(s))
182        })
183        .collect::<Vec<_>>()
184        .join("\n")
185}
186
187fn outline_depth(symbol: &Symbol, parent_by_id: &BTreeMap<&str, Option<&str>>) -> usize {
188    let mut depth = 0;
189    let mut seen = HashSet::new();
190    let mut current = symbol.parent_symbol_id.as_deref();
191    while let Some(parent_id) = current {
192        if !seen.insert(parent_id) {
193            break;
194        }
195        let Some(parent_parent) = parent_by_id.get(parent_id) else {
196            break;
197        };
198        depth += 1;
199        current = *parent_parent;
200    }
201    depth
202}
203
204fn outline_missing_diagnostic(conn: &mut postgres::Client, ctx: &Context, file: &str) -> String {
205    if scope::path_exists_in_current_project(ctx, file) {
206        if visibility::indexed_file_exists(conn, ctx, file) {
207            if let Some(message) = unsupported_file_type_diagnostic(file) {
208                return message;
209            }
210            return format!("file has no indexed symbols in current project: {file}");
211        }
212        return format!("file not indexed in current project: {file}");
213    }
214
215    if let Some(owner) = scope::other_project_for_path(conn, ctx, file) {
216        return format!(
217            "path belongs to indexed project {} ({}); use --project {}",
218            owner.root_path,
219            short_id(&owner.id),
220            owner.root_path
221        );
222    }
223
224    if visibility::indexed_file_exists(conn, ctx, file)
225        || visibility::content_chunks_exist(conn, ctx, file)
226    {
227        return format!("indexed path missing from current checkout: {file}; run gcode index");
228    }
229
230    format!("file not indexed in current project: {file}")
231}
232
233fn unsupported_file_type_diagnostic(file: &str) -> Option<String> {
234    if languages::detect_language(file).is_some() {
235        return None;
236    }
237
238    Some(format!(
239        "file type has no AST parser support; indexed as text chunks only: {file}"
240    ))
241}
242
243fn format_outline_text_line(symbol: &Symbol) -> String {
244    let mut line = format!(
245        "{}:{}-{} [{}] {} id={}",
246        symbol.file_path,
247        symbol.line_start,
248        symbol.line_end,
249        symbol.kind,
250        symbol.qualified_name,
251        symbol.id
252    );
253    if let Some(sig) = symbol.signature.as_deref().filter(|sig| !sig.is_empty()) {
254        line.push_str(" sig=");
255        line.push_str(sig);
256    }
257    line
258}
259
260pub fn symbol(ctx: &Context, id: &str, format: Format) -> anyhow::Result<()> {
261    let mut conn = db::connect_readonly(&ctx.database_url)?;
262    let sym = visibility::visible_symbol_by_id(&mut conn, ctx, id)?;
263
264    match sym {
265        Some(s) => {
266            let file_path = ctx.project_root.join(&s.file_path);
267            if file_path.exists() {
268                let source = std::fs::read(&file_path)?;
269                let file_bytes = source.len();
270                let end = s.byte_end.min(source.len());
271                let start = s.byte_start.min(end);
272                let symbol_bytes = end - start;
273                let snippet = String::from_utf8_lossy(&source[start..end]);
274
275                // Report savings: symbol bytes vs full file bytes
276                if symbol_bytes > 0
277                    && file_bytes > symbol_bytes
278                    && let Some(url) = savings::resolve_daemon_url(None)
279                {
280                    savings::report_savings(&url, file_bytes, symbol_bytes);
281                }
282
283                match format {
284                    Format::Json => {
285                        let mut result = serde_json::to_value(&s)?;
286                        result["source"] = serde_json::Value::String(snippet.to_string());
287                        output::print_json(&result)
288                    }
289                    Format::Text => {
290                        println!("{snippet}");
291                        Ok(())
292                    }
293                }
294            } else {
295                match format {
296                    Format::Json => output::print_json(&s),
297                    Format::Text => {
298                        println!("{}: file not found on disk", s.file_path);
299                        Ok(())
300                    }
301                }
302            }
303        }
304        None => anyhow::bail!("Symbol not found in current project: {id}"),
305    }
306}
307
308pub fn symbols(ctx: &Context, ids: &[String], format: Format) -> anyhow::Result<()> {
309    let mut conn = db::connect_readonly(&ctx.database_url)?;
310    if ids.is_empty() {
311        return match format {
312            Format::Json => output::print_json(&Vec::<Symbol>::new()),
313            Format::Text => Ok(()),
314        };
315    }
316    let results = visibility::visible_symbols_by_ids(&mut conn, ctx, ids)?;
317
318    // Report aggregate savings across batch
319    let mut total_file_bytes = 0usize;
320    let mut total_symbol_bytes = 0usize;
321    for s in &results {
322        let file_path = ctx.project_root.join(&s.file_path);
323        if let Ok(meta) = file_path.metadata() {
324            total_file_bytes += meta.len() as usize;
325            total_symbol_bytes += s.byte_end - s.byte_start;
326        }
327    }
328    if total_symbol_bytes > 0
329        && total_file_bytes > total_symbol_bytes
330        && let Some(url) = savings::resolve_daemon_url(None)
331    {
332        savings::report_savings(&url, total_file_bytes, total_symbol_bytes);
333    }
334
335    match format {
336        Format::Json => output::print_json(&results),
337        Format::Text => {
338            for s in &results {
339                println!(
340                    "{}:{} [{}] {}",
341                    s.file_path, s.line_start, s.kind, s.qualified_name
342                );
343            }
344            Ok(())
345        }
346    }
347}
348
349pub fn kinds(ctx: &Context, format: Format) -> anyhow::Result<()> {
350    let mut conn = db::connect_readonly(&ctx.database_url)?;
351    let kinds = visibility::visible_kinds(&mut conn, ctx)?;
352
353    match format {
354        Format::Json => output::print_json(&kinds),
355        Format::Text => {
356            for k in &kinds {
357                println!("{k}");
358            }
359            Ok(())
360        }
361    }
362}
363
364pub fn tree(ctx: &Context, format: Format) -> anyhow::Result<()> {
365    let mut conn = db::connect_readonly(&ctx.database_url)?;
366    let files: Vec<serde_json::Value> = visibility::visible_tree(&mut conn, ctx)?
367        .into_iter()
368        .map(|file| {
369            serde_json::json!({
370                "file_path": file.file_path,
371                "language": file.language,
372                "symbol_count": file.symbol_count,
373            })
374        })
375        .collect();
376
377    match format {
378        Format::Json => output::print_json(&files),
379        Format::Text => {
380            let text = format_tree_text(&files);
381            if text.is_empty() {
382                Ok(())
383            } else {
384                output::print_text(&text)
385            }
386        }
387    }
388}
389
390/// Format file summary rows as a directory tree.
391///
392/// Paths are grouped by their directory (`dir`) and displayed by filename
393/// (`basename`). Root-level files are grouped under `.`, a leading `/` is
394/// stripped for root files, and entries render as
395/// `  {basename} [{language}] ({symbol_count} symbols)`.
396fn format_tree_text(files: &[serde_json::Value]) -> String {
397    let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
398
399    for file in files {
400        let file_path = file["file_path"].as_str().unwrap_or("");
401        let language = file["language"].as_str().unwrap_or("");
402        let symbol_count = file["symbol_count"].as_i64().unwrap_or(0);
403        let (dir, basename) = file_path
404            .rsplit_once('/')
405            .map(|(dir, basename)| {
406                let dir = if dir.is_empty() { "." } else { dir };
407                (dir, basename)
408            })
409            .filter(|(_, basename)| !basename.is_empty())
410            .unwrap_or((".", file_path.trim_start_matches('/')));
411
412        groups.entry(dir.to_string()).or_default().push(format!(
413            "  {basename} [{language}] ({symbol_count} symbols)"
414        ));
415    }
416
417    let mut lines = Vec::new();
418    for (dir, entries) in groups {
419        lines.push(dir);
420        lines.extend(entries);
421    }
422    lines.join("\n")
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    fn symbol() -> Symbol {
430        Symbol {
431            id: "12345678-1234-5678-1234-567812345678".to_string(),
432            project_id: "current-project".to_string(),
433            file_path: "src/commands.rs".to_string(),
434            name: "outline".to_string(),
435            qualified_name: "outline".to_string(),
436            kind: "function".to_string(),
437            language: "rust".to_string(),
438            byte_start: 0,
439            byte_end: 10,
440            line_start: 7,
441            line_end: 63,
442            signature: Some("pub fn outline() -> anyhow::Result<()> {".to_string()),
443            docstring: None,
444            parent_symbol_id: None,
445            content_hash: String::new(),
446            summary: None,
447            created_at: String::new(),
448            updated_at: String::new(),
449        }
450    }
451
452    #[test]
453    fn outline_text_line_includes_id_range_and_signature() {
454        let line = format_outline_text_line(&symbol());
455
456        assert!(line.contains("src/commands.rs:7-63 [function] outline"));
457        assert!(line.contains("id=12345678-1234-5678-1234-567812345678"));
458        assert!(line.contains("sig=pub fn outline() -> anyhow::Result<()> {"));
459    }
460
461    #[test]
462    fn outline_text_indents_by_parent_chain_depth() {
463        let mut parent = symbol();
464        parent.id = "parent".to_string();
465        parent.kind = "class".to_string();
466        parent.qualified_name = "Parent".to_string();
467
468        let mut child = symbol();
469        child.id = "child".to_string();
470        child.parent_symbol_id = Some(parent.id.clone());
471        child.qualified_name = "Parent.child".to_string();
472
473        let mut grandchild = symbol();
474        grandchild.id = "grandchild".to_string();
475        grandchild.parent_symbol_id = Some(child.id.clone());
476        grandchild.qualified_name = "Parent.child.grandchild".to_string();
477
478        let outline = render_outline_text(&[parent, child, grandchild]);
479        let lines = outline.lines().collect::<Vec<_>>();
480
481        assert!(lines[0].starts_with("src/commands.rs:"));
482        assert!(lines[1].starts_with("  src/commands.rs:"));
483        assert!(lines[2].starts_with("    src/commands.rs:"));
484    }
485
486    #[test]
487    fn unsupported_file_type_diagnostic_mentions_text_only_indexing() {
488        assert_eq!(
489            unsupported_file_type_diagnostic("Dockerfile"),
490            Some(
491                "file type has no AST parser support; indexed as text chunks only: Dockerfile"
492                    .to_string()
493            )
494        );
495        assert_eq!(unsupported_file_type_diagnostic("src/lib.rs"), None);
496    }
497
498    #[test]
499    fn summarizes_when_configured() {
500        let symbols = vec![symbol()];
501        let summary = summarize_outline_with(
502            "src/commands.rs",
503            "pub fn outline() -> anyhow::Result<()> { Ok(()) }",
504            &symbols,
505            |prompt, system| {
506                assert_eq!(system, OUTLINE_SYSTEM_PROMPT);
507                assert!(prompt.contains("File: src/commands.rs"));
508                assert!(prompt.contains("Symbols:"));
509                assert!(prompt.contains("outline [function] lines 7-63"));
510                assert!(prompt.contains("Code:"));
511                assert!(prompt.contains("pub fn outline()"));
512                Some("Natural-language outline".to_string())
513            },
514        );
515
516        assert_eq!(summary, Some("Natural-language outline".to_string()));
517    }
518
519    #[test]
520    fn outline_summary_size_cap_is_one_mib() {
521        assert_eq!(OUTLINE_SUMMARY_MAX_BYTES, 1024 * 1024);
522    }
523
524    #[test]
525    fn degrades_to_ast() {
526        let symbols = vec![symbol()];
527        let ast_outline = render_outline_text(&symbols);
528        let output = summarize_outline_with(
529            "src/commands.rs",
530            "pub fn outline() -> anyhow::Result<()> { Ok(()) }",
531            &symbols,
532            |_prompt, _system| None,
533        )
534        .unwrap_or(ast_outline.clone());
535
536        assert_eq!(output, ast_outline);
537    }
538
539    #[test]
540    fn tree_text_groups_files_by_directory() {
541        let files = vec![
542            serde_json::json!({
543                "file_path": "README.md",
544                "language": "markdown",
545                "symbol_count": 0,
546            }),
547            serde_json::json!({
548                "file_path": "src/commands/grep.rs",
549                "language": "rust",
550                "symbol_count": 7,
551            }),
552            serde_json::json!({
553                "file_path": "src/lib.rs",
554                "language": "rust",
555                "symbol_count": 3,
556            }),
557        ];
558
559        assert_eq!(
560            format_tree_text(&files),
561            ".\n  README.md [markdown] (0 symbols)\nsrc\n  lib.rs [rust] (3 symbols)\nsrc/commands\n  grep.rs [rust] (7 symbols)"
562        );
563    }
564
565    #[test]
566    fn tree_text_treats_absolute_root_files_as_root_group() {
567        let files = vec![serde_json::json!({
568            "file_path": "/lib.rs",
569            "language": "rust",
570            "symbol_count": 1,
571        })];
572
573        assert_eq!(format_tree_text(&files), ".\n  lib.rs [rust] (1 symbols)");
574    }
575}