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, PartialEq, Eq)]
56pub enum CodeVectorConfigError {
57 InvalidVectorDim { source: &'static str, value: String },
58 Read { source: String },
59}
60
61impl fmt::Display for CodeVectorConfigError {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::InvalidVectorDim { source, value } => write!(
65 f,
66 "invalid code vector dimension from {source}: `{value}` must be a positive integer"
67 ),
68 Self::Read { source } => write!(f, "failed to read code vector config: {source}"),
69 }
70 }
71}
72
73impl std::error::Error for CodeVectorConfigError {}
74
75impl FalkorConfig {
76 pub fn connection_config(&self) -> gobby_core::config::FalkorConfig {
77 gobby_core::config::FalkorConfig {
78 host: self.host.clone(),
79 port: self.port,
80 password: self.password.clone(),
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct Context {
88 pub database_url: String,
90 pub project_root: PathBuf,
92 pub project_id: String,
94 pub quiet: bool,
96 pub falkordb: Option<FalkorConfig>,
98 pub qdrant: Option<QdrantConfig>,
100 pub embedding: Option<EmbeddingConfig>,
102 pub code_vectors: CodeVectorSettings,
104 pub daemon_url: Option<String>,
106 pub index_scope: ProjectIndexScope,
108}
109
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub enum ProjectIndexScope {
112 #[default]
113 Single,
114 Overlay {
115 overlay_project_id: String,
116 overlay_root: PathBuf,
117 parent_project_id: String,
118 parent_root: PathBuf,
119 },
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum MissingIdentity {
124 Error,
125 Generate,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum ProjectIdentitySource {
130 ProjectJson,
131 GcodeJson,
132 IsolatedRoot,
133 IsolatedOverlay,
134 LinkedWorktree,
135 Generated,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct ProjectIdentity {
140 pub project_id: String,
141 pub root: PathBuf,
142 pub source: ProjectIdentitySource,
143 pub warning: Option<String>,
144 pub should_write_gcode_json: bool,
145 pub index_scope: ProjectIndexScope,
146}
147
148impl Context {
149 pub fn resolve(project_override: Option<&str>, quiet: bool) -> anyhow::Result<Self> {
151 let database_url = db::resolve_database_url()?;
152 let project_root = match project_override {
153 Some(p) => {
154 let path = PathBuf::from(p);
155 if path.is_dir() {
156 path.canonicalize()?
157 } else {
158 resolve_project_by_name(p, &database_url)?
160 }
161 }
162 None => detect_project_root()?,
163 };
164
165 let identity = resolve_project_identity(&project_root, MissingIdentity::Error)?;
166 warn_project_identity(&identity, quiet);
167 let project_id = identity.project_id;
168 let index_scope = identity.index_scope;
169
170 let standalone_config = read_standalone_config_optional();
172 let mut conn = db::connect_readonly(&database_url)?;
173 validate_parent_code_index(&mut conn, &index_scope)?;
174 let falkordb = resolve_falkordb_config(&mut conn, standalone_config.clone(), quiet)?;
175 let qdrant = resolve_qdrant_config(&mut conn, standalone_config.clone(), quiet)?;
176 let embedding = resolve_embedding_config(&mut conn, standalone_config.clone(), quiet);
177 let code_vectors = resolve_code_vector_settings(&mut conn, standalone_config)?;
178
179 let daemon_url = resolve_daemon_url();
180
181 Ok(Self {
182 database_url,
183 project_root,
184 project_id,
185 quiet,
186 falkordb,
187 qdrant,
188 embedding,
189 code_vectors,
190 daemon_url,
191 index_scope,
192 })
193 }
194
195 pub fn resolve_for_project_id(project_id: &str, quiet: bool) -> anyhow::Result<Self> {
202 let project_id = normalize_project_id(project_id)?;
203 let database_url = db::resolve_database_url()?;
204
205 let standalone_config = read_standalone_config_optional();
206 let mut conn = db::connect_readonly(&database_url)?;
207 let falkordb = resolve_falkordb_config(&mut conn, standalone_config, quiet)?;
208
209 let daemon_url = resolve_daemon_url();
210
211 Ok(Self {
212 database_url,
213 project_root: PathBuf::new(),
214 project_id,
215 quiet,
216 falkordb,
217 qdrant: None,
218 embedding: None,
219 code_vectors: CodeVectorSettings::default(),
220 daemon_url,
221 index_scope: ProjectIndexScope::Single,
222 })
223 }
224}
225
226pub fn resolve_project_identity(
227 project_root: &Path,
228 missing: MissingIdentity,
229) -> anyhow::Result<ProjectIdentity> {
230 let root = project_root
231 .canonicalize()
232 .unwrap_or_else(|_| absolute_fallback(project_root));
233
234 if let Some(marker) = crate::project::read_isolation_marker(&root) {
235 if marker.parent_project_path.is_some() ^ marker.parent_project_id.is_some() {
236 anyhow::bail!(
237 "invalid isolation marker in {}: parent_project_path and parent_project_id must be set together",
238 root.join(".gobby").join("project.json").display()
239 );
240 }
241
242 if is_self_referential_isolation_marker(&marker, &root) {
243 return resolve_non_isolated_project_identity(root, missing);
244 }
245
246 if let (Some(parent_project_path), Some(parent_project_id)) = (
247 marker.parent_project_path.as_deref(),
248 marker.parent_project_id.as_deref(),
249 ) {
250 let overlay_project_id = crate::project::code_index_id_for_root(&root);
251 let parent_root = resolve_parent_project_root(&root, parent_project_path);
252 let parent_project_id = normalize_project_id(parent_project_id)?;
253 return Ok(ProjectIdentity {
254 project_id: overlay_project_id.clone(),
255 root: root.clone(),
256 source: ProjectIdentitySource::IsolatedOverlay,
257 warning: None,
258 should_write_gcode_json: false,
259 index_scope: ProjectIndexScope::Overlay {
260 overlay_project_id,
261 overlay_root: root,
262 parent_project_id,
263 parent_root,
264 },
265 });
266 }
267
268 return Ok(ProjectIdentity {
269 project_id: crate::project::code_index_id_for_root(&root),
270 root,
271 source: ProjectIdentitySource::IsolatedRoot,
272 warning: None,
273 should_write_gcode_json: false,
274 index_scope: ProjectIndexScope::Single,
275 });
276 }
277
278 resolve_non_isolated_project_identity(root, missing)
279}
280
281fn resolve_non_isolated_project_identity(
282 root: PathBuf,
283 missing: MissingIdentity,
284) -> anyhow::Result<ProjectIdentity> {
285 let worktree = git::worktree_info(&root)?;
286 if worktree.kind == WorktreeKind::Linked {
287 let project_id = crate::project::code_index_id_for_root(&worktree.top_level);
288 let copied_id = read_project_id(&worktree.top_level).ok();
289 let warning = copied_id
290 .filter(|id| id != &project_id)
291 .map(|id| {
292 format!(
293 "linked git worktree {} has copied .gobby/project.json id {}; using filesystem-scoped code index id {}",
294 worktree.top_level.display(),
295 short_id(&id),
296 short_id(&project_id)
297 )
298 });
299
300 return Ok(ProjectIdentity {
301 project_id,
302 root: worktree.top_level,
303 source: ProjectIdentitySource::LinkedWorktree,
304 warning,
305 should_write_gcode_json: false,
306 index_scope: ProjectIndexScope::Single,
307 });
308 }
309
310 let gobby_dir = root.join(".gobby");
311 if gobby_dir.join("project.json").exists() {
312 return Ok(ProjectIdentity {
313 project_id: read_project_id(&root)?,
314 root,
315 source: ProjectIdentitySource::ProjectJson,
316 warning: None,
317 should_write_gcode_json: false,
318 index_scope: ProjectIndexScope::Single,
319 });
320 }
321 if gobby_dir.join("gcode.json").exists() {
322 return Ok(ProjectIdentity {
323 project_id: crate::project::read_gcode_json(&root)?,
324 root,
325 source: ProjectIdentitySource::GcodeJson,
326 warning: None,
327 should_write_gcode_json: false,
328 index_scope: ProjectIndexScope::Single,
329 });
330 }
331
332 match missing {
333 MissingIdentity::Generate => Ok(ProjectIdentity {
334 project_id: crate::project::code_index_id_for_root(&root),
335 root,
336 source: ProjectIdentitySource::Generated,
337 warning: None,
338 should_write_gcode_json: true,
339 index_scope: ProjectIndexScope::Single,
340 }),
341 MissingIdentity::Error => anyhow::bail!(
342 "No gcode project found. Run `gcode init` to initialize, \
343 or use `--project <path>` to specify a project directory."
344 ),
345 }
346}
347
348fn is_self_referential_isolation_marker(
349 marker: &crate::project::IsolationMarker,
350 root: &Path,
351) -> bool {
352 let Some(parent_project_path) = marker.parent_project_path.as_deref() else {
353 return false;
354 };
355 resolve_parent_project_root(root, parent_project_path) == root
356}
357
358fn resolve_parent_project_root(root: &Path, parent_project_path: &str) -> PathBuf {
359 let parent = PathBuf::from(parent_project_path);
360 let parent = if parent.is_absolute() {
361 parent
362 } else {
363 root.join(parent)
364 };
365 parent.canonicalize().unwrap_or(parent)
366}
367
368fn normalize_project_id(project_id: &str) -> anyhow::Result<String> {
369 let project_id = project_id.trim();
370 if project_id.is_empty() {
371 anyhow::bail!("--project-id must not be empty");
372 }
373 Uuid::parse_str(project_id)
374 .map(|id| id.to_string())
375 .with_context(|| format!("--project-id must be a UUID, got `{project_id}`"))
376}
377
378pub(crate) fn validate_parent_code_index(
379 conn: &mut Client,
380 scope: &ProjectIndexScope,
381) -> anyhow::Result<()> {
382 let ProjectIndexScope::Overlay {
383 parent_project_id,
384 parent_root,
385 ..
386 } = scope
387 else {
388 return Ok(());
389 };
390
391 let exists = conn
392 .query_one(
393 "SELECT EXISTS(
394 SELECT 1 FROM code_indexed_files WHERE project_id = $1
395 )",
396 &[parent_project_id],
397 )
398 .and_then(|row| row.try_get::<_, bool>(0))?;
399
400 if !exists {
401 anyhow::bail!(
402 "parent code index missing for {} ({})",
403 parent_root.display(),
404 short_id(parent_project_id)
405 );
406 }
407
408 Ok(())
409}
410
411pub fn warn_project_identity(identity: &ProjectIdentity, quiet: bool) {
412 if quiet {
413 return;
414 }
415 if let Some(warning) = &identity.warning {
416 eprintln!("Warning: {warning}");
417 }
418}
419
420fn resolve_project_by_name(name: &str, database_url: &str) -> anyhow::Result<PathBuf> {
424 let mut conn = db::connect_readonly(database_url)?;
425 let (slash_suffix, backslash_suffix) = project_name_suffixes(name);
426 let rows = conn.query(
427 "SELECT root_path FROM code_indexed_projects
428 WHERE root_path = $1
429 OR right(root_path, length($2)) = $2
430 OR right(root_path, length($3)) = $3
431 ORDER BY last_indexed_at DESC NULLS LAST",
432 &[&name, &slash_suffix, &backslash_suffix],
433 )?;
434
435 for row in rows {
436 let root_path: String = row.try_get("root_path")?;
437 let path = PathBuf::from(&root_path);
438 if path.is_dir() {
439 return Ok(path);
440 }
441 }
442
443 anyhow::bail!(
444 "Project '{}' not found. Run `gcode projects` to see indexed projects.",
445 name
446 )
447}
448
449pub(super) fn project_name_suffixes(name: &str) -> (String, String) {
450 (format!("/{name}"), format!("\\{name}"))
451}
452
453pub fn detect_project_root() -> anyhow::Result<PathBuf> {
460 let cwd = std::env::current_dir()?;
461 detect_project_root_from(&cwd)
462}
463
464pub fn detect_project_root_from(start: &Path) -> anyhow::Result<PathBuf> {
465 let start = start
466 .canonicalize()
467 .unwrap_or_else(|_| absolute_fallback(start));
468 let start = if start.is_file() {
469 start
470 .parent()
471 .map(Path::to_path_buf)
472 .unwrap_or_else(|| start.clone())
473 } else {
474 start
475 };
476
477 if let Some(root) = find_project_root(&start) {
479 return Ok(root.canonicalize().unwrap_or(root));
480 }
481
482 if let Ok(info) = git::worktree_info(&start)
484 && info.kind != WorktreeKind::NotGit
485 {
486 return Ok(info.top_level);
487 }
488
489 let mut dir = start.as_path();
491 loop {
492 if dir.join(".git").exists() || dir.join(".hg").exists() {
493 return Ok(dir.to_path_buf());
494 }
495 match dir.parent() {
496 Some(parent) => dir = parent,
497 None => return Ok(start), }
499 }
500}
501
502pub(crate) fn resolve_daemon_url() -> Option<String> {
510 if let Ok(port) = std::env::var("GOBBY_PORT")
512 && !port.is_empty()
513 {
514 return Some(format!("http://localhost:{port}"));
515 }
516
517 let bootstrap_path = db::bootstrap_path().ok();
519 if let Some(bootstrap_path) = bootstrap_path
520 && let Ok(contents) = std::fs::read_to_string(&bootstrap_path)
521 && let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents)
522 && let Some(port) = yaml.get("daemon_port").and_then(|v| v.as_u64())
523 {
524 let host = yaml
525 .get("bind_host")
526 .and_then(|v| v.as_str())
527 .unwrap_or("localhost");
528 return Some(format!("http://{}:{port}", client_daemon_host(host)));
529 }
530
531 Some("http://localhost:60887".to_string())
533}
534
535fn client_daemon_host(host: &str) -> String {
536 match host.trim() {
537 "" | "0.0.0.0" | "::" | "[::]" => "localhost".to_string(),
538 host if host.contains(':') && !host.starts_with('[') => format!("[{host}]"),
539 host => host.to_string(),
540 }
541}
542
543#[cfg(test)]
550pub(super) fn resolve_project_id(project_root: &Path) -> anyhow::Result<String> {
551 Ok(resolve_project_identity(project_root, MissingIdentity::Error)?.project_id)
552}
553
554fn absolute_fallback(path: &Path) -> PathBuf {
555 if path.is_absolute() {
556 path.to_path_buf()
557 } else {
558 std::env::current_dir()
559 .unwrap_or_else(|_| std::env::temp_dir())
560 .join(path)
561 }
562}