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