1use std::fmt;
9use std::path::{Path, PathBuf};
10
11use anyhow::Context as _;
12use gobby_core::project::{find_project_root, read_project_id};
13use postgres::Client;
14use uuid::Uuid;
15
16use super::services::{
17 read_standalone_config_optional, resolve_code_vector_settings, resolve_embedding_config,
18 resolve_falkordb_config, resolve_qdrant_config,
19};
20use crate::db;
21use crate::git::{self, WorktreeKind};
22use crate::utils::short_id;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct FalkorConfig {
27 pub host: String,
28 pub port: u16,
29 pub password: Option<String>,
30 pub graph_name: String,
31}
32
33pub type QdrantConfig = gobby_core::config::QdrantConfig;
35
36pub type EmbeddingConfig = gobby_core::config::EmbeddingConfig;
38
39pub const FALKORDB_GRAPH_NAME: &str = "gobby_code";
40pub const CODE_SYMBOL_COLLECTION_PREFIX: &str = "code_symbols_";
41
42pub const GOBBY_FALKORDB_HOST_ENV: &str = "GOBBY_FALKORDB_HOST";
43pub const GOBBY_FALKORDB_PORT_ENV: &str = "GOBBY_FALKORDB_PORT";
44pub const GOBBY_FALKORDB_PASSWORD_ENV: &str = "GOBBY_FALKORDB_PASSWORD";
45
46pub const FALKORDB_HOST_CONFIG_KEY: &str = "databases.falkordb.host";
47pub const FALKORDB_PORT_CONFIG_KEY: &str = "databases.falkordb.port";
48pub const FALKORDB_PASSWORD_CONFIG_KEY: &str = "databases.falkordb.requirepass";
49
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
51pub struct CodeVectorSettings {
52 pub vector_dim: Option<usize>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct ServiceConfigSelection {
57 pub falkordb: bool,
58 pub qdrant: bool,
59 pub embedding: bool,
60 pub code_vectors: bool,
61}
62
63impl ServiceConfigSelection {
64 pub const fn all() -> Self {
65 Self {
66 falkordb: true,
67 qdrant: true,
68 embedding: true,
69 code_vectors: true,
70 }
71 }
72
73 pub const fn database_only() -> Self {
74 Self {
75 falkordb: false,
76 qdrant: false,
77 embedding: false,
78 code_vectors: false,
79 }
80 }
81
82 pub const fn falkordb_only() -> Self {
83 Self {
84 falkordb: true,
85 qdrant: false,
86 embedding: false,
87 code_vectors: false,
88 }
89 }
90
91 pub const fn vectors() -> Self {
92 Self {
93 falkordb: false,
94 qdrant: true,
95 embedding: true,
96 code_vectors: true,
97 }
98 }
99
100 pub const fn hybrid_search() -> Self {
101 Self {
102 falkordb: true,
103 qdrant: true,
104 embedding: true,
105 code_vectors: false,
106 }
107 }
108}
109
110impl Default for ServiceConfigSelection {
111 fn default() -> Self {
112 Self::all()
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum CodeVectorConfigError {
118 InvalidVectorDim { source: &'static str, value: String },
119 Read { source: String },
120}
121
122impl fmt::Display for CodeVectorConfigError {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 match self {
125 Self::InvalidVectorDim { source, value } => write!(
126 f,
127 "invalid code vector dimension from {source}: `{value}` must be a positive integer"
128 ),
129 Self::Read { source } => write!(f, "failed to read code vector config: {source}"),
130 }
131 }
132}
133
134impl std::error::Error for CodeVectorConfigError {}
135
136impl FalkorConfig {
137 pub fn connection_config(&self) -> gobby_core::config::FalkorConfig {
138 gobby_core::config::FalkorConfig {
139 host: self.host.clone(),
140 port: self.port,
141 password: self.password.clone(),
142 }
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct Context {
149 pub database_url: String,
151 pub project_root: PathBuf,
153 pub project_id: String,
155 pub quiet: bool,
157 pub falkordb: Option<FalkorConfig>,
159 pub qdrant: Option<QdrantConfig>,
161 pub embedding: Option<EmbeddingConfig>,
163 pub code_vectors: CodeVectorSettings,
165 pub daemon_url: Option<String>,
167 pub index_scope: ProjectIndexScope,
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Eq)]
172pub enum ProjectIndexScope {
173 #[default]
174 Single,
175 Overlay {
176 overlay_project_id: String,
177 overlay_root: PathBuf,
178 parent_project_id: String,
179 parent_root: PathBuf,
180 },
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum MissingIdentity {
185 Error,
186 Generate,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum ProjectIdentitySource {
191 ProjectJson,
192 GcodeJson,
193 IsolatedRoot,
194 IsolatedOverlay,
195 LinkedWorktree,
196 Generated,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct ProjectIdentity {
201 pub project_id: String,
202 pub root: PathBuf,
203 pub source: ProjectIdentitySource,
204 pub warning: Option<String>,
205 pub should_write_gcode_json: bool,
206 pub index_scope: ProjectIndexScope,
207}
208
209impl Context {
210 pub fn resolve(project_override: Option<&str>, quiet: bool) -> anyhow::Result<Self> {
212 Self::resolve_with_services(project_override, quiet, ServiceConfigSelection::all())
213 }
214
215 pub fn resolve_with_services(
216 project_override: Option<&str>,
217 quiet: bool,
218 services: ServiceConfigSelection,
219 ) -> anyhow::Result<Self> {
220 let database_url = db::resolve_database_url()?;
221 let project_root = match project_override {
222 Some(p) => {
223 let path = PathBuf::from(p);
224 if path.is_dir() {
225 path.canonicalize()?
226 } else {
227 resolve_project_by_name(p, &database_url)?
229 }
230 }
231 None => detect_project_root()?,
232 };
233
234 let identity = resolve_project_identity(&project_root, MissingIdentity::Error)?;
235 warn_project_identity(&identity, quiet);
236 let project_id = identity.project_id;
237 let index_scope = identity.index_scope;
238
239 let standalone_config = read_standalone_config_optional();
241 let mut conn = db::connect_readonly(&database_url)?;
242 validate_parent_code_index(&mut conn, &index_scope)?;
243 let falkordb = if services.falkordb {
244 resolve_falkordb_config(&mut conn, standalone_config.clone(), quiet)?
245 } else {
246 None
247 };
248 let qdrant = if services.qdrant {
249 resolve_qdrant_config(&mut conn, standalone_config.clone(), quiet)?
250 } else {
251 None
252 };
253 let embedding = if services.embedding {
254 resolve_embedding_config(&mut conn, standalone_config.clone(), quiet)
255 } else {
256 None
257 };
258 let code_vectors = if services.code_vectors {
259 resolve_code_vector_settings(&mut conn, standalone_config)?
260 } else {
261 CodeVectorSettings::default()
262 };
263
264 let daemon_url = resolve_daemon_url();
265
266 Ok(Self {
267 database_url,
268 project_root,
269 project_id,
270 quiet,
271 falkordb,
272 qdrant,
273 embedding,
274 code_vectors,
275 daemon_url,
276 index_scope,
277 })
278 }
279
280 pub fn resolve_for_project_id(project_id: &str, quiet: bool) -> anyhow::Result<Self> {
287 let project_id = normalize_project_id(project_id)?;
288 let database_url = db::resolve_database_url()?;
289
290 let standalone_config = read_standalone_config_optional();
291 let mut conn = db::connect_readonly(&database_url)?;
292 let falkordb = resolve_falkordb_config(&mut conn, standalone_config, quiet)?;
293
294 let daemon_url = resolve_daemon_url();
295
296 Ok(Self {
297 database_url,
298 project_root: PathBuf::new(),
299 project_id,
300 quiet,
301 falkordb,
302 qdrant: None,
303 embedding: None,
304 code_vectors: CodeVectorSettings::default(),
305 daemon_url,
306 index_scope: ProjectIndexScope::Single,
307 })
308 }
309}
310
311pub fn resolve_project_identity(
312 project_root: &Path,
313 missing: MissingIdentity,
314) -> anyhow::Result<ProjectIdentity> {
315 let root = project_root
316 .canonicalize()
317 .unwrap_or_else(|_| absolute_fallback(project_root));
318
319 if let Some(marker) = crate::project::read_isolation_marker(&root) {
320 if marker.parent_project_path.is_some() ^ marker.parent_project_id.is_some() {
321 anyhow::bail!(
322 "invalid isolation marker in {}: parent_project_path and parent_project_id must be set together",
323 root.join(".gobby").join("project.json").display()
324 );
325 }
326
327 if is_self_referential_isolation_marker(&marker, &root) {
328 return resolve_non_isolated_project_identity(root, missing);
329 }
330
331 if let (Some(parent_project_path), Some(parent_project_id)) = (
332 marker.parent_project_path.as_deref(),
333 marker.parent_project_id.as_deref(),
334 ) {
335 let overlay_project_id = crate::project::code_index_id_for_root(&root);
336 let parent_root = resolve_parent_project_root(&root, parent_project_path);
337 let parent_project_id = normalize_project_id(parent_project_id)?;
338 return Ok(ProjectIdentity {
339 project_id: overlay_project_id.clone(),
340 root: root.clone(),
341 source: ProjectIdentitySource::IsolatedOverlay,
342 warning: None,
343 should_write_gcode_json: false,
344 index_scope: ProjectIndexScope::Overlay {
345 overlay_project_id,
346 overlay_root: root,
347 parent_project_id,
348 parent_root,
349 },
350 });
351 }
352
353 return Ok(ProjectIdentity {
354 project_id: crate::project::code_index_id_for_root(&root),
355 root,
356 source: ProjectIdentitySource::IsolatedRoot,
357 warning: None,
358 should_write_gcode_json: false,
359 index_scope: ProjectIndexScope::Single,
360 });
361 }
362
363 resolve_non_isolated_project_identity(root, missing)
364}
365
366fn resolve_non_isolated_project_identity(
367 root: PathBuf,
368 missing: MissingIdentity,
369) -> anyhow::Result<ProjectIdentity> {
370 let worktree = git::worktree_info(&root)?;
371 if worktree.kind == WorktreeKind::Linked {
372 let project_id = crate::project::code_index_id_for_root(&worktree.top_level);
373 let copied_id = read_project_id(&worktree.top_level).ok();
374 let warning = copied_id
375 .filter(|id| id != &project_id)
376 .map(|id| {
377 format!(
378 "linked git worktree {} has copied .gobby/project.json id {}; using filesystem-scoped code index id {}",
379 worktree.top_level.display(),
380 short_id(&id),
381 short_id(&project_id)
382 )
383 });
384
385 return Ok(ProjectIdentity {
386 project_id,
387 root: worktree.top_level,
388 source: ProjectIdentitySource::LinkedWorktree,
389 warning,
390 should_write_gcode_json: false,
391 index_scope: ProjectIndexScope::Single,
392 });
393 }
394
395 let gobby_dir = root.join(".gobby");
396 if gobby_dir.join("project.json").exists() {
397 return Ok(ProjectIdentity {
398 project_id: read_project_id(&root)?,
399 root,
400 source: ProjectIdentitySource::ProjectJson,
401 warning: None,
402 should_write_gcode_json: false,
403 index_scope: ProjectIndexScope::Single,
404 });
405 }
406 if gobby_dir.join("gcode.json").exists() {
407 return Ok(ProjectIdentity {
408 project_id: crate::project::read_gcode_json(&root)?,
409 root,
410 source: ProjectIdentitySource::GcodeJson,
411 warning: None,
412 should_write_gcode_json: false,
413 index_scope: ProjectIndexScope::Single,
414 });
415 }
416
417 match missing {
418 MissingIdentity::Generate => Ok(ProjectIdentity {
419 project_id: crate::project::code_index_id_for_root(&root),
420 root,
421 source: ProjectIdentitySource::Generated,
422 warning: None,
423 should_write_gcode_json: true,
424 index_scope: ProjectIndexScope::Single,
425 }),
426 MissingIdentity::Error => anyhow::bail!(
427 "No gcode project found. Run `gcode init` to initialize, \
428 or use `--project <path>` to specify a project directory."
429 ),
430 }
431}
432
433fn is_self_referential_isolation_marker(
434 marker: &crate::project::IsolationMarker,
435 root: &Path,
436) -> bool {
437 let Some(parent_project_path) = marker.parent_project_path.as_deref() else {
438 return false;
439 };
440 resolve_parent_project_root(root, parent_project_path) == root
441}
442
443fn resolve_parent_project_root(root: &Path, parent_project_path: &str) -> PathBuf {
444 let parent = PathBuf::from(parent_project_path);
445 let parent = if parent.is_absolute() {
446 parent
447 } else {
448 root.join(parent)
449 };
450 parent.canonicalize().unwrap_or(parent)
451}
452
453fn normalize_project_id(project_id: &str) -> anyhow::Result<String> {
454 let project_id = project_id.trim();
455 if project_id.is_empty() {
456 anyhow::bail!("--project-id must not be empty");
457 }
458 Uuid::parse_str(project_id)
459 .map(|id| id.to_string())
460 .with_context(|| format!("--project-id must be a UUID, got `{project_id}`"))
461}
462
463pub(crate) fn validate_parent_code_index(
464 conn: &mut Client,
465 scope: &ProjectIndexScope,
466) -> anyhow::Result<()> {
467 let ProjectIndexScope::Overlay {
468 parent_project_id,
469 parent_root,
470 ..
471 } = scope
472 else {
473 return Ok(());
474 };
475
476 let exists = conn
477 .query_one(
478 "SELECT EXISTS(
479 SELECT 1 FROM code_indexed_files WHERE project_id = $1
480 )",
481 &[parent_project_id],
482 )
483 .and_then(|row| row.try_get::<_, bool>(0))?;
484
485 if !exists {
486 anyhow::bail!(
487 "parent code index missing for {} ({})",
488 parent_root.display(),
489 short_id(parent_project_id)
490 );
491 }
492
493 Ok(())
494}
495
496pub fn warn_project_identity(identity: &ProjectIdentity, quiet: bool) {
497 if quiet {
498 return;
499 }
500 if let Some(warning) = &identity.warning {
501 eprintln!("Warning: {warning}");
502 }
503}
504
505fn resolve_project_by_name(name: &str, database_url: &str) -> anyhow::Result<PathBuf> {
509 let mut conn = db::connect_readonly(database_url)?;
510 let (slash_suffix, backslash_suffix) = project_name_suffixes(name);
511 let rows = conn.query(
512 "SELECT root_path FROM code_indexed_projects
513 WHERE root_path = $1
514 OR right(root_path, length($2)) = $2
515 OR right(root_path, length($3)) = $3
516 ORDER BY last_indexed_at DESC NULLS LAST",
517 &[&name, &slash_suffix, &backslash_suffix],
518 )?;
519
520 for row in rows {
521 let root_path: String = row.try_get("root_path")?;
522 let path = PathBuf::from(&root_path);
523 if path.is_dir() {
524 return Ok(path);
525 }
526 }
527
528 anyhow::bail!(
529 "Project '{}' not found. Run `gcode projects` to see indexed projects.",
530 name
531 )
532}
533
534pub(super) fn project_name_suffixes(name: &str) -> (String, String) {
535 (format!("/{name}"), format!("\\{name}"))
536}
537
538pub fn detect_project_root() -> anyhow::Result<PathBuf> {
545 let cwd = std::env::current_dir()?;
546 detect_project_root_from(&cwd)
547}
548
549pub fn detect_project_root_from(start: &Path) -> anyhow::Result<PathBuf> {
550 let start = start
551 .canonicalize()
552 .unwrap_or_else(|_| absolute_fallback(start));
553 let start = if start.is_file() {
554 start
555 .parent()
556 .map(Path::to_path_buf)
557 .unwrap_or_else(|| start.clone())
558 } else {
559 start
560 };
561
562 if let Some(root) = find_project_root(&start) {
564 return Ok(root.canonicalize().unwrap_or(root));
565 }
566
567 if let Ok(info) = git::worktree_info(&start)
569 && info.kind != WorktreeKind::NotGit
570 {
571 return Ok(info.top_level);
572 }
573
574 let mut dir = start.as_path();
576 loop {
577 if dir.join(".git").exists() || dir.join(".hg").exists() {
578 return Ok(dir.to_path_buf());
579 }
580 match dir.parent() {
581 Some(parent) => dir = parent,
582 None => return Ok(start), }
584 }
585}
586
587pub(crate) fn resolve_daemon_url() -> Option<String> {
595 if let Ok(port) = std::env::var("GOBBY_PORT")
597 && !port.is_empty()
598 {
599 return Some(format!("http://localhost:{port}"));
600 }
601
602 let bootstrap_path = db::bootstrap_path().ok();
604 if let Some(bootstrap_path) = bootstrap_path
605 && let Ok(contents) = std::fs::read_to_string(&bootstrap_path)
606 && let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents)
607 && let Some(port) = yaml.get("daemon_port").and_then(|v| v.as_u64())
608 {
609 let host = yaml
610 .get("bind_host")
611 .and_then(|v| v.as_str())
612 .unwrap_or("localhost");
613 return Some(format!("http://{}:{port}", client_daemon_host(host)));
614 }
615
616 Some("http://localhost:60887".to_string())
618}
619
620fn client_daemon_host(host: &str) -> String {
621 match host.trim() {
622 "" | "0.0.0.0" | "::" | "[::]" => "localhost".to_string(),
623 host if host.contains(':') && !host.starts_with('[') => format!("[{host}]"),
624 host => host.to_string(),
625 }
626}
627
628#[cfg(test)]
635pub(super) fn resolve_project_id(project_root: &Path) -> anyhow::Result<String> {
636 Ok(resolve_project_identity(project_root, MissingIdentity::Error)?.project_id)
637}
638
639fn absolute_fallback(path: &Path) -> PathBuf {
640 if path.is_absolute() {
641 path.to_path_buf()
642 } else {
643 std::env::current_dir()
644 .unwrap_or_else(|_| std::env::temp_dir())
645 .join(path)
646 }
647}