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