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
16fn format_timestamp(raw: &str) -> String {
19 if raw.is_empty() {
20 return "never".to_string();
21 }
22
23 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 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 if raw.len() >= 19 && raw.as_bytes().get(4) == Some(&b'-') {
37 let base = &raw[..19]; return base.replace('T', " ");
39 }
40
41 raw.to_string()
42}
43
44fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
46 days += 719468;
48 let era = if days >= 0 { days } else { days - 146096 } / 146097;
49 let doe = days - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
52 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; 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
199fn 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
247fn 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
258fn 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
270pub 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
295fn 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
374pub 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 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 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}