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