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