Skip to main content

gobby_code/commands/status/
prune.rs

1use postgres::GenericClient;
2use std::collections::HashSet;
3
4use crate::config::{self, Context};
5use crate::db;
6use crate::graph::code_graph;
7use crate::index::indexer;
8use crate::utils::short_id;
9use crate::vector::code_symbols;
10
11use super::projects::stale_projects;
12use super::shared::{collect_projects, display_name};
13
14const ORPHAN_PROJECT_WARNING_LIMIT: usize = 5;
15const ORPHAN_PROJECT_SUMMARY_LIMIT: usize = 8;
16
17#[derive(Debug, PartialEq, Eq)]
18enum ProjectionCleanupScope {
19    AllIndexedProjects,
20    ResolvedProjectOverride,
21}
22
23#[derive(Default)]
24struct ProjectionPruneTotals {
25    graph_projects_cleaned: usize,
26    graph_projects_skipped: usize,
27    graph_stale_files_deleted: usize,
28    graph_nodes_deleted: usize,
29    vector_projects_cleaned: usize,
30    vector_projects_skipped: usize,
31    vector_orphan_files_deleted: usize,
32    vectors_deleted: usize,
33}
34
35impl ProjectionPruneTotals {
36    fn record_graph_cleanup(&mut self, cleanup: crate::graph::code_graph::GraphOrphanCleanup) {
37        self.graph_projects_cleaned += 1;
38        self.graph_stale_files_deleted += cleanup.stale_files_deleted;
39        self.graph_nodes_deleted += cleanup.graph_nodes_deleted;
40    }
41
42    fn record_vector_cleanup(&mut self, cleanup: code_symbols::VectorOrphanCleanup) {
43        self.vector_projects_cleaned += 1;
44        self.vector_orphan_files_deleted += cleanup.orphan_files_deleted;
45        self.vectors_deleted += cleanup.vectors_deleted;
46    }
47
48    fn add(&mut self, other: ProjectionPruneTotals) {
49        self.graph_projects_cleaned += other.graph_projects_cleaned;
50        self.graph_projects_skipped += other.graph_projects_skipped;
51        self.graph_stale_files_deleted += other.graph_stale_files_deleted;
52        self.graph_nodes_deleted += other.graph_nodes_deleted;
53        self.vector_projects_cleaned += other.vector_projects_cleaned;
54        self.vector_projects_skipped += other.vector_projects_skipped;
55        self.vector_orphan_files_deleted += other.vector_orphan_files_deleted;
56        self.vectors_deleted += other.vectors_deleted;
57    }
58}
59
60#[derive(Default, Debug, PartialEq, Eq)]
61pub(super) struct OrphanSqlDeletionCounts {
62    symbols_deleted: u64,
63    files_deleted: u64,
64    content_chunks_deleted: u64,
65    imports_deleted: u64,
66    calls_deleted: u64,
67}
68
69impl OrphanSqlDeletionCounts {
70    fn total(&self) -> u64 {
71        self.symbols_deleted
72            + self.files_deleted
73            + self.content_chunks_deleted
74            + self.imports_deleted
75            + self.calls_deleted
76    }
77}
78
79#[derive(Default)]
80struct OrphanProjectReconcileTotals {
81    project_ids: Vec<String>,
82    sql: OrphanSqlDeletionCounts,
83    graph_projects_cleared: usize,
84    graph_projects_skipped: usize,
85    vector_collections_deleted: usize,
86    vector_projects_skipped: usize,
87}
88
89impl OrphanProjectReconcileTotals {
90    fn record_sql(&mut self, project_id: String, counts: OrphanSqlDeletionCounts) {
91        self.project_ids.push(project_id);
92        self.sql.symbols_deleted += counts.symbols_deleted;
93        self.sql.files_deleted += counts.files_deleted;
94        self.sql.content_chunks_deleted += counts.content_chunks_deleted;
95        self.sql.imports_deleted += counts.imports_deleted;
96        self.sql.calls_deleted += counts.calls_deleted;
97    }
98}
99
100fn projection_cleanup_scope(project_override: Option<&str>) -> ProjectionCleanupScope {
101    if project_override.is_some() {
102        ProjectionCleanupScope::ResolvedProjectOverride
103    } else {
104        ProjectionCleanupScope::AllIndexedProjects
105    }
106}
107
108pub fn prune(force: bool, project_override: Option<&str>, quiet: bool) -> anyhow::Result<()> {
109    if prune_stale_projects(force)?.is_none() {
110        return Ok(());
111    }
112
113    let orphan_totals = reconcile_orphan_projects(quiet)?;
114    print_orphan_project_reconcile_totals(&orphan_totals);
115
116    match projection_cleanup_scope(project_override) {
117        ProjectionCleanupScope::AllIndexedProjects => prune_all_project_projections(quiet),
118        ProjectionCleanupScope::ResolvedProjectOverride => {
119            prune_resolved_project_projections(project_override, quiet)
120        }
121    }
122}
123
124fn prune_resolved_project_projections(
125    project_override: Option<&str>,
126    quiet: bool,
127) -> anyhow::Result<()> {
128    match Context::resolve_with_services(
129        project_override,
130        quiet,
131        config::ServiceConfigSelection::projection_cleanup(),
132    ) {
133        Ok(ctx) => prune_current_project_projections(&ctx),
134        Err(error) if project_override.is_none() && is_missing_project_context(&error) => Ok(()),
135        Err(error) => Err(error),
136    }
137}
138
139fn prune_stale_projects(force: bool) -> anyhow::Result<Option<usize>> {
140    let all_projects = collect_projects()?;
141    let stale = stale_projects(&all_projects);
142
143    if stale.is_empty() {
144        eprintln!("No stale projects found.");
145        return Ok(Some(0));
146    }
147
148    eprintln!("Found {} stale project(s):", stale.len());
149    for stale_project in &stale {
150        eprintln!(
151            "  {} — {}",
152            display_name(stale_project.project),
153            stale_project.reason
154        );
155    }
156
157    if !force {
158        eprint!("\nRemove these entries and their indexed data? [y/N] ");
159        let _ = std::io::Write::flush(&mut std::io::stderr());
160
161        let mut input = String::new();
162        std::io::stdin().read_line(&mut input)?;
163        if !input.trim().eq_ignore_ascii_case("y") {
164            eprintln!("Aborted.");
165            return Ok(None);
166        }
167    }
168
169    let daemon_url = gobby_core::daemon_url::daemon_url();
170    let database_url = db::resolve_database_url()?;
171    let mut conn = db::connect_readwrite(&database_url)?;
172
173    for stale_project in &stale {
174        indexer::invalidate(&mut conn, &stale_project.project.id, Some(&daemon_url))?;
175    }
176
177    eprintln!("Pruned {} stale project(s).", stale.len());
178    Ok(Some(stale.len()))
179}
180
181fn reconcile_orphan_projects(quiet: bool) -> anyhow::Result<OrphanProjectReconcileTotals> {
182    let database_url = db::resolve_database_url()?;
183    let mut conn = db::connect_readwrite(&database_url)?;
184    let project_ids = collect_orphan_project_ids(&mut conn)?;
185    let mut totals = OrphanProjectReconcileTotals::default();
186    let mut warnings_emitted = 0usize;
187
188    for project_id in project_ids {
189        if cleanup_orphan_project_projections(
190            &project_id,
191            quiet,
192            &mut totals,
193            &mut warnings_emitted,
194        ) {
195            let counts = delete_orphan_project_sql_rows(&mut conn, &project_id)?;
196            totals.record_sql(project_id, counts);
197        }
198    }
199
200    Ok(totals)
201}
202
203pub(super) fn collect_orphan_project_ids(
204    conn: &mut impl GenericClient,
205) -> anyhow::Result<Vec<String>> {
206    let rows = conn.query(
207        "SELECT child.project_id
208         FROM (
209             SELECT project_id FROM code_indexed_files
210             UNION
211             SELECT project_id FROM code_symbols
212             UNION
213             SELECT project_id FROM code_content_chunks
214             UNION
215             SELECT project_id FROM code_imports
216             UNION
217             SELECT project_id FROM code_calls
218         ) child
219         LEFT JOIN code_indexed_projects parent ON parent.id = child.project_id
220         WHERE parent.id IS NULL
221         ORDER BY child.project_id",
222        &[],
223    )?;
224
225    rows.into_iter()
226        .map(|row| row.try_get(0).map_err(anyhow::Error::from))
227        .collect()
228}
229
230pub(super) fn delete_orphan_project_sql_rows(
231    conn: &mut impl GenericClient,
232    project_id: &str,
233) -> anyhow::Result<OrphanSqlDeletionCounts> {
234    let calls_deleted = conn.execute(
235        "DELETE FROM code_calls WHERE project_id = $1",
236        &[&project_id],
237    )?;
238    let imports_deleted = conn.execute(
239        "DELETE FROM code_imports WHERE project_id = $1",
240        &[&project_id],
241    )?;
242    let content_chunks_deleted = conn.execute(
243        "DELETE FROM code_content_chunks WHERE project_id = $1",
244        &[&project_id],
245    )?;
246    let files_deleted = conn.execute(
247        "DELETE FROM code_indexed_files WHERE project_id = $1",
248        &[&project_id],
249    )?;
250    let symbols_deleted = conn.execute(
251        "DELETE FROM code_symbols WHERE project_id = $1",
252        &[&project_id],
253    )?;
254
255    Ok(OrphanSqlDeletionCounts {
256        symbols_deleted,
257        files_deleted,
258        content_chunks_deleted,
259        imports_deleted,
260        calls_deleted,
261    })
262}
263
264fn cleanup_orphan_project_projections(
265    project_id: &str,
266    quiet: bool,
267    totals: &mut OrphanProjectReconcileTotals,
268    warnings_emitted: &mut usize,
269) -> bool {
270    let mut cleaned = true;
271    let ctx = match Context::resolve_for_project_id_with_services(
272        project_id,
273        quiet,
274        config::ServiceConfigSelection::projection_cleanup(),
275    ) {
276        Ok(ctx) => ctx,
277        Err(error) => {
278            warn_orphan_projection_cleanup_failure(
279                "service config",
280                project_id,
281                error,
282                warnings_emitted,
283            );
284            totals.graph_projects_skipped += 1;
285            totals.vector_projects_skipped += 1;
286            return false;
287        }
288    };
289
290    if ctx.falkordb.is_some() {
291        if let Err(error) = code_graph::clear_project(&ctx) {
292            warn_orphan_projection_cleanup_failure("graph", project_id, error, warnings_emitted);
293            cleaned = false;
294        } else {
295            totals.graph_projects_cleared += 1;
296        }
297    } else {
298        totals.graph_projects_skipped += 1;
299    }
300
301    if let Some(qdrant) = &ctx.qdrant {
302        match code_symbols::delete_project_collection(qdrant, project_id) {
303            Ok(deleted) => totals.vector_collections_deleted += deleted,
304            Err(error) => {
305                warn_orphan_projection_cleanup_failure(
306                    "vector",
307                    project_id,
308                    anyhow::Error::from(error),
309                    warnings_emitted,
310                );
311                cleaned = false;
312            }
313        }
314    } else {
315        totals.vector_projects_skipped += 1;
316    }
317    cleaned
318}
319
320fn print_orphan_project_reconcile_totals(totals: &OrphanProjectReconcileTotals) {
321    if totals.project_ids.is_empty() {
322        return;
323    }
324
325    eprintln!(
326        "Reconciled {} orphan code-index project(s): deleted {} SQL row(s) ({} file(s), {} symbol(s), {} content chunk(s), {} import(s), {} call(s)).",
327        totals.project_ids.len(),
328        totals.sql.total(),
329        totals.sql.files_deleted,
330        totals.sql.symbols_deleted,
331        totals.sql.content_chunks_deleted,
332        totals.sql.imports_deleted,
333        totals.sql.calls_deleted
334    );
335    eprintln!(
336        "  Project IDs: {}",
337        bounded_project_id_summary(&totals.project_ids)
338    );
339    eprintln!(
340        "  Cleared projections: {} graph project(s), {} vector collection(s); skipped {} graph, {} vector project(s).",
341        totals.graph_projects_cleared,
342        totals.vector_collections_deleted,
343        totals.graph_projects_skipped,
344        totals.vector_projects_skipped
345    );
346}
347
348fn bounded_project_id_summary(project_ids: &[String]) -> String {
349    let mut ids = project_ids
350        .iter()
351        .take(ORPHAN_PROJECT_SUMMARY_LIMIT)
352        .map(|id| short_id(id))
353        .collect::<Vec<_>>();
354    if project_ids.len() > ORPHAN_PROJECT_SUMMARY_LIMIT {
355        ids.push(format!(
356            "+{} more",
357            project_ids.len() - ORPHAN_PROJECT_SUMMARY_LIMIT
358        ));
359    }
360    ids.join(", ")
361}
362
363fn warn_orphan_projection_cleanup_failure(
364    store: &str,
365    project_id: &str,
366    error: anyhow::Error,
367    warnings_emitted: &mut usize,
368) {
369    if *warnings_emitted < ORPHAN_PROJECT_WARNING_LIMIT {
370        eprintln!(
371            "Warning: {store} cleanup failed for orphan project {}: {error}",
372            short_id(project_id)
373        );
374    } else if *warnings_emitted == ORPHAN_PROJECT_WARNING_LIMIT {
375        eprintln!(
376            "Warning: additional orphan project projection cleanup failures omitted after {ORPHAN_PROJECT_WARNING_LIMIT} warning(s)."
377        );
378    }
379    *warnings_emitted += 1;
380}
381
382fn prune_all_project_projections(quiet: bool) -> anyhow::Result<()> {
383    let projects = collect_projects()?;
384    if projects.is_empty() {
385        eprintln!("No indexed projects remain for projection cleanup.");
386        return Ok(());
387    }
388
389    let mut totals = ProjectionPruneTotals::default();
390    for project in &projects {
391        let label = display_name(project);
392        match Context::resolve_for_project_id_with_services(
393            &project.id,
394            quiet,
395            config::ServiceConfigSelection::projection_cleanup(),
396        ) {
397            Ok(ctx) => totals.add(prune_project_orphan_projections(&ctx, Some(&label))),
398            Err(error) => {
399                eprintln!("Warning: projection orphan cleanup failed for {label}: {error}")
400            }
401        }
402    }
403
404    print_all_project_projection_totals(totals);
405    Ok(())
406}
407
408fn prune_current_project_projections(ctx: &Context) -> anyhow::Result<()> {
409    let totals = prune_project_orphan_projections(ctx, None);
410    print_current_project_projection_totals(totals);
411    Ok(())
412}
413
414fn prune_project_orphan_projections(
415    ctx: &Context,
416    project_label: Option<&str>,
417) -> ProjectionPruneTotals {
418    let mut totals = ProjectionPruneTotals::default();
419
420    match prune_graph_orphans(ctx) {
421        Ok(Some(cleanup)) => totals.record_graph_cleanup(cleanup),
422        Ok(None) => totals.graph_projects_skipped += 1,
423        Err(error) => warn_projection_cleanup_failure("graph", project_label, error),
424    }
425
426    match prune_vector_orphans(ctx) {
427        Ok(Some(cleanup)) => totals.record_vector_cleanup(cleanup),
428        Ok(None) => totals.vector_projects_skipped += 1,
429        Err(error) => warn_projection_cleanup_failure("vector", project_label, error),
430    }
431
432    totals
433}
434
435fn print_current_project_projection_totals(totals: ProjectionPruneTotals) {
436    if totals.graph_projects_cleaned > 0 {
437        eprintln!(
438            "Pruned graph projection: {} stale file(s), {} file-scoped node(s).",
439            totals.graph_stale_files_deleted, totals.graph_nodes_deleted
440        );
441    } else if totals.graph_projects_skipped > 0 {
442        eprintln!("Skipped graph projection orphan cleanup: FalkorDB is not configured.");
443    }
444
445    if totals.vector_projects_cleaned > 0 {
446        eprintln!(
447            "Pruned vector projection: {} stale file(s), {} vector point(s).",
448            totals.vector_orphan_files_deleted, totals.vectors_deleted
449        );
450    } else if totals.vector_projects_skipped > 0 {
451        eprintln!("Skipped vector projection orphan cleanup: Qdrant is not configured.");
452    }
453}
454
455fn print_all_project_projection_totals(totals: ProjectionPruneTotals) {
456    if totals.graph_projects_cleaned > 0 {
457        eprintln!(
458            "Pruned graph projections for {} project(s): {} stale file(s), {} file-scoped node(s).",
459            totals.graph_projects_cleaned,
460            totals.graph_stale_files_deleted,
461            totals.graph_nodes_deleted
462        );
463    } else if totals.graph_projects_skipped > 0 {
464        eprintln!(
465            "Skipped graph projection orphan cleanup for all indexed projects: FalkorDB is not configured."
466        );
467    }
468
469    if totals.vector_projects_cleaned > 0 {
470        eprintln!(
471            "Pruned vector projections for {} project(s): {} stale file(s), {} vector point(s).",
472            totals.vector_projects_cleaned,
473            totals.vector_orphan_files_deleted,
474            totals.vectors_deleted
475        );
476    } else if totals.vector_projects_skipped > 0 {
477        eprintln!(
478            "Skipped vector projection orphan cleanup for all indexed projects: Qdrant is not configured."
479        );
480    }
481}
482
483fn warn_projection_cleanup_failure(store: &str, project_label: Option<&str>, error: anyhow::Error) {
484    if let Some(project_label) = project_label {
485        eprintln!("Warning: {store} projection orphan cleanup failed for {project_label}: {error}");
486    } else {
487        eprintln!("Warning: {store} projection orphan cleanup failed: {error}");
488    }
489}
490
491fn prune_graph_orphans(
492    ctx: &Context,
493) -> anyhow::Result<Option<crate::graph::code_graph::GraphOrphanCleanup>> {
494    if ctx.falkordb.is_none() {
495        return Ok(None);
496    }
497    crate::commands::graph::cleanup_deleted_project_graph(ctx).map(Some)
498}
499
500fn prune_vector_orphans(
501    ctx: &Context,
502) -> anyhow::Result<Option<code_symbols::VectorOrphanCleanup>> {
503    let Some(qdrant) = &ctx.qdrant else {
504        return Ok(None);
505    };
506    let mut conn = db::connect_readonly(&ctx.database_url)?;
507    let indexed_file_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?
508        .into_iter()
509        .collect::<HashSet<_>>();
510    code_symbols::cleanup_orphan_file_vectors(qdrant, &ctx.project_id, &indexed_file_paths)
511        .map(Some)
512        .map_err(anyhow::Error::from)
513}
514
515fn is_missing_project_context(error: &anyhow::Error) -> bool {
516    error
517        .to_string()
518        .contains("No gcode project found. Run `gcode init`")
519}
520
521#[cfg(test)]
522mod tests {
523    use std::time::{SystemTime, UNIX_EPOCH};
524
525    use super::*;
526
527    #[test]
528    fn prune_without_project_uses_all_indexed_projection_scope() {
529        assert_eq!(
530            projection_cleanup_scope(None),
531            ProjectionCleanupScope::AllIndexedProjects
532        );
533    }
534
535    #[test]
536    fn prune_with_project_uses_single_resolved_projection_scope() {
537        assert_eq!(
538            projection_cleanup_scope(Some("/tmp/project")),
539            ProjectionCleanupScope::ResolvedProjectOverride
540        );
541    }
542
543    #[test]
544    fn bounded_project_id_summary_caps_ids() {
545        let ids = (0..10)
546            .map(|idx| format!("project-{idx:02}-abcdef"))
547            .collect::<Vec<_>>();
548
549        let summary = bounded_project_id_summary(&ids);
550
551        assert!(summary.contains("project-"));
552        assert!(summary.contains("+2 more"));
553    }
554
555    #[test]
556    #[cfg_attr(
557        not(gcode_postgres_tests),
558        ignore = "requires a PostgreSQL test database URL"
559    )]
560    #[serial_test::serial(serial_db)]
561    fn orphan_project_discovery_and_sql_deletion_counts() {
562        let (mut conn, database_url) = connect_test_db();
563        let valid_project_id = unique_test_project_id("gcode-orphan-valid");
564        let orphan_project_id = unique_test_project_id("gcode-orphan-missing-parent");
565        cleanup_project(&mut conn, &valid_project_id).expect("pre-clean valid project rows");
566        cleanup_project(&mut conn, &orphan_project_id).expect("pre-clean orphan project rows");
567        let _valid_cleanup = ProjectCleanup {
568            database_url: database_url.clone(),
569            project_id: valid_project_id.clone(),
570        };
571        let _orphan_cleanup = ProjectCleanup {
572            database_url,
573            project_id: orphan_project_id.clone(),
574        };
575
576        seed_project_with_child_rows(&mut conn, &valid_project_id, true);
577        seed_project_with_child_rows(&mut conn, &orphan_project_id, false);
578
579        let orphan_ids = collect_orphan_project_ids(&mut conn).expect("discover orphan projects");
580        assert!(orphan_ids.contains(&orphan_project_id));
581        assert!(!orphan_ids.contains(&valid_project_id));
582
583        let counts = delete_orphan_project_sql_rows(&mut conn, &orphan_project_id)
584            .expect("delete orphan rows");
585
586        assert_eq!(
587            counts,
588            OrphanSqlDeletionCounts {
589                symbols_deleted: 1,
590                files_deleted: 1,
591                content_chunks_deleted: 1,
592                imports_deleted: 1,
593                calls_deleted: 1,
594            }
595        );
596        assert_eq!(project_child_row_count(&mut conn, &orphan_project_id), 0);
597        assert_eq!(project_child_row_count(&mut conn, &valid_project_id), 5);
598    }
599
600    struct ProjectCleanup {
601        database_url: String,
602        project_id: String,
603    }
604
605    impl Drop for ProjectCleanup {
606        fn drop(&mut self) {
607            if let Ok(mut conn) = db::connect_readwrite(&self.database_url) {
608                let _ = cleanup_project(&mut conn, &self.project_id);
609            }
610        }
611    }
612
613    fn connect_test_db() -> (postgres::Client, String) {
614        let database_url = crate::test_env::postgres_test_database_url("prune tests");
615        let conn = db::connect_readwrite(&database_url).expect("connect prune PostgreSQL test DB");
616        (conn, database_url)
617    }
618
619    fn unique_test_project_id(prefix: &str) -> String {
620        let nanos = SystemTime::now()
621            .duration_since(UNIX_EPOCH)
622            .expect("system time after epoch")
623            .as_nanos();
624        format!("{prefix}-{nanos}")
625    }
626
627    fn seed_project_with_child_rows(
628        conn: &mut postgres::Client,
629        project_id: &str,
630        include_project_row: bool,
631    ) {
632        let file_path = "src/lib.rs";
633        let file_id = format!("{project_id}-file");
634        let symbol_id = format!("{project_id}-symbol");
635        if include_project_row {
636            conn.execute(
637                "INSERT INTO code_indexed_projects
638                    (id, root_path, total_files, total_symbols, last_indexed_at, index_duration_ms)
639                 VALUES ($1, $2, 1, 1, NOW(), 0)",
640                &[&project_id, &format!("/tmp/{project_id}")],
641            )
642            .expect("insert indexed project");
643        }
644        conn.execute(
645            "INSERT INTO code_indexed_files
646                (id, project_id, file_path, language, content_hash, symbol_count, byte_size)
647             VALUES ($1, $2, $3, 'rust', 'hash-1', 1, 19)",
648            &[&file_id, &project_id, &file_path],
649        )
650        .expect("insert indexed file");
651        conn.execute(
652            "INSERT INTO code_symbols
653                (id, project_id, file_path, name, qualified_name, kind, language, byte_start,
654                 byte_end, line_start, line_end, signature, docstring, parent_symbol_id,
655                 content_hash, summary, created_at, updated_at)
656             VALUES ($1, $2, $3, 'indexed', 'crate::indexed', 'function', 'rust', 0, 19,
657                 1, 1, 'pub fn indexed()', NULL, NULL, 'hash-1', NULL, NOW(), NOW())",
658            &[&symbol_id, &project_id, &file_path],
659        )
660        .expect("insert symbol");
661        conn.execute(
662            "INSERT INTO code_content_chunks
663                (id, project_id, file_path, chunk_index, line_start, line_end, content, language)
664             VALUES ($1, $2, $3, 0, 1, 1, 'pub fn indexed() {}', 'rust')",
665            &[&format!("{project_id}-chunk"), &project_id, &file_path],
666        )
667        .expect("insert content chunk");
668        conn.execute(
669            "INSERT INTO code_imports (project_id, source_file, target_module)
670             VALUES ($1, $2, 'std::fmt')",
671            &[&project_id, &file_path],
672        )
673        .expect("insert import");
674        conn.execute(
675            "INSERT INTO code_calls
676                (project_id, caller_symbol_id, callee_symbol_id, callee_name,
677                 callee_target_kind, callee_external_module, file_path, line)
678             VALUES ($1, $2, '', 'missing', 'unresolved', '', $3, 1)",
679            &[&project_id, &symbol_id, &file_path],
680        )
681        .expect("insert call");
682    }
683
684    fn cleanup_project(conn: &mut postgres::Client, project_id: &str) -> anyhow::Result<()> {
685        conn.execute(
686            "DELETE FROM code_calls WHERE project_id = $1",
687            &[&project_id],
688        )?;
689        conn.execute(
690            "DELETE FROM code_imports WHERE project_id = $1",
691            &[&project_id],
692        )?;
693        conn.execute(
694            "DELETE FROM code_content_chunks WHERE project_id = $1",
695            &[&project_id],
696        )?;
697        conn.execute(
698            "DELETE FROM code_symbols WHERE project_id = $1",
699            &[&project_id],
700        )?;
701        conn.execute(
702            "DELETE FROM code_indexed_files WHERE project_id = $1",
703            &[&project_id],
704        )?;
705        conn.execute(
706            "DELETE FROM code_indexed_projects WHERE id = $1",
707            &[&project_id],
708        )?;
709        Ok(())
710    }
711
712    fn project_child_row_count(conn: &mut postgres::Client, project_id: &str) -> i64 {
713        let files = count_rows(conn, "code_indexed_files", project_id);
714        let symbols = count_rows(conn, "code_symbols", project_id);
715        let chunks = count_rows(conn, "code_content_chunks", project_id);
716        let imports = count_rows(conn, "code_imports", project_id);
717        let calls = count_rows(conn, "code_calls", project_id);
718        files + symbols + chunks + imports + calls
719    }
720
721    fn count_rows(conn: &mut postgres::Client, table: &str, project_id: &str) -> i64 {
722        conn.query_one(
723            &format!("SELECT COUNT(*)::BIGINT FROM {table} WHERE project_id = $1"),
724            &[&project_id],
725        )
726        .expect("count rows")
727        .get(0)
728    }
729}