Skip to main content

gobby_code/commands/
symbols.rs

1use std::collections::BTreeMap;
2
3use crate::commands::scope;
4use crate::config::Context;
5use crate::db;
6use crate::models::Symbol;
7use crate::output::{self, Format};
8use crate::savings;
9use crate::utils::short_id;
10
11pub fn outline(ctx: &Context, file: &str, format: Format, verbose: bool) -> anyhow::Result<()> {
12    let mut conn = db::connect_readonly(&ctx.database_url)?;
13    let file = scope::normalize_file_arg(ctx, file);
14    let columns = db::symbol_select_columns("");
15    let symbols: Vec<Symbol> = conn
16        .query(
17            &format!(
18                "SELECT {columns} FROM code_symbols
19                 WHERE project_id = $1 AND file_path = $2
20                 ORDER BY line_start"
21            ),
22            &[&ctx.project_id, &file],
23        )?
24        .iter()
25        .filter_map(|row| Symbol::from_row(row).ok())
26        .collect();
27
28    if symbols.is_empty() && !ctx.quiet {
29        eprintln!("{}", outline_missing_diagnostic(&mut conn, ctx, &file));
30    }
31
32    // Report savings: outline bytes vs full file bytes
33    let file_path = ctx.project_root.join(&file);
34    if let Ok(meta) = file_path.metadata() {
35        let file_bytes = meta.len() as usize;
36        let outline_bytes: usize = symbols
37            .iter()
38            .map(|s| {
39                // Approximate outline size: name + kind + line numbers + signature
40                s.qualified_name.len()
41                    + s.kind.len()
42                    + s.signature.as_ref().map_or(0, |sig| sig.len())
43                    + 20 // line numbers, separators
44            })
45            .sum();
46        if outline_bytes > 0
47            && file_bytes > outline_bytes
48            && let Some(url) = savings::resolve_daemon_url(None)
49        {
50            savings::report_savings(&url, file_bytes, outline_bytes);
51        }
52    }
53
54    match format {
55        Format::Json => {
56            if verbose {
57                output::print_json(&symbols)
58            } else {
59                let slim: Vec<_> = symbols.iter().map(|s| s.to_outline()).collect();
60                output::print_json(&slim)
61            }
62        }
63        Format::Text => {
64            for s in &symbols {
65                let indent = if s.parent_symbol_id.is_some() {
66                    "  "
67                } else {
68                    ""
69                };
70                println!("{indent}{}", format_outline_text_line(s));
71            }
72            Ok(())
73        }
74    }
75}
76
77fn outline_missing_diagnostic(conn: &mut postgres::Client, ctx: &Context, file: &str) -> String {
78    if scope::path_exists_in_current_project(ctx, file) {
79        if scope::indexed_file_exists(conn, &ctx.project_id, file) {
80            return format!("file has no indexed symbols in current project: {file}");
81        }
82        return format!("file not indexed in current project: {file}");
83    }
84
85    if let Some(owner) = scope::other_project_for_path(conn, ctx, file) {
86        return format!(
87            "path belongs to indexed project {} ({}); use --project {}",
88            owner.root_path,
89            short_id(&owner.id),
90            owner.root_path
91        );
92    }
93
94    if scope::indexed_file_exists(conn, &ctx.project_id, file)
95        || scope::content_chunks_exist(conn, &ctx.project_id, file)
96    {
97        return format!("indexed path missing from current checkout: {file}; run gcode index");
98    }
99
100    format!("file not indexed in current project: {file}")
101}
102
103fn format_outline_text_line(symbol: &Symbol) -> String {
104    let mut line = format!(
105        "{}:{}-{} [{}] {} id={}",
106        symbol.file_path,
107        symbol.line_start,
108        symbol.line_end,
109        symbol.kind,
110        symbol.qualified_name,
111        symbol.id
112    );
113    if let Some(sig) = symbol.signature.as_deref().filter(|sig| !sig.is_empty()) {
114        line.push_str(" sig=");
115        line.push_str(sig);
116    }
117    line
118}
119
120pub fn symbol(ctx: &Context, id: &str, format: Format) -> anyhow::Result<()> {
121    let mut conn = db::connect_readonly(&ctx.database_url)?;
122    let columns = db::symbol_select_columns("");
123    let sym: Option<Symbol> = conn
124        .query_opt(
125            &format!("SELECT {columns} FROM code_symbols WHERE id = $1 AND project_id = $2"),
126            &[&id, &ctx.project_id],
127        )
128        .ok()
129        .flatten()
130        .and_then(|row| Symbol::from_row(&row).ok());
131
132    match sym {
133        Some(s) => {
134            let file_path = ctx.project_root.join(&s.file_path);
135            if file_path.exists() {
136                let source = std::fs::read(&file_path)?;
137                let file_bytes = source.len();
138                let end = s.byte_end.min(source.len());
139                let start = s.byte_start.min(end);
140                let symbol_bytes = end - start;
141                let snippet = String::from_utf8_lossy(&source[start..end]);
142
143                // Report savings: symbol bytes vs full file bytes
144                if symbol_bytes > 0
145                    && file_bytes > symbol_bytes
146                    && let Some(url) = savings::resolve_daemon_url(None)
147                {
148                    savings::report_savings(&url, file_bytes, symbol_bytes);
149                }
150
151                match format {
152                    Format::Json => {
153                        let mut result = serde_json::to_value(&s)?;
154                        result["source"] = serde_json::Value::String(snippet.to_string());
155                        output::print_json(&result)
156                    }
157                    Format::Text => {
158                        println!("{snippet}");
159                        Ok(())
160                    }
161                }
162            } else {
163                match format {
164                    Format::Json => output::print_json(&s),
165                    Format::Text => {
166                        println!("{}: file not found on disk", s.file_path);
167                        Ok(())
168                    }
169                }
170            }
171        }
172        None => anyhow::bail!("Symbol not found in current project: {id}"),
173    }
174}
175
176pub fn symbols(ctx: &Context, ids: &[String], format: Format) -> anyhow::Result<()> {
177    let mut conn = db::connect_readonly(&ctx.database_url)?;
178    if ids.is_empty() {
179        return match format {
180            Format::Json => output::print_json(&Vec::<Symbol>::new()),
181            Format::Text => Ok(()),
182        };
183    }
184    let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("${i}")).collect();
185    let project_placeholder = format!("${}", ids.len() + 1);
186    let columns = db::symbol_select_columns("");
187    let sql = format!(
188        "SELECT {columns} FROM code_symbols
189         WHERE id IN ({}) AND project_id = {project_placeholder}",
190        placeholders.join(",")
191    );
192    let mut params: Vec<&(dyn postgres::types::ToSql + Sync)> = ids
193        .iter()
194        .map(|s| s as &(dyn postgres::types::ToSql + Sync))
195        .collect();
196    params.push(&ctx.project_id);
197    let results: Vec<Symbol> = conn
198        .query(&sql, &params)?
199        .iter()
200        .filter_map(|row| Symbol::from_row(row).ok())
201        .collect();
202
203    // Report aggregate savings across batch
204    let mut total_file_bytes = 0usize;
205    let mut total_symbol_bytes = 0usize;
206    for s in &results {
207        let file_path = ctx.project_root.join(&s.file_path);
208        if let Ok(meta) = file_path.metadata() {
209            total_file_bytes += meta.len() as usize;
210            total_symbol_bytes += s.byte_end - s.byte_start;
211        }
212    }
213    if total_symbol_bytes > 0
214        && total_file_bytes > total_symbol_bytes
215        && let Some(url) = savings::resolve_daemon_url(None)
216    {
217        savings::report_savings(&url, total_file_bytes, total_symbol_bytes);
218    }
219
220    match format {
221        Format::Json => output::print_json(&results),
222        Format::Text => {
223            for s in &results {
224                println!(
225                    "{}:{} [{}] {}",
226                    s.file_path, s.line_start, s.kind, s.qualified_name
227                );
228            }
229            Ok(())
230        }
231    }
232}
233
234pub fn kinds(ctx: &Context, format: Format) -> anyhow::Result<()> {
235    let mut conn = db::connect_readonly(&ctx.database_url)?;
236    let kinds: Vec<String> = conn
237        .query(
238            "SELECT DISTINCT kind FROM code_symbols WHERE project_id = $1 ORDER BY kind",
239            &[&ctx.project_id],
240        )?
241        .iter()
242        .filter_map(|row| row.try_get(0).ok())
243        .collect();
244
245    match format {
246        Format::Json => output::print_json(&kinds),
247        Format::Text => {
248            for k in &kinds {
249                println!("{k}");
250            }
251            Ok(())
252        }
253    }
254}
255
256pub fn tree(ctx: &Context, format: Format) -> anyhow::Result<()> {
257    let mut conn = db::connect_readonly(&ctx.database_url)?;
258    let files: Vec<serde_json::Value> = conn
259        .query(
260            "SELECT file_path, language, symbol_count::BIGINT AS symbol_count
261             FROM code_indexed_files
262             WHERE project_id = $1 ORDER BY file_path",
263            &[&ctx.project_id],
264        )?
265        .iter()
266        .filter_map(|row| {
267            Some(serde_json::json!({
268                "file_path": row.try_get::<_, String>("file_path").ok()?,
269                "language": row.try_get::<_, String>("language").ok()?,
270                "symbol_count": row.try_get::<_, i64>("symbol_count").ok()?,
271            }))
272        })
273        .collect();
274
275    match format {
276        Format::Json => output::print_json(&files),
277        Format::Text => {
278            let text = format_tree_text(&files);
279            if text.is_empty() {
280                Ok(())
281            } else {
282                output::print_text(&text)
283            }
284        }
285    }
286}
287
288fn format_tree_text(files: &[serde_json::Value]) -> String {
289    let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
290
291    for file in files {
292        let file_path = file["file_path"].as_str().unwrap_or("");
293        let language = file["language"].as_str().unwrap_or("");
294        let symbol_count = file["symbol_count"].as_i64().unwrap_or(0);
295        let (dir, basename) = file_path
296            .rsplit_once('/')
297            .filter(|(dir, basename)| !dir.is_empty() && !basename.is_empty())
298            .unwrap_or((".", file_path));
299
300        groups.entry(dir.to_string()).or_default().push(format!(
301            "  {basename} [{language}] ({symbol_count} symbols)"
302        ));
303    }
304
305    let mut lines = Vec::new();
306    for (dir, entries) in groups {
307        lines.push(dir);
308        lines.extend(entries);
309    }
310    lines.join("\n")
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn symbol() -> Symbol {
318        Symbol {
319            id: "12345678-1234-5678-1234-567812345678".to_string(),
320            project_id: "current-project".to_string(),
321            file_path: "src/commands.rs".to_string(),
322            name: "outline".to_string(),
323            qualified_name: "outline".to_string(),
324            kind: "function".to_string(),
325            language: "rust".to_string(),
326            byte_start: 0,
327            byte_end: 10,
328            line_start: 7,
329            line_end: 63,
330            signature: Some("pub fn outline() -> anyhow::Result<()> {".to_string()),
331            docstring: None,
332            parent_symbol_id: None,
333            content_hash: String::new(),
334            summary: None,
335            created_at: String::new(),
336            updated_at: String::new(),
337        }
338    }
339
340    #[test]
341    fn outline_text_line_includes_id_range_and_signature() {
342        let line = format_outline_text_line(&symbol());
343
344        assert!(line.contains("src/commands.rs:7-63 [function] outline"));
345        assert!(line.contains("id=12345678-1234-5678-1234-567812345678"));
346        assert!(line.contains("sig=pub fn outline() -> anyhow::Result<()> {"));
347    }
348
349    #[test]
350    fn tree_text_groups_files_by_directory() {
351        let files = vec![
352            serde_json::json!({
353                "file_path": "README.md",
354                "language": "markdown",
355                "symbol_count": 0,
356            }),
357            serde_json::json!({
358                "file_path": "src/commands/grep.rs",
359                "language": "rust",
360                "symbol_count": 7,
361            }),
362            serde_json::json!({
363                "file_path": "src/lib.rs",
364                "language": "rust",
365                "symbol_count": 3,
366            }),
367        ];
368
369        assert_eq!(
370            format_tree_text(&files),
371            ".\n  README.md [markdown] (0 symbols)\nsrc\n  lib.rs [rust] (3 symbols)\nsrc/commands\n  grep.rs [rust] (7 symbols)"
372        );
373    }
374}