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 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 s.qualified_name.len()
41 + s.kind.len()
42 + s.signature.as_ref().map_or(0, |sig| sig.len())
43 + 20 })
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 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, ¶ms)?
199 .iter()
200 .filter_map(|row| Symbol::from_row(row).ok())
201 .collect();
202
203 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}