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
14fn format_timestamp(raw: &str) -> String {
17 if raw.is_empty() {
18 return "never".to_string();
19 }
20
21 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 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 if raw.len() >= 19 && raw.as_bytes().get(4) == Some(&b'-') {
35 let base = &raw[..19]; return base.replace('T', " ");
37 }
38
39 raw.to_string()
40}
41
42fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
44 days += 719468;
46 let era = if days >= 0 { days } else { days - 146096 } / 146097;
47 let doe = days - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
50 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 };
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
146fn 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
194fn 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
205fn 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
218pub 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
243fn 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
322pub 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 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 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}