Skip to main content

gobby_code/commands/
status.rs

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