Skip to main content

gobby_code/commands/
setup.rs

1use anyhow::Context as _;
2use gobby_core::provisioning::{
3    DEFAULT_EMBEDDING_VECTOR_DIM, DEFAULT_LM_STUDIO_API_BASE, DEFAULT_OLLAMA_API_BASE,
4    DEFAULT_OLLAMA_MODEL, DockerProvisioningReport, DockerServiceOptions, EmbeddingBootstrap,
5    StandaloneConfig, compose_file_path, gcore_config_path, provision_docker_services,
6};
7use postgres::{Client, NoTls};
8use std::net::{TcpStream, ToSocketAddrs};
9use std::time::Duration;
10
11use crate::config::{self, QdrantConfig};
12use crate::db;
13use crate::graph::code_graph;
14use crate::output::{self, Format};
15use crate::setup::{
16    self, StandaloneEmbeddingStatus, StandaloneServicesStatus, StandaloneSetupRequest,
17};
18use crate::vector::code_symbols;
19
20pub fn run(request: StandaloneSetupRequest, format: Format, quiet: bool) -> anyhow::Result<()> {
21    setup::validate_standalone_request(&request)?;
22
23    let home = db::gobby_home()?;
24    let mut service_options = DockerServiceOptions::new(home.clone());
25    apply_service_overrides(&request, &mut service_options);
26
27    let embedding = resolve_embedding_bootstrap(&request)?;
28    let (database_url, service_report) = resolve_or_provision_database(&request, &service_options)?;
29    let mut client = connect_postgres_with_retry(&database_url, service_report.is_some())?;
30    if request.overwrite_code_index {
31        clear_overwrite_projections(&home, &request, &service_options, service_report.as_ref())?;
32    }
33    let mut status = setup::run_standalone_setup(&request, &mut client)?;
34
35    let config_file = write_gcore_config(
36        &home,
37        &request,
38        &service_options,
39        &database_url,
40        service_report.as_ref(),
41        embedding.as_ref(),
42    )?;
43    status.config_file = Some(config_file.display().to_string());
44    status.services = Some(match service_report {
45        Some(report) => StandaloneServicesStatus {
46            provisioned: true,
47            compose_file: Some(report.compose_file.display().to_string()),
48            health_checks: report.health_checks,
49        },
50        None => StandaloneServicesStatus {
51            provisioned: false,
52            compose_file: service_configured_compose_file(&home),
53            health_checks: Vec::new(),
54        },
55    });
56    status.embedding = embedding.map(|embedding| StandaloneEmbeddingStatus {
57        provider: embedding.provider,
58        api_base: embedding.api_base,
59        model: embedding.model,
60        vector_dim: embedding.vector_dim,
61        api_key_env: embedding.api_key_env,
62    });
63
64    match format {
65        Format::Json => output::print_json(&status),
66        Format::Text => {
67            if !quiet {
68                output::print_text(&format!(
69                    "Standalone gcode setup complete in schema {}",
70                    status.schema
71                ))?;
72            }
73            Ok(())
74        }
75    }
76}
77
78struct OverwriteProjectionConfigs {
79    falkordb: Option<config::FalkorConfig>,
80    qdrant: Option<QdrantConfig>,
81}
82
83fn clear_overwrite_projections(
84    home: &std::path::Path,
85    request: &StandaloneSetupRequest,
86    service_options: &DockerServiceOptions,
87    service_report: Option<&DockerProvisioningReport>,
88) -> anyhow::Result<()> {
89    let configs = overwrite_projection_configs(home, request, service_options, service_report)?;
90    if let Some(falkordb) = configs.falkordb {
91        code_graph::clear_all_code_index(&falkordb)
92            .context("failed to clear FalkorDB code-index projection during overwrite setup")?;
93    }
94    if let Some(qdrant) = configs.qdrant {
95        code_symbols::delete_code_symbol_collections_with_prefix(&qdrant)
96            .context("failed to delete Qdrant code-symbol collections during overwrite setup")?;
97    }
98    Ok(())
99}
100
101fn overwrite_projection_configs(
102    home: &std::path::Path,
103    request: &StandaloneSetupRequest,
104    service_options: &DockerServiceOptions,
105    service_report: Option<&DockerProvisioningReport>,
106) -> anyhow::Result<OverwriteProjectionConfigs> {
107    let mut standalone = StandaloneConfig::read_at(&gcore_config_path(home))?
108        .unwrap_or_else(StandaloneConfig::empty);
109
110    if service_report.is_some() {
111        standalone.set("databases.falkordb.host", &service_options.falkordb_host);
112        standalone.set(
113            "databases.falkordb.port",
114            service_options.falkordb_port.to_string(),
115        );
116        standalone.set(
117            "databases.falkordb.password",
118            &service_options.falkordb_password,
119        );
120        standalone.set("databases.qdrant.url", service_options.qdrant_url());
121    }
122
123    if let Some(host) = request.falkordb_host.as_deref() {
124        standalone.set("databases.falkordb.host", host);
125    }
126    if let Some(port) = request.falkordb_port {
127        standalone.set("databases.falkordb.port", port.to_string());
128    }
129    if let Some(password) = request.falkordb_password.as_deref() {
130        standalone.set("databases.falkordb.password", password);
131    }
132    if let Some(qdrant_url) = request.qdrant_url.as_deref() {
133        standalone.set("databases.qdrant.url", qdrant_url);
134    }
135
136    let falkordb = gobby_core::config::resolve_falkordb_config(&mut standalone).map(|connection| {
137        config::FalkorConfig {
138            host: connection.host,
139            port: connection.port,
140            password: connection.password,
141            graph_name: config::FALKORDB_GRAPH_NAME.to_string(),
142        }
143    });
144    let qdrant = gobby_core::config::resolve_qdrant_config(&mut standalone);
145
146    Ok(OverwriteProjectionConfigs { falkordb, qdrant })
147}
148
149fn resolve_or_provision_database(
150    request: &StandaloneSetupRequest,
151    service_options: &DockerServiceOptions,
152) -> anyhow::Result<(String, Option<DockerProvisioningReport>)> {
153    if let Some(database_url) = request.database_url.as_deref() {
154        return Ok((database_url.to_string(), None));
155    }
156
157    if request.no_services {
158        return db::resolve_database_url().map(|url| (url, None));
159    }
160
161    match db::resolve_database_url() {
162        Ok(database_url) => Ok((database_url, None)),
163        Err(_) => {
164            let report = provision_docker_services(service_options)
165                .context("failed to provision standalone Docker services")?;
166            Ok((service_options.database_url(), Some(report)))
167        }
168    }
169}
170
171fn apply_service_overrides(
172    request: &StandaloneSetupRequest,
173    service_options: &mut DockerServiceOptions,
174) {
175    if let Some(host) = request.falkordb_host.as_deref() {
176        service_options.falkordb_host = host.to_string();
177    }
178    if let Some(port) = request.falkordb_port {
179        service_options.falkordb_port = port;
180    }
181    if let Some(password) = request.falkordb_password.as_deref() {
182        service_options.falkordb_password = password.to_string();
183    }
184}
185
186fn connect_postgres_with_retry(database_url: &str, retry: bool) -> anyhow::Result<Client> {
187    let attempts = if retry { 30 } else { 1 };
188    let mut last_error = None;
189    for attempt in 0..attempts {
190        match Client::connect(database_url, NoTls) {
191            Ok(client) => return Ok(client),
192            Err(err) => last_error = Some(err),
193        }
194        if attempt + 1 < attempts {
195            std::thread::sleep(Duration::from_secs(2));
196        }
197    }
198    match last_error {
199        Some(err) => Err(anyhow::Error::new(err)
200            .context("failed to connect to the standalone PostgreSQL database")),
201        None => anyhow::bail!("failed to connect to the standalone PostgreSQL database"),
202    }
203}
204
205fn write_gcore_config(
206    home: &std::path::Path,
207    request: &StandaloneSetupRequest,
208    service_options: &DockerServiceOptions,
209    database_url: &str,
210    service_report: Option<&DockerProvisioningReport>,
211    embedding: Option<&EmbeddingBootstrap>,
212) -> anyhow::Result<std::path::PathBuf> {
213    let path = gcore_config_path(home);
214    let mut config = StandaloneConfig::read_at(&path)?.unwrap_or_else(StandaloneConfig::empty);
215
216    config.set("databases.postgres.dsn", database_url);
217
218    if let Some(report) = service_report {
219        config.set("databases.falkordb.host", &service_options.falkordb_host);
220        config.set(
221            "databases.falkordb.port",
222            service_options.falkordb_port.to_string(),
223        );
224        config.set(
225            "databases.falkordb.password",
226            &service_options.falkordb_password,
227        );
228        config.remove("databases.falkordb.requirepass");
229        config.set("databases.qdrant.url", service_options.qdrant_url());
230        config.set(
231            "services.compose_file",
232            report.compose_file.display().to_string(),
233        );
234    } else {
235        if let Some(host) = request.falkordb_host.as_deref() {
236            config.set("databases.falkordb.host", host);
237        }
238        if let Some(port) = request.falkordb_port {
239            config.set("databases.falkordb.port", port.to_string());
240        }
241        if let Some(password) = request.falkordb_password.as_deref() {
242            config.set("databases.falkordb.password", password);
243            config.remove("databases.falkordb.requirepass");
244        }
245        if let Some(qdrant_url) = request.qdrant_url.as_deref() {
246            config.set("databases.qdrant.url", qdrant_url);
247        }
248    }
249
250    if let Some(embedding) = embedding {
251        config.set("embeddings.provider", &embedding.provider);
252        config.set("embeddings.api_base", &embedding.api_base);
253        config.set("embeddings.model", &embedding.model);
254        config.set("embeddings.vector_dim", embedding.vector_dim.to_string());
255        match embedding.api_key_env.as_deref() {
256            Some(api_key_env) => config.set("embeddings.api_key_env", api_key_env),
257            None => config.remove("embeddings.api_key_env"),
258        }
259    }
260
261    config.write_at(&path)?;
262    Ok(path)
263}
264
265fn service_configured_compose_file(home: &std::path::Path) -> Option<String> {
266    let compose = compose_file_path(home);
267    compose.exists().then(|| compose.display().to_string())
268}
269
270fn resolve_embedding_bootstrap(
271    request: &StandaloneSetupRequest,
272) -> anyhow::Result<Option<EmbeddingBootstrap>> {
273    let provider = request
274        .embedding_provider
275        .as_deref()
276        .map(|provider| provider.trim().to_ascii_lowercase());
277
278    let mut embedding = match provider.as_deref() {
279        Some("none") => return Ok(None),
280        Some("lm-studio") | Some("lmstudio") => EmbeddingBootstrap::lm_studio(),
281        Some("ollama") => EmbeddingBootstrap::ollama(),
282        Some("openai-compatible") | Some("openai") | Some("remote") => {
283            explicit_embedding_bootstrap(request)?
284        }
285        Some(other) => anyhow::bail!(
286            "unsupported embedding provider `{other}`; expected lm-studio, ollama, openai-compatible, or none"
287        ),
288        None if request.embedding_api_base.is_some() || request.embedding_model.is_some() => {
289            explicit_embedding_bootstrap(request)?
290        }
291        None if endpoint_reachable(DEFAULT_LM_STUDIO_API_BASE) => EmbeddingBootstrap::lm_studio(),
292        None if endpoint_reachable(DEFAULT_OLLAMA_API_BASE) => EmbeddingBootstrap::ollama(),
293        None => EmbeddingBootstrap::lm_studio(),
294    };
295
296    if let Some(api_base) = request.embedding_api_base.as_deref() {
297        embedding.api_base = api_base.to_string();
298    }
299    if let Some(model) = request.embedding_model.as_deref() {
300        embedding.model = model.to_string();
301    }
302    if let Some(vector_dim) = request.embedding_vector_dim {
303        if vector_dim == 0 {
304            anyhow::bail!("--embedding-vector-dim must be positive");
305        }
306        embedding.vector_dim = vector_dim;
307    }
308    if let Some(api_key_env) = request.embedding_api_key_env.as_deref() {
309        embedding.api_key_env = Some(api_key_env.to_string());
310    }
311
312    Ok(Some(embedding))
313}
314
315fn explicit_embedding_bootstrap(
316    request: &StandaloneSetupRequest,
317) -> anyhow::Result<EmbeddingBootstrap> {
318    let Some(api_base) = request.embedding_api_base.as_deref() else {
319        anyhow::bail!("--embedding-api-base is required for openai-compatible embeddings");
320    };
321    Ok(EmbeddingBootstrap {
322        provider: "openai-compatible".to_string(),
323        api_base: api_base.to_string(),
324        model: request
325            .embedding_model
326            .clone()
327            .unwrap_or_else(|| DEFAULT_OLLAMA_MODEL.to_string()),
328        vector_dim: request
329            .embedding_vector_dim
330            .unwrap_or(DEFAULT_EMBEDDING_VECTOR_DIM),
331        api_key_env: request.embedding_api_key_env.clone(),
332    })
333}
334
335fn endpoint_reachable(api_base: &str) -> bool {
336    let Ok(url) = reqwest::Url::parse(api_base) else {
337        return false;
338    };
339    let Some(host) = url.host_str() else {
340        return false;
341    };
342    let Some(port) = url.port_or_known_default() else {
343        return false;
344    };
345    let Ok(addrs) = (host, port).to_socket_addrs() else {
346        return false;
347    };
348    addrs
349        .into_iter()
350        .any(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(150)).is_ok())
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    #[serial_test::serial]
359    fn standalone_command_installs_public_code_index_subset() {
360        let Ok(database_url) = std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL") else {
361            return;
362        };
363        let home = tempfile::tempdir().expect("temp home");
364        unsafe { std::env::set_var("GOBBY_HOME", home.path()) };
365        let request = StandaloneSetupRequest::new(true, Some(database_url.clone()), None);
366
367        run(request, Format::Json, true).expect("standalone setup runs");
368
369        let mut client =
370            postgres::Client::connect(&database_url, postgres::NoTls).expect("connect test db");
371        let exists: bool = client
372            .query_one("SELECT to_regclass('public.code_symbols') IS NOT NULL", &[])
373            .expect("check code_symbols")
374            .get(0);
375        assert!(exists);
376
377        let forbidden_exists: bool = client
378            .query_one("SELECT to_regclass('public.config_store') IS NOT NULL", &[])
379            .expect("check config_store")
380            .get(0);
381        assert!(!forbidden_exists);
382        assert!(home.path().join("gcore.yaml").exists());
383
384        client
385            .batch_execute(
386                "DROP INDEX IF EXISTS public.code_symbols_search_bm25;
387                 DROP INDEX IF EXISTS public.code_content_search_bm25;
388                 DROP TABLE IF EXISTS public.code_calls;
389                 DROP TABLE IF EXISTS public.code_imports;
390                 DROP TABLE IF EXISTS public.code_content_chunks;
391                 DROP TABLE IF EXISTS public.code_symbols;
392                 DROP TABLE IF EXISTS public.code_indexed_files;
393                 DROP TABLE IF EXISTS public.code_indexed_projects;",
394            )
395            .expect("drop code-index test objects");
396        unsafe { std::env::remove_var("GOBBY_HOME") };
397    }
398}