Skip to main content

gobby_code/commands/
status.rs

1use std::collections::{BTreeMap, HashSet};
2use std::fmt::Write as _;
3use std::path::{Path, PathBuf};
4
5use crate::config;
6use crate::config::Context;
7use crate::db;
8use crate::graph::code_graph;
9use crate::index::indexer;
10use crate::models::IndexedProject;
11use crate::output::{self, Format};
12use crate::utils::short_id;
13use crate::vector::code_symbols;
14use crate::visibility;
15
16/// Format a `last_indexed_at` value for display.
17/// Handles both epoch seconds ("1774970556") and ISO 8601 ("2026-03-29T18:52:25.750230+00:00").
18fn format_timestamp(raw: &str) -> String {
19    if raw.is_empty() {
20        return "never".to_string();
21    }
22
23    // Try epoch seconds first (all digits)
24    if let Ok(epoch) = raw.parse::<i64>() {
25        let secs = epoch % 60;
26        let mins = (epoch / 60) % 60;
27        let hours = (epoch / 3600) % 24;
28        let days = epoch / 86400;
29
30        // Simple date calculation from days since epoch
31        let (year, month, day) = days_to_ymd(days);
32        return format!("{year:04}-{month:02}-{day:02} {hours:02}:{mins:02}:{secs:02} UTC");
33    }
34
35    // Try ISO 8601 — extract the date/time portion before any fractional seconds or timezone
36    if raw.len() >= 19 && raw.as_bytes().get(4) == Some(&b'-') {
37        let base = &raw[..19]; // "2026-03-29T18:52:25"
38        return base.replace('T', " ");
39    }
40
41    raw.to_string()
42}
43
44/// Convert days since Unix epoch to (year, month, day).
45fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
46    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
47    days += 719468;
48    let era = if days >= 0 { days } else { days - 146096 } / 146097;
49    let doe = days - era * 146097; // day of era [0, 146096]
50    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year of era [0, 399]
51    let y = yoe + era * 400;
52    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
53    let mp = (5 * doy + 2) / 153; // [0, 11]
54    let d = doy - (153 * mp + 2) / 5 + 1; // day [1, 31]
55    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
56    let y = if m <= 2 { y + 1 } else { y };
57    (y, m, d)
58}
59
60pub fn run(ctx: &Context, format: Format) -> anyhow::Result<()> {
61    let mut conn = db::connect_readonly(&ctx.database_url)?;
62
63    let stats: Option<IndexedProject> = conn
64        .query_opt(
65            "SELECT id,
66                    root_path,
67                    total_files::BIGINT AS total_files,
68                    total_symbols::BIGINT AS total_symbols,
69                    last_indexed_at::TEXT AS last_indexed_at,
70                    COALESCE(index_duration_ms, 0)::BIGINT AS index_duration_ms,
71                    NULL::BIGINT AS total_eligible_files
72             FROM code_indexed_projects WHERE id = $1",
73            &[&ctx.project_id],
74        )
75        .ok()
76        .flatten()
77        .and_then(|row| indexed_project_from_row(&row).ok());
78
79    match stats {
80        Some(s) => match format {
81            Format::Json => {
82                let mut value = serde_json::to_value(&s)?;
83                if let Some(overlay) = overlay_status_json(ctx, &mut conn) {
84                    value["overlay"] = overlay;
85                }
86                output::print_json(&value)
87            }
88            Format::Text => {
89                let name = Path::new(&s.root_path)
90                    .file_name()
91                    .map(|n| n.to_string_lossy().to_string())
92                    .unwrap_or_else(|| s.id.clone());
93                let mut text = String::new();
94                writeln!(text, "{} ({})", name, short_id(&s.id))?;
95                writeln!(text, "  Root:     {}", s.root_path)?;
96                writeln!(
97                    text,
98                    "  Files:    {}",
99                    format_coverage(s.total_files, s.total_eligible_files)
100                )?;
101                writeln!(text, "  Symbols:  {}", s.total_symbols)?;
102                writeln!(text, "  Indexed:  {}", format_timestamp(&s.last_indexed_at))?;
103                write!(text, "  Duration: {}ms", s.index_duration_ms)?;
104                if let crate::config::ProjectIndexScope::Overlay {
105                    parent_project_id,
106                    parent_root,
107                    ..
108                } = &ctx.index_scope
109                {
110                    writeln!(text)?;
111                    write!(
112                        text,
113                        "  Overlay:  parent {} ({})",
114                        parent_root.display(),
115                        short_id(parent_project_id)
116                    )?;
117                    let tombstones = visibility::tombstone_count(&mut conn, ctx);
118                    if tombstones > 0 {
119                        writeln!(text)?;
120                        write!(text, "  Deletes:  {tombstones}")?;
121                    }
122                }
123                output::print_text(&text)
124            }
125        },
126        None => {
127            eprintln!(
128                "No index found for project {}. Run `gcode index` first.",
129                ctx.project_id
130            );
131            Ok(())
132        }
133    }
134}
135
136fn overlay_status_json(ctx: &Context, conn: &mut postgres::Client) -> Option<serde_json::Value> {
137    let crate::config::ProjectIndexScope::Overlay {
138        overlay_project_id,
139        overlay_root,
140        parent_project_id,
141        parent_root,
142    } = &ctx.index_scope
143    else {
144        return None;
145    };
146
147    let tombstones = visibility::tombstone_count(conn, ctx);
148    let mut overlay = serde_json::json!({
149        "overlay_project_id": overlay_project_id,
150        "overlay_root": overlay_root,
151        "parent_project_id": parent_project_id,
152        "parent_root": parent_root,
153    });
154    if tombstones > 0 {
155        overlay["tombstones"] = serde_json::json!(tombstones);
156    }
157    Some(overlay)
158}
159
160pub fn invalidate(ctx: &Context, force: bool) -> anyhow::Result<()> {
161    if !force {
162        let project_name = ctx
163            .project_root
164            .file_name()
165            .map(|n| n.to_string_lossy().to_string())
166            .unwrap_or_else(|| ctx.project_id.clone());
167
168        eprint!(
169            "This will clear the entire code index for '{}'. Continue? [y/N] ",
170            project_name
171        );
172        let _ = std::io::Write::flush(&mut std::io::stderr());
173
174        let mut input = String::new();
175        std::io::stdin().read_line(&mut input)?;
176        if !input.trim().eq_ignore_ascii_case("y") {
177            eprintln!("Aborted.");
178            return Ok(());
179        }
180    }
181
182    let mut conn = db::connect_readwrite(&ctx.database_url)?;
183    indexer::invalidate(&mut conn, &ctx.project_id, ctx.daemon_url.as_deref())?;
184    cleanup_project_projections(ctx)
185}
186
187fn cleanup_project_projections(ctx: &Context) -> anyhow::Result<()> {
188    if ctx.falkordb.is_some() {
189        code_graph::clear_project(ctx)
190            .map_err(|err| anyhow::anyhow!("failed to clear FalkorDB projection: {err}"))?;
191    }
192    if let Some(qdrant) = &ctx.qdrant {
193        code_symbols::delete_project_collection(qdrant, &ctx.project_id)
194            .map_err(|err| anyhow::anyhow!("failed to delete Qdrant projection: {err}"))?;
195    }
196    Ok(())
197}
198
199/// Collect indexed projects from the PostgreSQL hub.
200fn collect_projects() -> anyhow::Result<Vec<IndexedProject>> {
201    let database_url = db::resolve_database_url()?;
202    let mut conn = db::connect_readonly(&database_url)?;
203    let mut seen_ids = std::collections::HashSet::new();
204    let mut all = Vec::new();
205    let rows = conn.query(
206        "SELECT id,
207                root_path,
208                total_files::BIGINT AS total_files,
209                total_symbols::BIGINT AS total_symbols,
210                last_indexed_at::TEXT AS last_indexed_at,
211                COALESCE(index_duration_ms, 0)::BIGINT AS index_duration_ms,
212                NULL::BIGINT AS total_eligible_files
213         FROM code_indexed_projects
214         ORDER BY last_indexed_at DESC NULLS LAST",
215        &[],
216    )?;
217
218    for row in rows {
219        if let Ok(project) = indexed_project_from_row(&row)
220            && seen_ids.insert(project.id.clone())
221        {
222            all.push(project);
223        }
224    }
225
226    Ok(all)
227}
228
229fn indexed_project_from_row(row: &postgres::Row) -> anyhow::Result<IndexedProject> {
230    Ok(IndexedProject {
231        id: row.try_get("id")?,
232        root_path: row.try_get("root_path")?,
233        total_files: row.try_get::<_, i64>("total_files")? as usize,
234        total_symbols: row.try_get::<_, i64>("total_symbols")? as usize,
235        last_indexed_at: row
236            .try_get::<_, Option<String>>("last_indexed_at")?
237            .unwrap_or_default(),
238        index_duration_ms: row.try_get::<_, i64>("index_duration_ms")? as u64,
239        total_eligible_files: row
240            .try_get::<_, Option<i64>>("total_eligible_files")
241            .ok()
242            .flatten()
243            .map(|n| n as usize),
244    })
245}
246
247/// Format file count with optional coverage percentage.
248fn format_coverage(indexed: usize, eligible: Option<usize>) -> String {
249    match eligible {
250        Some(total) if total > 0 => {
251            let pct = (indexed as f64 / total as f64 * 100.0) as usize;
252            format!("{indexed}/{total} ({pct}%)")
253        }
254        _ => format!("{indexed}"),
255    }
256}
257
258/// Format a project name for display.
259fn display_name(p: &IndexedProject) -> String {
260    if p.root_path.is_empty() || !Path::new(&p.root_path).is_absolute() {
261        return format!("<unknown> ({})", p.id);
262    }
263    let basename = Path::new(&p.root_path)
264        .file_name()
265        .map(|n| n.to_string_lossy().to_string())
266        .unwrap_or_else(|| p.id.clone());
267    format!("{basename} ({})", short_id(&p.id))
268}
269
270/// List all indexed projects from the PostgreSQL hub.
271pub fn projects(format: Format) -> anyhow::Result<()> {
272    let all_projects = collect_projects()?;
273
274    match format {
275        Format::Json => output::print_json(&all_projects),
276        Format::Text => {
277            if all_projects.is_empty() {
278                eprintln!("No indexed projects. Run `gcode init` in a project directory.");
279            } else {
280                for p in &all_projects {
281                    println!("{} — {}", display_name(p), p.root_path);
282                    println!(
283                        "  {} files, {} symbols | Last indexed: {}",
284                        format_coverage(p.total_files, p.total_eligible_files),
285                        p.total_symbols,
286                        format_timestamp(&p.last_indexed_at)
287                    );
288                }
289            }
290            Ok(())
291        }
292    }
293}
294
295/// Check if a project entry is stale.
296fn is_stale(p: &IndexedProject) -> Option<&'static str> {
297    if p.id.starts_with("00000000") {
298        return Some("sentinel project (not a code project)");
299    }
300    if p.root_path.is_empty() {
301        return Some("empty root path");
302    }
303    if !Path::new(&p.root_path).is_absolute() {
304        return Some("relative root path");
305    }
306    if !Path::new(&p.root_path).exists() {
307        return Some("path does not exist");
308    }
309    None
310}
311
312#[derive(Debug)]
313struct StaleProject<'a> {
314    project: &'a IndexedProject,
315    reason: String,
316}
317
318fn stale_projects(projects: &[IndexedProject]) -> Vec<StaleProject<'_>> {
319    let mut stale = Vec::new();
320    let mut stale_ids = HashSet::new();
321
322    for project in projects {
323        if let Some(reason) = is_stale(project) {
324            stale_ids.insert(project.id.clone());
325            stale.push(StaleProject {
326                project,
327                reason: reason.to_string(),
328            });
329        }
330    }
331
332    let mut by_root: BTreeMap<PathBuf, Vec<&IndexedProject>> = BTreeMap::new();
333    for project in projects {
334        if stale_ids.contains(&project.id) {
335            continue;
336        }
337        let Ok(canonical_root) = Path::new(&project.root_path).canonicalize() else {
338            continue;
339        };
340        by_root.entry(canonical_root).or_default().push(project);
341    }
342
343    for (root, entries) in by_root {
344        if entries.len() < 2 {
345            continue;
346        }
347        let Ok(identity) = config::resolve_project_identity(&root, config::MissingIdentity::Error)
348        else {
349            continue;
350        };
351        if !entries
352            .iter()
353            .any(|project| project.id == identity.project_id)
354        {
355            continue;
356        }
357        for project in entries {
358            if project.id == identity.project_id || !stale_ids.insert(project.id.clone()) {
359                continue;
360            }
361            stale.push(StaleProject {
362                project,
363                reason: format!(
364                    "duplicate root superseded by current project id {}",
365                    short_id(&identity.project_id)
366                ),
367            });
368        }
369    }
370
371    stale
372}
373
374/// Remove stale project entries from the code index.
375pub fn prune(force: bool) -> anyhow::Result<()> {
376    let all_projects = collect_projects()?;
377    let stale = stale_projects(&all_projects);
378
379    if stale.is_empty() {
380        eprintln!("No stale projects found.");
381        return Ok(());
382    }
383
384    eprintln!("Found {} stale project(s):", stale.len());
385    for stale_project in &stale {
386        eprintln!(
387            "  {} — {}",
388            display_name(stale_project.project),
389            stale_project.reason
390        );
391    }
392
393    if !force {
394        eprint!("\nRemove these entries and their indexed data? [y/N] ");
395        let _ = std::io::Write::flush(&mut std::io::stderr());
396
397        let mut input = String::new();
398        std::io::stdin().read_line(&mut input)?;
399        if !input.trim().eq_ignore_ascii_case("y") {
400            eprintln!("Aborted.");
401            return Ok(());
402        }
403    }
404
405    let daemon_url = config::resolve_daemon_url();
406    let database_url = db::resolve_database_url()?;
407    let mut conn = db::connect_readwrite(&database_url)?;
408
409    for stale_project in &stale {
410        indexer::invalidate(&mut conn, &stale_project.project.id, daemon_url.as_deref())?;
411    }
412
413    eprintln!("Pruned {} stale project(s).", stale.len());
414    Ok(())
415}
416
417pub fn repo_outline(ctx: &Context, format: Format) -> anyhow::Result<()> {
418    let mut conn = db::connect_readonly(&ctx.database_url)?;
419
420    // Group files by directory with symbol counts.
421    let files: Vec<serde_json::Value> = visibility::visible_tree(&mut conn, ctx)?
422        .into_iter()
423        .map(|file| {
424            serde_json::json!({
425                "file_path": file.file_path,
426                "language": file.language,
427                "symbol_count": file.symbol_count,
428            })
429        })
430        .collect();
431
432    // Group by directory
433    let mut dirs: std::collections::BTreeMap<String, Vec<&serde_json::Value>> =
434        std::collections::BTreeMap::new();
435    for f in &files {
436        let fp = f["file_path"].as_str().unwrap_or("");
437        let dir = std::path::Path::new(fp)
438            .parent()
439            .map(|p| p.to_string_lossy().to_string())
440            .unwrap_or_else(|| ".".to_string());
441        dirs.entry(dir).or_default().push(f);
442    }
443
444    match format {
445        Format::Json => output::print_json(&dirs),
446        Format::Text => {
447            for (dir, dir_files) in &dirs {
448                let total_syms: i64 = dir_files
449                    .iter()
450                    .map(|f| f["symbol_count"].as_i64().unwrap_or(0))
451                    .sum();
452                println!("{dir}/ ({} files, {total_syms} symbols)", dir_files.len());
453            }
454            Ok(())
455        }
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    fn indexed_project(id: &str, root_path: &Path) -> IndexedProject {
464        IndexedProject {
465            id: id.to_string(),
466            root_path: root_path.to_string_lossy().to_string(),
467            total_files: 1,
468            total_symbols: 1,
469            last_indexed_at: "1".to_string(),
470            index_duration_ms: 1,
471            total_eligible_files: Some(1),
472        }
473    }
474
475    fn write_project_json(root: &Path, id: &str) {
476        let gobby_dir = root.join(".gobby");
477        std::fs::create_dir_all(&gobby_dir).expect("create .gobby");
478        std::fs::write(
479            gobby_dir.join("project.json"),
480            serde_json::json!({
481                "id": id,
482                "name": "project",
483                "parent_project_path": root.to_string_lossy(),
484                "parent_project_id": id
485            })
486            .to_string(),
487        )
488        .expect("write project.json");
489    }
490
491    #[test]
492    fn duplicate_root_prune_detection_keeps_resolved_project_id() {
493        let tmp = tempfile::tempdir().expect("tempdir");
494        let root = tmp.path().canonicalize().expect("canonical root");
495        let current_id = "d45545c5-current-project-id";
496        let stale_id = "39c31b8f-stale-project-id";
497        write_project_json(&root, current_id);
498
499        let projects = vec![
500            indexed_project(current_id, &root),
501            indexed_project(stale_id, &root),
502        ];
503
504        let stale = stale_projects(&projects);
505
506        assert_eq!(stale.len(), 1);
507        assert_eq!(stale[0].project.id, stale_id);
508        assert!(stale[0].reason.contains("duplicate root"));
509        assert!(stale.iter().all(|entry| entry.project.id != current_id));
510    }
511}