Skip to main content

gobby_code/
setup.rs

1use gobby_core::setup::{
2    OwnedObject, SetupContext, SetupError, SetupReport, StandaloneSetup, StoreKind,
3};
4use postgres::{Client, GenericClient};
5use serde::{Deserialize, Serialize};
6use std::collections::HashSet;
7
8const DEFAULT_SCHEMA: &str = "public";
9const NAMESPACE: &str = "gcode";
10const OVERWRITE_GUIDANCE: &str = "Rerun with `gcode setup --standalone --overwrite-code-index` to replace only gcode-owned code-index relations.";
11
12const CODE_INDEX_TABLES: &[&str] = &[
13    "code_indexed_projects",
14    "code_indexed_files",
15    "code_symbols",
16    "code_content_chunks",
17    "code_imports",
18    "code_calls",
19];
20
21const CODE_INDEX_INDEXES: &[&str] = &[
22    "idx_cif_project",
23    "idx_cif_graph_synced",
24    "idx_cif_vectors_synced",
25    "idx_cs_project",
26    "idx_cs_file",
27    "idx_cs_name",
28    "idx_cs_qualified",
29    "idx_cs_kind",
30    "idx_cs_parent",
31    "idx_ccc_project",
32    "idx_ccc_file",
33    "idx_ci_file",
34    "idx_cc_file",
35    "idx_cc_caller",
36    "idx_cc_target",
37    "code_symbols_search_bm25",
38    "code_content_search_bm25",
39];
40
41struct TableContract {
42    name: &'static str,
43    required_columns: &'static [&'static str],
44}
45
46struct IndexContract {
47    name: &'static str,
48    table: &'static str,
49    method: &'static str,
50}
51
52const TABLE_CONTRACTS: &[TableContract] = &[
53    TableContract {
54        name: "code_indexed_projects",
55        required_columns: &[
56            "id",
57            "root_path",
58            "total_files",
59            "total_symbols",
60            "last_indexed_at",
61            "index_duration_ms",
62            "created_at",
63            "updated_at",
64        ],
65    },
66    TableContract {
67        name: "code_indexed_files",
68        required_columns: &[
69            "id",
70            "project_id",
71            "file_path",
72            "language",
73            "content_hash",
74            "symbol_count",
75            "byte_size",
76            "graph_synced",
77            "vectors_synced",
78            "graph_sync_attempted_at",
79            "indexed_at",
80        ],
81    },
82    TableContract {
83        name: "code_symbols",
84        required_columns: &[
85            "id",
86            "project_id",
87            "file_path",
88            "name",
89            "qualified_name",
90            "kind",
91            "language",
92            "byte_start",
93            "byte_end",
94            "line_start",
95            "line_end",
96            "signature",
97            "docstring",
98            "parent_symbol_id",
99            "content_hash",
100            "summary",
101            "created_at",
102            "updated_at",
103        ],
104    },
105    TableContract {
106        name: "code_content_chunks",
107        required_columns: &[
108            "id",
109            "project_id",
110            "file_path",
111            "chunk_index",
112            "line_start",
113            "line_end",
114            "content",
115            "language",
116            "created_at",
117        ],
118    },
119    TableContract {
120        name: "code_imports",
121        required_columns: &["id", "project_id", "source_file", "target_module"],
122    },
123    TableContract {
124        name: "code_calls",
125        required_columns: &[
126            "id",
127            "project_id",
128            "caller_symbol_id",
129            "callee_symbol_id",
130            "callee_name",
131            "callee_target_kind",
132            "callee_external_module",
133            "file_path",
134            "line",
135        ],
136    },
137];
138
139const INDEX_CONTRACTS: &[IndexContract] = &[
140    IndexContract {
141        name: "idx_cif_project",
142        table: "code_indexed_files",
143        method: "btree",
144    },
145    IndexContract {
146        name: "idx_cif_graph_synced",
147        table: "code_indexed_files",
148        method: "btree",
149    },
150    IndexContract {
151        name: "idx_cif_vectors_synced",
152        table: "code_indexed_files",
153        method: "btree",
154    },
155    IndexContract {
156        name: "idx_cs_project",
157        table: "code_symbols",
158        method: "btree",
159    },
160    IndexContract {
161        name: "idx_cs_file",
162        table: "code_symbols",
163        method: "btree",
164    },
165    IndexContract {
166        name: "idx_cs_name",
167        table: "code_symbols",
168        method: "btree",
169    },
170    IndexContract {
171        name: "idx_cs_qualified",
172        table: "code_symbols",
173        method: "btree",
174    },
175    IndexContract {
176        name: "idx_cs_kind",
177        table: "code_symbols",
178        method: "btree",
179    },
180    IndexContract {
181        name: "idx_cs_parent",
182        table: "code_symbols",
183        method: "btree",
184    },
185    IndexContract {
186        name: "idx_ccc_project",
187        table: "code_content_chunks",
188        method: "btree",
189    },
190    IndexContract {
191        name: "idx_ccc_file",
192        table: "code_content_chunks",
193        method: "btree",
194    },
195    IndexContract {
196        name: "idx_ci_file",
197        table: "code_imports",
198        method: "btree",
199    },
200    IndexContract {
201        name: "idx_cc_file",
202        table: "code_calls",
203        method: "btree",
204    },
205    IndexContract {
206        name: "idx_cc_caller",
207        table: "code_calls",
208        method: "btree",
209    },
210    IndexContract {
211        name: "idx_cc_target",
212        table: "code_calls",
213        method: "btree",
214    },
215    IndexContract {
216        name: "code_symbols_search_bm25",
217        table: "code_symbols",
218        method: "bm25",
219    },
220    IndexContract {
221        name: "code_content_search_bm25",
222        table: "code_content_chunks",
223        method: "bm25",
224    },
225];
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct StandaloneSetupRequest {
229    pub standalone: bool,
230    pub database_url: Option<String>,
231    pub no_services: bool,
232    pub overwrite_code_index: bool,
233    pub schema: String,
234    pub embedding_provider: Option<String>,
235    pub embedding_api_base: Option<String>,
236    pub embedding_model: Option<String>,
237    pub embedding_vector_dim: Option<usize>,
238    pub embedding_api_key_env: Option<String>,
239    pub falkordb_host: Option<String>,
240    pub falkordb_port: Option<u16>,
241    pub falkordb_password: Option<String>,
242    pub qdrant_url: Option<String>,
243}
244
245impl StandaloneSetupRequest {
246    pub fn new(standalone: bool, database_url: Option<String>, schema: Option<String>) -> Self {
247        Self {
248            standalone,
249            database_url,
250            no_services: false,
251            overwrite_code_index: false,
252            schema: schema.unwrap_or_else(|| DEFAULT_SCHEMA.to_string()),
253            embedding_provider: None,
254            embedding_api_base: None,
255            embedding_model: None,
256            embedding_vector_dim: None,
257            embedding_api_key_env: None,
258            falkordb_host: None,
259            falkordb_port: None,
260            falkordb_password: None,
261            qdrant_url: None,
262        }
263    }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct StandaloneServicesStatus {
268    pub provisioned: bool,
269    pub compose_file: Option<String>,
270    pub health_checks: Vec<String>,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct StandaloneEmbeddingStatus {
275    pub provider: String,
276    pub api_base: String,
277    pub model: String,
278    pub vector_dim: usize,
279    pub api_key_env: Option<String>,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct StandaloneSetupStatus {
284    pub namespace: String,
285    pub schema: String,
286    pub created: Vec<String>,
287    pub skipped: Vec<String>,
288    pub failed: Vec<(String, String)>,
289    pub config_file: Option<String>,
290    pub services: Option<StandaloneServicesStatus>,
291    pub embedding: Option<StandaloneEmbeddingStatus>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct GcodeStandaloneSetup {
296    schema: String,
297}
298
299impl GcodeStandaloneSetup {
300    pub fn new(schema: impl Into<String>) -> Self {
301        Self {
302            schema: schema.into(),
303        }
304    }
305
306    pub fn schema(&self) -> &str {
307        &self.schema
308    }
309
310    fn object(&self, name: &str, sql: String) -> OwnedObject {
311        let object_name = name.to_string();
312        OwnedObject {
313            name: object_name.clone(),
314            store: StoreKind::Postgres,
315            creator: Box::new(move |ctx| execute_postgres_ddl(ctx, &object_name, &sql)),
316        }
317    }
318
319    fn qualified(&self, relation: &str) -> Result<String, SetupError> {
320        Ok(format!(
321            "{}.{}",
322            quote_identifier(&self.schema, "schema")?,
323            quote_identifier(relation, "relation")?
324        ))
325    }
326}
327
328impl StandaloneSetup for GcodeStandaloneSetup {
329    fn namespace(&self) -> &str {
330        NAMESPACE
331    }
332
333    fn owned_objects(&self) -> Vec<OwnedObject> {
334        let code_indexed_projects = match self.qualified("code_indexed_projects") {
335            Ok(name) => name,
336            Err(err) => return vec![invalid_object("code_indexed_projects table", err)],
337        };
338        let code_indexed_files = match self.qualified("code_indexed_files") {
339            Ok(name) => name,
340            Err(err) => return vec![invalid_object("code_indexed_files table", err)],
341        };
342        let code_symbols = match self.qualified("code_symbols") {
343            Ok(name) => name,
344            Err(err) => return vec![invalid_object("code_symbols table", err)],
345        };
346        let code_content_chunks = match self.qualified("code_content_chunks") {
347            Ok(name) => name,
348            Err(err) => return vec![invalid_object("code_content_chunks table", err)],
349        };
350        let code_imports = match self.qualified("code_imports") {
351            Ok(name) => name,
352            Err(err) => return vec![invalid_object("code_imports table", err)],
353        };
354        let code_calls = match self.qualified("code_calls") {
355            Ok(name) => name,
356            Err(err) => return vec![invalid_object("code_calls table", err)],
357        };
358
359        vec![
360            self.object(
361                "pg_search extension",
362                "CREATE EXTENSION IF NOT EXISTS pg_search;".to_string(),
363            ),
364            self.object(
365                "code_indexed_projects table",
366                format!(
367                    "CREATE TABLE IF NOT EXISTS {code_indexed_projects} (
368                        id TEXT PRIMARY KEY,
369                        root_path TEXT NOT NULL,
370                        total_files INTEGER NOT NULL DEFAULT 0,
371                        total_symbols INTEGER NOT NULL DEFAULT 0,
372                        last_indexed_at TIMESTAMPTZ,
373                        index_duration_ms INTEGER,
374                        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
375                        updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
376                    );"
377                ),
378            ),
379            self.object(
380                "code_indexed_files table",
381                format!(
382                    "CREATE TABLE IF NOT EXISTS {code_indexed_files} (
383                        id TEXT PRIMARY KEY,
384                        project_id TEXT NOT NULL,
385                        file_path TEXT NOT NULL,
386                        language TEXT NOT NULL,
387                        content_hash TEXT NOT NULL,
388                        symbol_count INTEGER NOT NULL DEFAULT 0,
389                        byte_size INTEGER NOT NULL DEFAULT 0,
390                        graph_synced BOOLEAN NOT NULL DEFAULT FALSE,
391                        vectors_synced BOOLEAN NOT NULL DEFAULT FALSE,
392                        graph_sync_attempted_at TIMESTAMPTZ,
393                        indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
394                        UNIQUE (project_id, file_path)
395                    );"
396                ),
397            ),
398            self.object(
399                "idx_cif_project index",
400                format!(
401                    "CREATE INDEX IF NOT EXISTS idx_cif_project
402                     ON {code_indexed_files}(project_id);"
403                ),
404            ),
405            self.object(
406                "idx_cif_graph_synced index",
407                format!(
408                    "CREATE INDEX IF NOT EXISTS idx_cif_graph_synced
409                     ON {code_indexed_files}(project_id, graph_synced);"
410                ),
411            ),
412            self.object(
413                "idx_cif_vectors_synced index",
414                format!(
415                    "CREATE INDEX IF NOT EXISTS idx_cif_vectors_synced
416                     ON {code_indexed_files}(project_id, vectors_synced);"
417                ),
418            ),
419            self.object(
420                "code_symbols table",
421                format!(
422                    "CREATE TABLE IF NOT EXISTS {code_symbols} (
423                        id TEXT PRIMARY KEY,
424                        project_id TEXT NOT NULL,
425                        file_path TEXT NOT NULL,
426                        name TEXT NOT NULL,
427                        qualified_name TEXT NOT NULL,
428                        kind TEXT NOT NULL,
429                        language TEXT NOT NULL,
430                        byte_start INTEGER NOT NULL,
431                        byte_end INTEGER NOT NULL,
432                        line_start INTEGER NOT NULL,
433                        line_end INTEGER NOT NULL,
434                        signature TEXT,
435                        docstring TEXT,
436                        parent_symbol_id TEXT,
437                        content_hash TEXT NOT NULL,
438                        summary TEXT,
439                        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
440                        updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
441                    );"
442                ),
443            ),
444            self.object(
445                "idx_cs_project index",
446                format!("CREATE INDEX IF NOT EXISTS idx_cs_project ON {code_symbols}(project_id);"),
447            ),
448            self.object(
449                "idx_cs_file index",
450                format!(
451                    "CREATE INDEX IF NOT EXISTS idx_cs_file
452                     ON {code_symbols}(project_id, file_path);"
453                ),
454            ),
455            self.object(
456                "idx_cs_name index",
457                format!("CREATE INDEX IF NOT EXISTS idx_cs_name ON {code_symbols}(name);"),
458            ),
459            self.object(
460                "idx_cs_qualified index",
461                format!(
462                    "CREATE INDEX IF NOT EXISTS idx_cs_qualified
463                     ON {code_symbols}(qualified_name);"
464                ),
465            ),
466            self.object(
467                "idx_cs_kind index",
468                format!("CREATE INDEX IF NOT EXISTS idx_cs_kind ON {code_symbols}(kind);"),
469            ),
470            self.object(
471                "idx_cs_parent index",
472                format!(
473                    "CREATE INDEX IF NOT EXISTS idx_cs_parent
474                     ON {code_symbols}(parent_symbol_id);"
475                ),
476            ),
477            self.object(
478                "code_content_chunks table",
479                format!(
480                    "CREATE TABLE IF NOT EXISTS {code_content_chunks} (
481                        id TEXT PRIMARY KEY,
482                        project_id TEXT NOT NULL,
483                        file_path TEXT NOT NULL,
484                        chunk_index INTEGER NOT NULL,
485                        line_start INTEGER NOT NULL,
486                        line_end INTEGER NOT NULL,
487                        content TEXT NOT NULL,
488                        language TEXT,
489                        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
490                        UNIQUE (project_id, file_path, chunk_index)
491                    );"
492                ),
493            ),
494            self.object(
495                "idx_ccc_project index",
496                format!(
497                    "CREATE INDEX IF NOT EXISTS idx_ccc_project
498                     ON {code_content_chunks}(project_id);"
499                ),
500            ),
501            self.object(
502                "idx_ccc_file index",
503                format!(
504                    "CREATE INDEX IF NOT EXISTS idx_ccc_file
505                     ON {code_content_chunks}(project_id, file_path);"
506                ),
507            ),
508            self.object(
509                "code_imports table",
510                format!(
511                    "CREATE TABLE IF NOT EXISTS {code_imports} (
512                        id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
513                        project_id TEXT NOT NULL,
514                        source_file TEXT NOT NULL,
515                        target_module TEXT NOT NULL,
516                        UNIQUE (project_id, source_file, target_module)
517                    );"
518                ),
519            ),
520            self.object(
521                "idx_ci_file index",
522                format!(
523                    "CREATE INDEX IF NOT EXISTS idx_ci_file
524                     ON {code_imports}(project_id, source_file);"
525                ),
526            ),
527            self.object(
528                "code_calls table",
529                format!(
530                    "CREATE TABLE IF NOT EXISTS {code_calls} (
531                        id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
532                        project_id TEXT NOT NULL,
533                        caller_symbol_id TEXT NOT NULL,
534                        callee_symbol_id TEXT NOT NULL DEFAULT '',
535                        callee_name TEXT NOT NULL,
536                        callee_target_kind TEXT NOT NULL DEFAULT 'unresolved',
537                        callee_external_module TEXT NOT NULL DEFAULT '',
538                        file_path TEXT NOT NULL,
539                        line INTEGER NOT NULL DEFAULT 0,
540                        UNIQUE (
541                            project_id, caller_symbol_id, callee_symbol_id, callee_name,
542                            callee_target_kind, callee_external_module, file_path, line
543                        )
544                    );"
545                ),
546            ),
547            self.object(
548                "idx_cc_file index",
549                format!(
550                    "CREATE INDEX IF NOT EXISTS idx_cc_file
551                     ON {code_calls}(project_id, file_path);"
552                ),
553            ),
554            self.object(
555                "idx_cc_caller index",
556                format!(
557                    "CREATE INDEX IF NOT EXISTS idx_cc_caller
558                     ON {code_calls}(project_id, caller_symbol_id);"
559                ),
560            ),
561            self.object(
562                "idx_cc_target index",
563                format!(
564                    "CREATE INDEX IF NOT EXISTS idx_cc_target
565                     ON {code_calls}(project_id, callee_target_kind, callee_symbol_id, callee_name);"
566                ),
567            ),
568            self.object(
569                "code_symbols_search_bm25 index",
570                format!(
571                    "CREATE INDEX IF NOT EXISTS code_symbols_search_bm25
572                     ON {code_symbols}
573                     USING bm25 (id, name, qualified_name, signature, docstring, summary)
574                     WITH (key_field = 'id');"
575                ),
576            ),
577            self.object(
578                "code_content_search_bm25 index",
579                format!(
580                    "CREATE INDEX IF NOT EXISTS code_content_search_bm25
581                     ON {code_content_chunks}
582                     USING bm25 (id, content)
583                     WITH (key_field = 'id');"
584                ),
585            ),
586        ]
587    }
588
589    fn create(&self, ctx: &mut SetupContext<'_>) -> Result<SetupReport, SetupError> {
590        let mut report = SetupReport::default();
591        for mut object in self.owned_objects() {
592            match (object.creator)(ctx) {
593                Ok(()) => report.created.push(object.name),
594                Err(err) => {
595                    report.failed.push((object.name, err.to_string()));
596                    break;
597                }
598            }
599        }
600        Ok(report)
601    }
602}
603
604pub fn run_standalone_setup(
605    request: &StandaloneSetupRequest,
606    client: &mut Client,
607) -> Result<StandaloneSetupStatus, SetupError> {
608    validate_standalone_request(request)?;
609
610    let setup = GcodeStandaloneSetup::new(request.schema.clone());
611    begin_postgres_transaction(client)?;
612    let mut reset_report = SetupReport::default();
613    if request.overwrite_code_index {
614        if let Err(err) = reset_postgres_code_index(client, setup.schema()) {
615            reset_report
616                .failed
617                .push(("code-index overwrite reset".to_string(), err.to_string()));
618            rollback_postgres_transaction(client, "code-index overwrite reset rollback")?;
619            return Ok(standalone_setup_status(&setup, reset_report));
620        }
621    } else {
622        if let Err(err) = ensure_postgres_code_index_compatible(client, setup.schema()) {
623            rollback_postgres_transaction(client, "code-index preflight rollback")?;
624            return Err(err);
625        }
626    }
627
628    let report = {
629        let mut ctx = SetupContext {
630            pg: Some(client),
631            falkor_config: None,
632            qdrant_config: None,
633            non_interactive: true,
634        };
635        setup.create(&mut ctx)?
636    };
637    if report.failed.is_empty() {
638        commit_postgres_transaction(client)?;
639    } else {
640        rollback_postgres_transaction(client, "standalone setup rollback")?;
641    }
642
643    Ok(standalone_setup_status(&setup, report))
644}
645
646fn standalone_setup_status(
647    setup: &GcodeStandaloneSetup,
648    report: SetupReport,
649) -> StandaloneSetupStatus {
650    StandaloneSetupStatus {
651        namespace: setup.namespace().to_string(),
652        schema: setup.schema().to_string(),
653        created: report.created,
654        skipped: report.skipped,
655        failed: report.failed,
656        config_file: None,
657        services: None,
658        embedding: None,
659    }
660}
661
662fn begin_postgres_transaction(client: &mut Client) -> Result<(), SetupError> {
663    client
664        .batch_execute("BEGIN")
665        .map_err(|err| SetupError::CreationFailed {
666            object: "standalone setup transaction".to_string(),
667            message: err.to_string(),
668        })
669}
670
671fn commit_postgres_transaction(client: &mut Client) -> Result<(), SetupError> {
672    client
673        .batch_execute("COMMIT")
674        .map_err(|err| SetupError::CreationFailed {
675            object: "standalone setup commit".to_string(),
676            message: err.to_string(),
677        })
678}
679
680fn rollback_postgres_transaction(client: &mut Client, object: &str) -> Result<(), SetupError> {
681    client
682        .batch_execute("ROLLBACK")
683        .map_err(|err| SetupError::CreationFailed {
684            object: object.to_string(),
685            message: err.to_string(),
686        })
687}
688
689pub(crate) fn ensure_postgres_code_index_compatible(
690    client: &mut impl GenericClient,
691    schema: &str,
692) -> Result<(), SetupError> {
693    let issues = incompatible_postgres_code_index_relations(client, schema)?;
694    if issues.is_empty() {
695        return Ok(());
696    }
697
698    Err(SetupError::CreationFailed {
699        object: "code-index preflight".to_string(),
700        message: format!(
701            "existing code-index PostgreSQL state is incompatible: {}. {OVERWRITE_GUIDANCE}",
702            issues.join("; ")
703        ),
704    })
705}
706
707pub(crate) fn reset_postgres_code_index(
708    client: &mut impl GenericClient,
709    schema: &str,
710) -> Result<(), SetupError> {
711    let sql = postgres_overwrite_reset_sql(schema)?;
712    client
713        .batch_execute(&sql)
714        .map_err(|err| SetupError::CreationFailed {
715            object: "code-index overwrite reset".to_string(),
716            message: err.to_string(),
717        })
718}
719
720pub(crate) fn postgres_overwrite_reset_sql(schema: &str) -> Result<String, SetupError> {
721    let mut statements = Vec::new();
722    for index in CODE_INDEX_INDEXES {
723        statements.push(format!(
724            "DROP INDEX IF EXISTS {};",
725            qualified_relation(schema, index, "index")?
726        ));
727    }
728    for table in CODE_INDEX_TABLES.iter().rev() {
729        statements.push(format!(
730            "DROP TABLE IF EXISTS {};",
731            qualified_relation(schema, table, "table")?
732        ));
733    }
734    Ok(statements.join("\n"))
735}
736
737fn incompatible_postgres_code_index_relations(
738    client: &mut impl GenericClient,
739    schema: &str,
740) -> Result<Vec<String>, SetupError> {
741    let mut issues = Vec::new();
742    for contract in TABLE_CONTRACTS {
743        inspect_table_contract(client, schema, contract, &mut issues)?;
744    }
745    for contract in INDEX_CONTRACTS {
746        inspect_index_contract(client, schema, contract, &mut issues)?;
747    }
748    Ok(issues)
749}
750
751fn inspect_table_contract(
752    client: &mut impl GenericClient,
753    schema: &str,
754    contract: &TableContract,
755    issues: &mut Vec<String>,
756) -> Result<(), SetupError> {
757    let Some(kind) = relation_kind(client, schema, contract.name)? else {
758        return Ok(());
759    };
760    if kind != "r" {
761        issues.push(format!(
762            "{} exists but is not an ordinary table",
763            contract.name
764        ));
765        return Ok(());
766    }
767
768    let existing = table_columns(client, schema, contract.name)?;
769    let missing = contract
770        .required_columns
771        .iter()
772        .filter(|column| !existing.contains::<str>(column))
773        .copied()
774        .collect::<Vec<_>>();
775    if !missing.is_empty() {
776        issues.push(format!(
777            "{} is missing column(s): {}",
778            contract.name,
779            missing.join(", ")
780        ));
781    }
782    Ok(())
783}
784
785fn inspect_index_contract(
786    client: &mut impl GenericClient,
787    schema: &str,
788    contract: &IndexContract,
789    issues: &mut Vec<String>,
790) -> Result<(), SetupError> {
791    let Some(index) = index_info(client, schema, contract.name)? else {
792        return Ok(());
793    };
794
795    if index.relkind != "i" && index.relkind != "I" {
796        issues.push(format!("{} exists but is not an index", contract.name));
797        return Ok(());
798    }
799    if index.table_name.as_deref() != Some(contract.table) {
800        issues.push(format!(
801            "{} is attached to {}, expected {}",
802            contract.name,
803            index.table_name.as_deref().unwrap_or("<unknown>"),
804            contract.table
805        ));
806    }
807    if index.method.as_deref() != Some(contract.method) {
808        issues.push(format!(
809            "{} uses access method {}, expected {}",
810            contract.name,
811            index.method.as_deref().unwrap_or("<unknown>"),
812            contract.method
813        ));
814    }
815    Ok(())
816}
817
818fn relation_kind(
819    client: &mut impl GenericClient,
820    schema: &str,
821    relation: &str,
822) -> Result<Option<String>, SetupError> {
823    let row = client
824        .query_opt(
825            "SELECT c.relkind::TEXT
826             FROM pg_class c
827             JOIN pg_namespace n ON n.oid = c.relnamespace
828             WHERE n.nspname = $1 AND c.relname = $2",
829            &[&schema, &relation],
830        )
831        .map_err(|err| SetupError::CreationFailed {
832            object: format!("{relation} preflight"),
833            message: err.to_string(),
834        })?;
835    Ok(row.map(|row| row.get(0)))
836}
837
838fn table_columns(
839    client: &mut impl GenericClient,
840    schema: &str,
841    table: &str,
842) -> Result<HashSet<String>, SetupError> {
843    let rows = client
844        .query(
845            "SELECT a.attname
846             FROM pg_attribute a
847             JOIN pg_class c ON c.oid = a.attrelid
848             JOIN pg_namespace n ON n.oid = c.relnamespace
849             WHERE n.nspname = $1
850               AND c.relname = $2
851               AND a.attnum > 0
852               AND NOT a.attisdropped",
853            &[&schema, &table],
854        )
855        .map_err(|err| SetupError::CreationFailed {
856            object: format!("{table} preflight"),
857            message: err.to_string(),
858        })?;
859    Ok(rows.into_iter().map(|row| row.get(0)).collect())
860}
861
862struct ExistingIndexInfo {
863    relkind: String,
864    table_name: Option<String>,
865    method: Option<String>,
866}
867
868fn index_info(
869    client: &mut impl GenericClient,
870    schema: &str,
871    index: &str,
872) -> Result<Option<ExistingIndexInfo>, SetupError> {
873    let row = client
874        .query_opt(
875            "SELECT c.relkind::TEXT,
876                    table_class.relname::TEXT AS table_name,
877                    am.amname::TEXT AS method
878             FROM pg_class c
879             JOIN pg_namespace n ON n.oid = c.relnamespace
880             LEFT JOIN pg_index idx ON idx.indexrelid = c.oid
881             LEFT JOIN pg_class table_class ON table_class.oid = idx.indrelid
882             LEFT JOIN pg_am am ON am.oid = c.relam
883             WHERE n.nspname = $1 AND c.relname = $2",
884            &[&schema, &index],
885        )
886        .map_err(|err| SetupError::CreationFailed {
887            object: format!("{index} preflight"),
888            message: err.to_string(),
889        })?;
890
891    Ok(row.map(|row| ExistingIndexInfo {
892        relkind: row.get(0),
893        table_name: row.get(1),
894        method: row.get(2),
895    }))
896}
897
898pub fn validate_standalone_request(request: &StandaloneSetupRequest) -> Result<(), SetupError> {
899    if !request.standalone {
900        return Err(SetupError::AttachedModeRefused);
901    }
902    if request.schema != DEFAULT_SCHEMA {
903        return Err(SetupError::CreationFailed {
904            object: "schema".to_string(),
905            message: "standalone code-index schema must be `public` for daemon adoption"
906                .to_string(),
907        });
908    }
909    Ok(())
910}
911
912fn qualified_relation(schema: &str, relation: &str, label: &str) -> Result<String, SetupError> {
913    Ok(format!(
914        "{}.{}",
915        quote_identifier(schema, "schema")?,
916        quote_identifier(relation, label)?
917    ))
918}
919
920fn execute_postgres_ddl(
921    ctx: &mut SetupContext<'_>,
922    object: &str,
923    sql: &str,
924) -> Result<(), SetupError> {
925    let Some(pg) = ctx.pg.as_deref_mut() else {
926        return Err(SetupError::ConnectionFailed {
927            store: "postgres".to_string(),
928            message: "PostgreSQL connection was not supplied to setup context".to_string(),
929        });
930    };
931
932    pg.batch_execute(sql)
933        .map_err(|err| SetupError::CreationFailed {
934            object: object.to_string(),
935            message: err.to_string(),
936        })
937}
938
939fn invalid_object(name: &str, err: SetupError) -> OwnedObject {
940    let message = err.to_string();
941    let object_name = name.to_string();
942    OwnedObject {
943        name: object_name.clone(),
944        store: StoreKind::Postgres,
945        creator: Box::new(move |_| {
946            Err(SetupError::CreationFailed {
947                object: object_name.clone(),
948                message: message.clone(),
949            })
950        }),
951    }
952}
953
954fn quote_identifier(value: &str, label: &str) -> Result<String, SetupError> {
955    let trimmed = value.trim();
956    if trimmed.is_empty() {
957        return Err(SetupError::CreationFailed {
958            object: label.to_string(),
959            message: format!("{label} identifier must not be empty"),
960        });
961    }
962    if trimmed.contains('\0') {
963        return Err(SetupError::CreationFailed {
964            object: label.to_string(),
965            message: format!("{label} identifier must not contain NUL bytes"),
966        });
967    }
968    Ok(format!("\"{}\"", trimmed.replace('"', "\"\"")))
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974    use gobby_core::setup::{StandaloneSetup, StoreKind};
975    use postgres::NoTls;
976
977    #[test]
978    fn standalone_setup_declares_public_daemon_code_index_subset() {
979        let setup = GcodeStandaloneSetup::new("public");
980        assert_eq!(setup.namespace(), "gcode");
981        assert_eq!(setup.schema(), "public");
982
983        let object_names: Vec<String> = setup
984            .owned_objects()
985            .into_iter()
986            .map(|object| object.name)
987            .collect();
988
989        assert!(
990            object_names
991                .iter()
992                .any(|name| name.contains("indexed_files"))
993        );
994        assert!(object_names.iter().any(|name| name.contains("symbols")));
995        assert!(
996            object_names
997                .iter()
998                .any(|name| name.contains("content_chunks"))
999        );
1000        assert!(object_names.iter().any(|name| name.contains("idx_cif")));
1001        assert!(object_names.iter().any(|name| name.contains("bm25")));
1002
1003        let forbidden = [
1004            "config_store",
1005            "schema_migrations",
1006            "secrets",
1007            ".gobby/project.json",
1008            "project_json",
1009            "code_graph_sync_state",
1010            "code_vector_sync_state",
1011        ];
1012        for name in object_names {
1013            for forbidden_name in forbidden {
1014                assert!(
1015                    !name.contains(forbidden_name),
1016                    "standalone setup declared forbidden object {name}"
1017                );
1018            }
1019        }
1020    }
1021
1022    #[test]
1023    fn standalone_setup_uses_gobby_core_contract() {
1024        fn assert_standalone_setup<T: StandaloneSetup>() {}
1025        assert_standalone_setup::<GcodeStandaloneSetup>();
1026
1027        let setup = GcodeStandaloneSetup::new("public");
1028        let objects = setup.owned_objects();
1029        assert!(
1030            objects
1031                .iter()
1032                .all(|object| object.store == StoreKind::Postgres)
1033        );
1034        assert!(
1035            objects
1036                .iter()
1037                .any(|object| object.name == "code_symbols table")
1038        );
1039        assert!(
1040            objects
1041                .iter()
1042                .any(|object| object.name == "code_symbols_search_bm25 index")
1043        );
1044        assert!(
1045            objects
1046                .iter()
1047                .any(|object| object.name == "pg_search extension")
1048        );
1049    }
1050
1051    #[test]
1052    fn standalone_setup_rejects_non_public_schema() {
1053        let request = StandaloneSetupRequest::new(
1054            true,
1055            Some("postgresql://localhost/gcode".to_string()),
1056            Some("gcode_ci".to_string()),
1057        );
1058        let err = validate_standalone_request(&request).expect_err("non-public schema fails");
1059        assert!(err.to_string().contains("public"));
1060    }
1061
1062    #[test]
1063    fn overwrite_reset_sql_is_allowlisted() {
1064        let sql = postgres_overwrite_reset_sql("public").expect("reset SQL");
1065
1066        for table in CODE_INDEX_TABLES {
1067            assert!(
1068                sql.contains(&format!("DROP TABLE IF EXISTS \"public\".\"{table}\";")),
1069                "{sql}"
1070            );
1071        }
1072        for index in CODE_INDEX_INDEXES {
1073            assert!(
1074                sql.contains(&format!("DROP INDEX IF EXISTS \"public\".\"{index}\";")),
1075                "{sql}"
1076            );
1077        }
1078
1079        for forbidden in [
1080            "config_store",
1081            "schema_migrations",
1082            "secrets",
1083            "tasks",
1084            "sessions",
1085            "memory",
1086            ".gobby/project.json",
1087        ] {
1088            assert!(!sql.contains(forbidden), "{sql}");
1089        }
1090        assert!(!sql.contains("CASCADE"), "{sql}");
1091        assert!(!sql.contains("DROP DATABASE"), "{sql}");
1092        assert!(!sql.contains("DROP SCHEMA"), "{sql}");
1093    }
1094
1095    #[test]
1096    fn overwrite_guidance_names_flag() {
1097        let request = StandaloneSetupRequest::new(true, None, None);
1098        assert!(!request.overwrite_code_index);
1099        assert!(OVERWRITE_GUIDANCE.contains("--overwrite-code-index"));
1100    }
1101
1102    #[test]
1103    #[serial_test::serial]
1104    fn overwrite_recreates_incompatible_code_index_and_preserves_sentinel_table() {
1105        let Ok(database_url) = std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL") else {
1106            return;
1107        };
1108        let mut client =
1109            Client::connect(&database_url, NoTls).expect("connect test PostgreSQL hub");
1110        cleanup_code_index_relations(&mut client);
1111        client
1112            .batch_execute(
1113                "CREATE TABLE public.code_symbols (id TEXT PRIMARY KEY);
1114                 CREATE TABLE IF NOT EXISTS public.gobby_owned_sentinel (
1115                     key TEXT PRIMARY KEY,
1116                     value TEXT NOT NULL
1117                 );
1118                 INSERT INTO public.gobby_owned_sentinel (key, value)
1119                 VALUES ('gcode-overwrite-sentinel', 'keep-me')
1120                 ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;",
1121            )
1122            .expect("seed incompatible code index and sentinel");
1123
1124        let request = StandaloneSetupRequest::new(true, Some(database_url.clone()), None);
1125        let err = run_standalone_setup(&request, &mut client)
1126            .expect_err("incompatible setup fails without overwrite");
1127        assert!(err.to_string().contains("--overwrite-code-index"));
1128
1129        let mut overwrite = StandaloneSetupRequest::new(true, Some(database_url), None);
1130        overwrite.overwrite_code_index = true;
1131        run_standalone_setup(&overwrite, &mut client).expect("overwrite setup succeeds");
1132
1133        let has_project_id: bool = client
1134            .query_one(
1135                "SELECT EXISTS(
1136                    SELECT 1
1137                    FROM pg_attribute
1138                    WHERE attrelid = 'public.code_symbols'::regclass
1139                      AND attname = 'project_id'
1140                      AND attnum > 0
1141                      AND NOT attisdropped
1142                )",
1143                &[],
1144            )
1145            .expect("check recreated code_symbols")
1146            .get(0);
1147        assert!(has_project_id);
1148
1149        let sentinel: String = client
1150            .query_one(
1151                "SELECT value FROM public.gobby_owned_sentinel WHERE key = 'gcode-overwrite-sentinel'",
1152                &[],
1153            )
1154            .expect("read sentinel")
1155            .get(0);
1156        assert_eq!(sentinel, "keep-me");
1157
1158        cleanup_code_index_relations(&mut client);
1159        client
1160            .batch_execute(
1161                "DELETE FROM public.gobby_owned_sentinel WHERE key = 'gcode-overwrite-sentinel';
1162                 DROP TABLE IF EXISTS public.gobby_owned_sentinel;",
1163            )
1164            .expect("cleanup sentinel");
1165    }
1166
1167    fn cleanup_code_index_relations(client: &mut Client) {
1168        let sql = postgres_overwrite_reset_sql("public").expect("reset SQL");
1169        client
1170            .batch_execute(&sql)
1171            .expect("cleanup code index objects");
1172    }
1173}