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