Skip to main content

gobby_code/
setup.rs

1use gobby_core::setup::{
2    OwnedObject, SetupContext, SetupError, SetupReport, StandaloneSetup, StoreKind,
3};
4use postgres::Client;
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                    return Err(err);
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    if request.overwrite_code_index {
612        reset_postgres_code_index(client, setup.schema())?;
613    } else {
614        ensure_postgres_code_index_compatible(client, setup.schema())?;
615    }
616
617    let mut ctx = SetupContext {
618        pg: Some(client),
619        falkor_config: None,
620        qdrant_config: None,
621        non_interactive: true,
622    };
623    let report = setup.create(&mut ctx)?;
624
625    Ok(StandaloneSetupStatus {
626        namespace: setup.namespace().to_string(),
627        schema: setup.schema().to_string(),
628        created: report.created,
629        skipped: report.skipped,
630        failed: report.failed,
631        config_file: None,
632        services: None,
633        embedding: None,
634    })
635}
636
637pub(crate) fn ensure_postgres_code_index_compatible(
638    client: &mut Client,
639    schema: &str,
640) -> Result<(), SetupError> {
641    let issues = incompatible_postgres_code_index_relations(client, schema)?;
642    if issues.is_empty() {
643        return Ok(());
644    }
645
646    Err(SetupError::CreationFailed {
647        object: "code-index preflight".to_string(),
648        message: format!(
649            "existing code-index PostgreSQL state is incompatible: {}. {OVERWRITE_GUIDANCE}",
650            issues.join("; ")
651        ),
652    })
653}
654
655pub(crate) fn reset_postgres_code_index(
656    client: &mut Client,
657    schema: &str,
658) -> Result<(), SetupError> {
659    let sql = postgres_overwrite_reset_sql(schema)?;
660    client
661        .batch_execute(&sql)
662        .map_err(|err| SetupError::CreationFailed {
663            object: "code-index overwrite reset".to_string(),
664            message: err.to_string(),
665        })
666}
667
668pub(crate) fn postgres_overwrite_reset_sql(schema: &str) -> Result<String, SetupError> {
669    let mut statements = Vec::new();
670    for index in CODE_INDEX_INDEXES {
671        statements.push(format!(
672            "DROP INDEX IF EXISTS {};",
673            qualified_relation(schema, index, "index")?
674        ));
675    }
676    for table in CODE_INDEX_TABLES.iter().rev() {
677        statements.push(format!(
678            "DROP TABLE IF EXISTS {};",
679            qualified_relation(schema, table, "table")?
680        ));
681    }
682    Ok(statements.join("\n"))
683}
684
685fn incompatible_postgres_code_index_relations(
686    client: &mut Client,
687    schema: &str,
688) -> Result<Vec<String>, SetupError> {
689    let mut issues = Vec::new();
690    for contract in TABLE_CONTRACTS {
691        inspect_table_contract(client, schema, contract, &mut issues)?;
692    }
693    for contract in INDEX_CONTRACTS {
694        inspect_index_contract(client, schema, contract, &mut issues)?;
695    }
696    Ok(issues)
697}
698
699fn inspect_table_contract(
700    client: &mut Client,
701    schema: &str,
702    contract: &TableContract,
703    issues: &mut Vec<String>,
704) -> Result<(), SetupError> {
705    let Some(kind) = relation_kind(client, schema, contract.name)? else {
706        return Ok(());
707    };
708    if kind != "r" {
709        issues.push(format!(
710            "{} exists but is not an ordinary table",
711            contract.name
712        ));
713        return Ok(());
714    }
715
716    let existing = table_columns(client, schema, contract.name)?;
717    let missing = contract
718        .required_columns
719        .iter()
720        .filter(|column| !existing.contains::<str>(column))
721        .copied()
722        .collect::<Vec<_>>();
723    if !missing.is_empty() {
724        issues.push(format!(
725            "{} is missing column(s): {}",
726            contract.name,
727            missing.join(", ")
728        ));
729    }
730    Ok(())
731}
732
733fn inspect_index_contract(
734    client: &mut Client,
735    schema: &str,
736    contract: &IndexContract,
737    issues: &mut Vec<String>,
738) -> Result<(), SetupError> {
739    let Some(index) = index_info(client, schema, contract.name)? else {
740        return Ok(());
741    };
742
743    if index.relkind != "i" && index.relkind != "I" {
744        issues.push(format!("{} exists but is not an index", contract.name));
745        return Ok(());
746    }
747    if index.table_name.as_deref() != Some(contract.table) {
748        issues.push(format!(
749            "{} is attached to {}, expected {}",
750            contract.name,
751            index.table_name.as_deref().unwrap_or("<unknown>"),
752            contract.table
753        ));
754    }
755    if index.method.as_deref() != Some(contract.method) {
756        issues.push(format!(
757            "{} uses access method {}, expected {}",
758            contract.name,
759            index.method.as_deref().unwrap_or("<unknown>"),
760            contract.method
761        ));
762    }
763    Ok(())
764}
765
766fn relation_kind(
767    client: &mut Client,
768    schema: &str,
769    relation: &str,
770) -> Result<Option<String>, SetupError> {
771    let row = client
772        .query_opt(
773            "SELECT c.relkind::TEXT
774             FROM pg_class c
775             JOIN pg_namespace n ON n.oid = c.relnamespace
776             WHERE n.nspname = $1 AND c.relname = $2",
777            &[&schema, &relation],
778        )
779        .map_err(|err| SetupError::CreationFailed {
780            object: format!("{relation} preflight"),
781            message: err.to_string(),
782        })?;
783    Ok(row.map(|row| row.get(0)))
784}
785
786fn table_columns(
787    client: &mut Client,
788    schema: &str,
789    table: &str,
790) -> Result<HashSet<String>, SetupError> {
791    let rows = client
792        .query(
793            "SELECT a.attname
794             FROM pg_attribute a
795             JOIN pg_class c ON c.oid = a.attrelid
796             JOIN pg_namespace n ON n.oid = c.relnamespace
797             WHERE n.nspname = $1
798               AND c.relname = $2
799               AND a.attnum > 0
800               AND NOT a.attisdropped",
801            &[&schema, &table],
802        )
803        .map_err(|err| SetupError::CreationFailed {
804            object: format!("{table} preflight"),
805            message: err.to_string(),
806        })?;
807    Ok(rows.into_iter().map(|row| row.get(0)).collect())
808}
809
810struct ExistingIndexInfo {
811    relkind: String,
812    table_name: Option<String>,
813    method: Option<String>,
814}
815
816fn index_info(
817    client: &mut Client,
818    schema: &str,
819    index: &str,
820) -> Result<Option<ExistingIndexInfo>, SetupError> {
821    let row = client
822        .query_opt(
823            "SELECT c.relkind::TEXT,
824                    table_class.relname::TEXT AS table_name,
825                    am.amname::TEXT AS method
826             FROM pg_class c
827             JOIN pg_namespace n ON n.oid = c.relnamespace
828             LEFT JOIN pg_index idx ON idx.indexrelid = c.oid
829             LEFT JOIN pg_class table_class ON table_class.oid = idx.indrelid
830             LEFT JOIN pg_am am ON am.oid = c.relam
831             WHERE n.nspname = $1 AND c.relname = $2",
832            &[&schema, &index],
833        )
834        .map_err(|err| SetupError::CreationFailed {
835            object: format!("{index} preflight"),
836            message: err.to_string(),
837        })?;
838
839    Ok(row.map(|row| ExistingIndexInfo {
840        relkind: row.get(0),
841        table_name: row.get(1),
842        method: row.get(2),
843    }))
844}
845
846pub fn validate_standalone_request(request: &StandaloneSetupRequest) -> Result<(), SetupError> {
847    if !request.standalone {
848        return Err(SetupError::AttachedModeRefused);
849    }
850    if request.schema != DEFAULT_SCHEMA {
851        return Err(SetupError::CreationFailed {
852            object: "schema".to_string(),
853            message: "standalone code-index schema must be `public` for daemon adoption"
854                .to_string(),
855        });
856    }
857    Ok(())
858}
859
860fn qualified_relation(schema: &str, relation: &str, label: &str) -> Result<String, SetupError> {
861    Ok(format!(
862        "{}.{}",
863        quote_identifier(schema, "schema")?,
864        quote_identifier(relation, label)?
865    ))
866}
867
868fn execute_postgres_ddl(
869    ctx: &mut SetupContext<'_>,
870    object: &str,
871    sql: &str,
872) -> Result<(), SetupError> {
873    let Some(pg) = ctx.pg.as_deref_mut() else {
874        return Err(SetupError::ConnectionFailed {
875            store: "postgres".to_string(),
876            message: "PostgreSQL connection was not supplied to setup context".to_string(),
877        });
878    };
879
880    pg.batch_execute(sql)
881        .map_err(|err| SetupError::CreationFailed {
882            object: object.to_string(),
883            message: err.to_string(),
884        })
885}
886
887fn invalid_object(name: &str, err: SetupError) -> OwnedObject {
888    let message = err.to_string();
889    let object_name = name.to_string();
890    OwnedObject {
891        name: object_name.clone(),
892        store: StoreKind::Postgres,
893        creator: Box::new(move |_| {
894            Err(SetupError::CreationFailed {
895                object: object_name.clone(),
896                message: message.clone(),
897            })
898        }),
899    }
900}
901
902fn quote_identifier(value: &str, label: &str) -> Result<String, SetupError> {
903    let trimmed = value.trim();
904    if trimmed.is_empty() {
905        return Err(SetupError::CreationFailed {
906            object: label.to_string(),
907            message: format!("{label} identifier must not be empty"),
908        });
909    }
910    if trimmed.contains('\0') {
911        return Err(SetupError::CreationFailed {
912            object: label.to_string(),
913            message: format!("{label} identifier must not contain NUL bytes"),
914        });
915    }
916    Ok(format!("\"{}\"", trimmed.replace('"', "\"\"")))
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use gobby_core::setup::{StandaloneSetup, StoreKind};
923    use postgres::NoTls;
924
925    #[test]
926    fn standalone_setup_declares_public_daemon_code_index_subset() {
927        let setup = GcodeStandaloneSetup::new("public");
928        assert_eq!(setup.namespace(), "gcode");
929        assert_eq!(setup.schema(), "public");
930
931        let object_names: Vec<String> = setup
932            .owned_objects()
933            .into_iter()
934            .map(|object| object.name)
935            .collect();
936
937        assert!(
938            object_names
939                .iter()
940                .any(|name| name.contains("indexed_files"))
941        );
942        assert!(object_names.iter().any(|name| name.contains("symbols")));
943        assert!(
944            object_names
945                .iter()
946                .any(|name| name.contains("content_chunks"))
947        );
948        assert!(object_names.iter().any(|name| name.contains("idx_cif")));
949        assert!(object_names.iter().any(|name| name.contains("bm25")));
950
951        let forbidden = [
952            "config_store",
953            "schema_migrations",
954            "secrets",
955            ".gobby/project.json",
956            "project_json",
957            "code_graph_sync_state",
958            "code_vector_sync_state",
959        ];
960        for name in object_names {
961            for forbidden_name in forbidden {
962                assert!(
963                    !name.contains(forbidden_name),
964                    "standalone setup declared forbidden object {name}"
965                );
966            }
967        }
968    }
969
970    #[test]
971    fn standalone_setup_uses_gobby_core_contract() {
972        fn assert_standalone_setup<T: StandaloneSetup>() {}
973        assert_standalone_setup::<GcodeStandaloneSetup>();
974
975        let setup = GcodeStandaloneSetup::new("public");
976        let objects = setup.owned_objects();
977        assert!(
978            objects
979                .iter()
980                .all(|object| object.store == StoreKind::Postgres)
981        );
982        assert!(
983            objects
984                .iter()
985                .any(|object| object.name == "code_symbols table")
986        );
987        assert!(
988            objects
989                .iter()
990                .any(|object| object.name == "code_symbols_search_bm25 index")
991        );
992        assert!(
993            objects
994                .iter()
995                .any(|object| object.name == "pg_search extension")
996        );
997    }
998
999    #[test]
1000    fn standalone_setup_rejects_non_public_schema() {
1001        let request = StandaloneSetupRequest::new(
1002            true,
1003            Some("postgresql://localhost/gcode".to_string()),
1004            Some("gcode_ci".to_string()),
1005        );
1006        let err = validate_standalone_request(&request).expect_err("non-public schema fails");
1007        assert!(err.to_string().contains("public"));
1008    }
1009
1010    #[test]
1011    fn overwrite_reset_sql_is_allowlisted() {
1012        let sql = postgres_overwrite_reset_sql("public").expect("reset SQL");
1013
1014        for table in CODE_INDEX_TABLES {
1015            assert!(
1016                sql.contains(&format!("DROP TABLE IF EXISTS \"public\".\"{table}\";")),
1017                "{sql}"
1018            );
1019        }
1020        for index in CODE_INDEX_INDEXES {
1021            assert!(
1022                sql.contains(&format!("DROP INDEX IF EXISTS \"public\".\"{index}\";")),
1023                "{sql}"
1024            );
1025        }
1026
1027        for forbidden in [
1028            "config_store",
1029            "schema_migrations",
1030            "secrets",
1031            "tasks",
1032            "sessions",
1033            "memory",
1034            ".gobby/project.json",
1035        ] {
1036            assert!(!sql.contains(forbidden), "{sql}");
1037        }
1038        assert!(!sql.contains("CASCADE"), "{sql}");
1039        assert!(!sql.contains("DROP DATABASE"), "{sql}");
1040        assert!(!sql.contains("DROP SCHEMA"), "{sql}");
1041    }
1042
1043    #[test]
1044    fn overwrite_guidance_names_flag() {
1045        let request = StandaloneSetupRequest::new(true, None, None);
1046        assert!(!request.overwrite_code_index);
1047        assert!(OVERWRITE_GUIDANCE.contains("--overwrite-code-index"));
1048    }
1049
1050    #[test]
1051    #[serial_test::serial]
1052    fn overwrite_recreates_incompatible_code_index_and_preserves_sentinel_table() {
1053        let Ok(database_url) = std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL") else {
1054            return;
1055        };
1056        let mut client =
1057            Client::connect(&database_url, NoTls).expect("connect test PostgreSQL hub");
1058        cleanup_code_index_relations(&mut client);
1059        client
1060            .batch_execute(
1061                "CREATE TABLE public.code_symbols (id TEXT PRIMARY KEY);
1062                 CREATE TABLE IF NOT EXISTS public.gobby_owned_sentinel (
1063                     key TEXT PRIMARY KEY,
1064                     value TEXT NOT NULL
1065                 );
1066                 INSERT INTO public.gobby_owned_sentinel (key, value)
1067                 VALUES ('gcode-overwrite-sentinel', 'keep-me')
1068                 ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;",
1069            )
1070            .expect("seed incompatible code index and sentinel");
1071
1072        let request = StandaloneSetupRequest::new(true, Some(database_url.clone()), None);
1073        let err = run_standalone_setup(&request, &mut client)
1074            .expect_err("incompatible setup fails without overwrite");
1075        assert!(err.to_string().contains("--overwrite-code-index"));
1076
1077        let mut overwrite = StandaloneSetupRequest::new(true, Some(database_url), None);
1078        overwrite.overwrite_code_index = true;
1079        run_standalone_setup(&overwrite, &mut client).expect("overwrite setup succeeds");
1080
1081        let has_project_id: bool = client
1082            .query_one(
1083                "SELECT EXISTS(
1084                    SELECT 1
1085                    FROM pg_attribute
1086                    WHERE attrelid = 'public.code_symbols'::regclass
1087                      AND attname = 'project_id'
1088                      AND attnum > 0
1089                      AND NOT attisdropped
1090                )",
1091                &[],
1092            )
1093            .expect("check recreated code_symbols")
1094            .get(0);
1095        assert!(has_project_id);
1096
1097        let sentinel: String = client
1098            .query_one(
1099                "SELECT value FROM public.gobby_owned_sentinel WHERE key = 'gcode-overwrite-sentinel'",
1100                &[],
1101            )
1102            .expect("read sentinel")
1103            .get(0);
1104        assert_eq!(sentinel, "keep-me");
1105
1106        cleanup_code_index_relations(&mut client);
1107        client
1108            .batch_execute(
1109                "DELETE FROM public.gobby_owned_sentinel WHERE key = 'gcode-overwrite-sentinel';
1110                 DROP TABLE IF EXISTS public.gobby_owned_sentinel;",
1111            )
1112            .expect("cleanup sentinel");
1113    }
1114
1115    fn cleanup_code_index_relations(client: &mut Client) {
1116        let sql = postgres_overwrite_reset_sql("public").expect("reset SQL");
1117        client
1118            .batch_execute(&sql)
1119            .expect("cleanup code index objects");
1120    }
1121}