Skip to main content

gobby_code/commands/
symbols.rs

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