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