1use std::fmt;
9use std::path::{Path, PathBuf};
10
11use gobby_core::config::ConfigSource;
12use gobby_core::project::{find_project_root, read_project_id};
13use gobby_core::provisioning::{GCORE_CONFIG_FILENAME, StandaloneConfig};
14use postgres::Client;
15
16use crate::db;
17use crate::git::{self, WorktreeKind};
18use crate::secrets;
19use crate::utils::short_id;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct FalkorConfig {
24 pub host: String,
25 pub port: u16,
26 pub password: Option<String>,
27 pub graph_name: String,
28}
29
30pub type QdrantConfig = gobby_core::config::QdrantConfig;
32
33pub type EmbeddingConfig = gobby_core::config::EmbeddingConfig;
35
36pub const FALKORDB_GRAPH_NAME: &str = "gobby_code";
37pub const CODE_SYMBOL_COLLECTION_PREFIX: &str = "code_symbols_";
38pub const GOBBY_EMBEDDING_VECTOR_DIM_ENV: &str = "GOBBY_EMBEDDING_VECTOR_DIM";
39pub const EMBEDDING_VECTOR_DIM_CONFIG_KEY: &str = "embeddings.vector_dim";
40
41pub const GOBBY_FALKORDB_HOST_ENV: &str = "GOBBY_FALKORDB_HOST";
42pub const GOBBY_FALKORDB_PORT_ENV: &str = "GOBBY_FALKORDB_PORT";
43pub const GOBBY_FALKORDB_PASSWORD_ENV: &str = "GOBBY_FALKORDB_PASSWORD";
44
45pub const FALKORDB_HOST_CONFIG_KEY: &str = "databases.falkordb.host";
46pub const FALKORDB_PORT_CONFIG_KEY: &str = "databases.falkordb.port";
47pub const FALKORDB_PASSWORD_CONFIG_KEY: &str = "databases.falkordb.requirepass";
48
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50pub struct CodeVectorSettings {
51 pub vector_dim: Option<usize>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum CodeVectorConfigError {
56 InvalidVectorDim { source: &'static str, value: String },
57}
58
59impl fmt::Display for CodeVectorConfigError {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 match self {
62 Self::InvalidVectorDim { source, value } => write!(
63 f,
64 "invalid code vector dimension from {source}: `{value}` must be a positive integer"
65 ),
66 }
67 }
68}
69
70impl std::error::Error for CodeVectorConfigError {}
71
72impl FalkorConfig {
73 pub fn connection_config(&self) -> gobby_core::config::FalkorConfig {
74 gobby_core::config::FalkorConfig {
75 host: self.host.clone(),
76 port: self.port,
77 password: self.password.clone(),
78 }
79 }
80}
81
82pub struct Context {
84 pub database_url: String,
86 pub project_root: PathBuf,
88 pub project_id: String,
90 pub quiet: bool,
92 pub falkordb: Option<FalkorConfig>,
94 pub qdrant: Option<QdrantConfig>,
96 pub embedding: Option<EmbeddingConfig>,
98 pub code_vectors: CodeVectorSettings,
100 pub daemon_url: Option<String>,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum MissingIdentity {
106 Error,
107 Generate,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum ProjectIdentitySource {
112 ProjectJson,
113 GcodeJson,
114 IsolatedRoot,
115 LinkedWorktree,
116 Generated,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ProjectIdentity {
121 pub project_id: String,
122 pub root: PathBuf,
123 pub source: ProjectIdentitySource,
124 pub warning: Option<String>,
125 pub should_write_gcode_json: bool,
126}
127
128impl Context {
129 pub fn resolve(project_override: Option<&str>, quiet: bool) -> anyhow::Result<Self> {
131 let database_url = db::resolve_database_url()?;
132 let project_root = match project_override {
133 Some(p) => {
134 let path = PathBuf::from(p);
135 if path.is_dir() {
136 path.canonicalize()?
137 } else {
138 resolve_project_by_name(p, &database_url)?
140 }
141 }
142 None => detect_project_root()?,
143 };
144
145 let identity = resolve_project_identity(&project_root, MissingIdentity::Error)?;
146 warn_project_identity(&identity, quiet);
147 let project_id = identity.project_id;
148
149 let standalone_config = read_standalone_config();
151 let mut conn = db::connect_readonly(&database_url)?;
152 let falkordb = resolve_falkordb_config(&mut conn, standalone_config.clone(), quiet);
153 let qdrant = resolve_qdrant_config(&mut conn, standalone_config.clone(), quiet);
154 let embedding = resolve_embedding_config(&mut conn, standalone_config.clone(), quiet);
155 let code_vectors = resolve_code_vector_settings(&mut conn, standalone_config)?;
156
157 let daemon_url = resolve_daemon_url();
158
159 Ok(Self {
160 database_url,
161 project_root,
162 project_id,
163 quiet,
164 falkordb,
165 qdrant,
166 embedding,
167 code_vectors,
168 daemon_url,
169 })
170 }
171}
172
173pub fn resolve_project_identity(
174 project_root: &Path,
175 missing: MissingIdentity,
176) -> anyhow::Result<ProjectIdentity> {
177 let root = project_root
178 .canonicalize()
179 .unwrap_or_else(|_| absolute_fallback(project_root));
180
181 if let Some(marker) = crate::project::read_isolation_marker(&root)
182 && !is_self_referential_isolation_marker(&marker, &root)
183 {
184 return Ok(ProjectIdentity {
185 project_id: crate::project::code_index_id_for_root(&root),
186 root,
187 source: ProjectIdentitySource::IsolatedRoot,
188 warning: None,
189 should_write_gcode_json: false,
190 });
191 }
192
193 let worktree = git::worktree_info(&root)?;
194 if worktree.kind == WorktreeKind::Linked {
195 let project_id = crate::project::code_index_id_for_root(&worktree.top_level);
196 let copied_id = read_project_id(&worktree.top_level).ok();
197 let warning = copied_id
198 .filter(|id| id != &project_id)
199 .map(|id| {
200 format!(
201 "linked git worktree {} has copied .gobby/project.json id {}; using filesystem-scoped code index id {}",
202 worktree.top_level.display(),
203 short_id(&id),
204 short_id(&project_id)
205 )
206 });
207
208 return Ok(ProjectIdentity {
209 project_id,
210 root: worktree.top_level,
211 source: ProjectIdentitySource::LinkedWorktree,
212 warning,
213 should_write_gcode_json: false,
214 });
215 }
216
217 let gobby_dir = root.join(".gobby");
218 if gobby_dir.join("project.json").exists() {
219 return Ok(ProjectIdentity {
220 project_id: read_project_id(&root)?,
221 root,
222 source: ProjectIdentitySource::ProjectJson,
223 warning: None,
224 should_write_gcode_json: false,
225 });
226 }
227 if gobby_dir.join("gcode.json").exists() {
228 return Ok(ProjectIdentity {
229 project_id: crate::project::read_gcode_json(&root)?,
230 root,
231 source: ProjectIdentitySource::GcodeJson,
232 warning: None,
233 should_write_gcode_json: false,
234 });
235 }
236
237 match missing {
238 MissingIdentity::Generate => Ok(ProjectIdentity {
239 project_id: crate::project::code_index_id_for_root(&root),
240 root,
241 source: ProjectIdentitySource::Generated,
242 warning: None,
243 should_write_gcode_json: true,
244 }),
245 MissingIdentity::Error => anyhow::bail!(
246 "No gcode project found. Run `gcode init` to initialize, \
247 or use `--project <path>` to specify a project directory."
248 ),
249 }
250}
251
252fn is_self_referential_isolation_marker(
253 marker: &crate::project::IsolationMarker,
254 root: &Path,
255) -> bool {
256 let Some(parent_project_path) = marker.parent_project_path.as_deref() else {
257 return false;
258 };
259 let parent = PathBuf::from(parent_project_path);
260 let parent = if parent.is_absolute() {
261 parent
262 } else {
263 root.join(parent)
264 };
265 let parent = parent.canonicalize().unwrap_or(parent);
266 parent == root
267}
268
269pub fn warn_project_identity(identity: &ProjectIdentity, quiet: bool) {
270 if quiet {
271 return;
272 }
273 if let Some(warning) = &identity.warning {
274 eprintln!("Warning: {warning}");
275 }
276}
277
278fn resolve_project_by_name(name: &str, database_url: &str) -> anyhow::Result<PathBuf> {
282 let mut conn = db::connect_readonly(database_url)?;
283 let rows = conn.query(
284 "SELECT root_path FROM code_indexed_projects
285 WHERE root_path = $1 OR root_path LIKE '%' || '/' || $1
286 ORDER BY last_indexed_at DESC NULLS LAST
287 LIMIT 1",
288 &[&name],
289 )?;
290
291 if let Some(row) = rows.first() {
292 let root_path: String = row.try_get("root_path")?;
293 let path = PathBuf::from(&root_path);
294 if path.is_dir() {
295 return Ok(path);
296 }
297 }
298
299 anyhow::bail!(
300 "Project '{}' not found. Run `gcode projects` to see indexed projects.",
301 name
302 )
303}
304
305pub fn detect_project_root() -> anyhow::Result<PathBuf> {
312 let cwd = std::env::current_dir()?;
313 detect_project_root_from(&cwd)
314}
315
316pub fn detect_project_root_from(start: &Path) -> anyhow::Result<PathBuf> {
317 let start = start
318 .canonicalize()
319 .unwrap_or_else(|_| absolute_fallback(start));
320 let start = if start.is_file() {
321 start
322 .parent()
323 .map(Path::to_path_buf)
324 .unwrap_or_else(|| start.clone())
325 } else {
326 start
327 };
328
329 if let Some(root) = find_project_root(&start) {
331 return Ok(root.canonicalize().unwrap_or(root));
332 }
333
334 if let Ok(info) = git::worktree_info(&start)
336 && info.kind != WorktreeKind::NotGit
337 {
338 return Ok(info.top_level);
339 }
340
341 let mut dir = start.as_path();
343 loop {
344 if dir.join(".git").exists() || dir.join(".hg").exists() {
345 return Ok(dir.to_path_buf());
346 }
347 match dir.parent() {
348 Some(parent) => dir = parent,
349 None => return Ok(start), }
351 }
352}
353
354pub(crate) fn resolve_daemon_url() -> Option<String> {
361 if let Ok(port) = std::env::var("GOBBY_PORT")
363 && !port.is_empty()
364 {
365 return Some(format!("http://localhost:{port}"));
366 }
367
368 let bootstrap_path = db::bootstrap_path().ok()?;
370 if let Ok(contents) = std::fs::read_to_string(&bootstrap_path)
371 && let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents)
372 && let Some(port) = yaml.get("daemon_port").and_then(|v| v.as_u64())
373 {
374 let host = yaml
375 .get("bind_host")
376 .and_then(|v| v.as_str())
377 .unwrap_or("localhost");
378 return Some(format!("http://{host}:{port}"));
379 }
380
381 Some("http://localhost:60887".to_string())
383}
384
385#[cfg(test)]
392fn resolve_project_id(project_root: &Path) -> anyhow::Result<String> {
393 Ok(resolve_project_identity(project_root, MissingIdentity::Error)?.project_id)
394}
395
396fn absolute_fallback(path: &Path) -> PathBuf {
397 if path.is_absolute() {
398 path.to_path_buf()
399 } else {
400 std::env::current_dir()
401 .unwrap_or_else(|_| PathBuf::from("."))
402 .join(path)
403 }
404}
405
406pub(crate) struct PostgresConfigSource<'a> {
409 conn: &'a mut Client,
410}
411
412impl gobby_core::config::ConfigSource for PostgresConfigSource<'_> {
413 fn config_value(&mut self, key: &str) -> Option<String> {
414 let key = canonical_config_key(key);
415 gobby_core::postgres::read_config_value(self.conn, key)
416 .ok()
417 .flatten()
418 .and_then(|raw| gobby_core::config::decode_config_value(&raw))
419 }
420
421 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
422 secrets::resolve_config_value(value, self.conn)
423 }
424}
425
426struct FallbackConfigSource<'a> {
427 postgres: PostgresConfigSource<'a>,
428 standalone: Option<StandaloneConfig>,
429}
430
431impl ConfigSource for FallbackConfigSource<'_> {
432 fn config_value(&mut self, key: &str) -> Option<String> {
433 self.postgres.config_value(key).or_else(|| {
434 self.standalone
435 .as_mut()
436 .and_then(|standalone| standalone.config_value(key))
437 })
438 }
439
440 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
441 self.postgres.resolve_value(value)
442 }
443}
444
445fn read_standalone_config() -> Option<StandaloneConfig> {
446 let home = db::gobby_home().ok()?;
447 StandaloneConfig::read_at(&home.join(GCORE_CONFIG_FILENAME))
448 .ok()
449 .flatten()
450}
451
452#[cfg(test)]
453struct ClosureConfigSource<R, S> {
454 read_config_value: R,
455 resolve_value: S,
456}
457
458#[cfg(test)]
459impl<R, S> ConfigSource for ClosureConfigSource<R, S>
460where
461 R: FnMut(&str) -> Option<String>,
462 S: FnMut(&str) -> anyhow::Result<String>,
463{
464 fn config_value(&mut self, key: &str) -> Option<String> {
465 (self.read_config_value)(key).and_then(|raw| gobby_core::config::decode_config_value(&raw))
466 }
467
468 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
469 (self.resolve_value)(value)
470 }
471}
472
473fn canonical_config_key(key: &str) -> &str {
474 match key {
475 FALKORDB_HOST_CONFIG_KEY => FALKORDB_HOST_CONFIG_KEY,
476 FALKORDB_PORT_CONFIG_KEY => FALKORDB_PORT_CONFIG_KEY,
477 FALKORDB_PASSWORD_CONFIG_KEY => FALKORDB_PASSWORD_CONFIG_KEY,
478 _ => key,
479 }
480}
481
482#[cfg(test)]
483fn resolve_falkordb_config_from_values<R, S>(
484 read_config_value: R,
485 resolve_value: S,
486) -> Option<FalkorConfig>
487where
488 R: FnMut(&str) -> Option<String>,
489 S: FnMut(&str) -> anyhow::Result<String>,
490{
491 let mut source = ClosureConfigSource {
492 read_config_value,
493 resolve_value,
494 };
495 resolve_falkordb_config_from_source(&mut source)
496}
497
498#[cfg(test)]
499fn resolve_qdrant_config_from_values<R, S>(
500 read_config_value: R,
501 resolve_value: S,
502) -> Option<QdrantConfig>
503where
504 R: FnMut(&str) -> Option<String>,
505 S: FnMut(&str) -> anyhow::Result<String>,
506{
507 let mut source = ClosureConfigSource {
508 read_config_value,
509 resolve_value,
510 };
511 gobby_core::config::resolve_qdrant_config(&mut source)
512}
513
514#[cfg(test)]
515fn resolve_embedding_config_from_values<R, S>(
516 read_config_value: R,
517 resolve_value: S,
518) -> Option<EmbeddingConfig>
519where
520 R: FnMut(&str) -> Option<String>,
521 S: FnMut(&str) -> anyhow::Result<String>,
522{
523 let mut source = ClosureConfigSource {
524 read_config_value,
525 resolve_value,
526 };
527 gobby_core::config::resolve_embedding_config(&mut source)
528}
529
530#[cfg(test)]
531fn resolve_code_vector_settings_from_values<R>(
532 read_config_value: R,
533) -> Result<CodeVectorSettings, CodeVectorConfigError>
534where
535 R: FnMut(&str) -> Option<String>,
536{
537 let mut source = ClosureConfigSource {
538 read_config_value,
539 resolve_value: |value: &str| Ok(value.to_string()),
540 };
541 resolve_code_vector_settings_from_source(&mut source)
542}
543
544fn resolve_falkordb_config(
546 conn: &mut Client,
547 standalone: Option<StandaloneConfig>,
548 _quiet: bool,
549) -> Option<FalkorConfig> {
550 let mut source = FallbackConfigSource {
551 postgres: PostgresConfigSource { conn },
552 standalone,
553 };
554 resolve_falkordb_config_from_source(&mut source)
555}
556
557fn resolve_falkordb_config_from_source(source: &mut impl ConfigSource) -> Option<FalkorConfig> {
558 let connection = gobby_core::config::resolve_falkordb_config(source)?;
559
560 Some(FalkorConfig {
561 host: connection.host,
562 port: connection.port,
563 password: connection.password,
564 graph_name: FALKORDB_GRAPH_NAME.to_string(),
565 })
566}
567
568fn resolve_qdrant_config(
570 conn: &mut Client,
571 standalone: Option<StandaloneConfig>,
572 _quiet: bool,
573) -> Option<QdrantConfig> {
574 let mut source = FallbackConfigSource {
575 postgres: PostgresConfigSource { conn },
576 standalone,
577 };
578 gobby_core::config::resolve_qdrant_config(&mut source)
579}
580
581fn resolve_embedding_config(
585 conn: &mut Client,
586 standalone: Option<StandaloneConfig>,
587 _quiet: bool,
588) -> Option<EmbeddingConfig> {
589 let mut source = FallbackConfigSource {
590 postgres: PostgresConfigSource { conn },
591 standalone,
592 };
593 gobby_core::config::resolve_embedding_config(&mut source)
594}
595
596pub(crate) fn resolve_code_vector_settings(
597 conn: &mut Client,
598 standalone: Option<StandaloneConfig>,
599) -> Result<CodeVectorSettings, CodeVectorConfigError> {
600 let mut source = FallbackConfigSource {
601 postgres: PostgresConfigSource { conn },
602 standalone,
603 };
604 resolve_code_vector_settings_from_source(&mut source)
605}
606
607pub(crate) fn resolve_code_vector_settings_from_source(
608 source: &mut impl ConfigSource,
609) -> Result<CodeVectorSettings, CodeVectorConfigError> {
610 let vector_dim = match std::env::var(GOBBY_EMBEDDING_VECTOR_DIM_ENV)
611 .ok()
612 .filter(|value| !value.trim().is_empty())
613 {
614 Some(value) => Some(parse_vector_dim(
615 GOBBY_EMBEDDING_VECTOR_DIM_ENV,
616 value.trim(),
617 )?),
618 None => source
619 .config_value(EMBEDDING_VECTOR_DIM_CONFIG_KEY)
620 .map(|value| parse_vector_dim(EMBEDDING_VECTOR_DIM_CONFIG_KEY, value.trim()))
621 .transpose()?,
622 };
623
624 Ok(CodeVectorSettings { vector_dim })
625}
626
627fn parse_vector_dim(source: &'static str, value: &str) -> Result<usize, CodeVectorConfigError> {
628 value
629 .parse::<usize>()
630 .ok()
631 .filter(|size| *size > 0)
632 .ok_or_else(|| CodeVectorConfigError::InvalidVectorDim {
633 source,
634 value: value.to_string(),
635 })
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use std::process::Command;
642
643 fn write_project_json(root: &Path, json: serde_json::Value) {
644 let gobby_dir = root.join(".gobby");
645 std::fs::create_dir_all(&gobby_dir).expect("create .gobby");
646 std::fs::write(
647 gobby_dir.join("project.json"),
648 serde_json::to_string_pretty(&json).expect("serialize project json"),
649 )
650 .expect("write project json");
651 }
652
653 fn run_git(dir: &Path, args: &[&str]) {
654 let status = Command::new("git")
655 .arg("-C")
656 .arg(dir)
657 .args(args)
658 .status()
659 .expect("run git");
660 assert!(status.success(), "git {:?} failed", args);
661 }
662
663 fn create_linked_worktree(tmp: &tempfile::TempDir) -> (PathBuf, PathBuf) {
664 let repo = tmp.path().join("repo");
665 let linked = tmp.path().join("linked");
666 std::fs::create_dir(&repo).expect("create repo");
667 run_git(&repo, &["init"]);
668 std::fs::write(repo.join("README.md"), "hello\n").expect("write readme");
669 run_git(&repo, &["add", "README.md"]);
670 run_git(
671 &repo,
672 &[
673 "-c",
674 "user.email=test@example.com",
675 "-c",
676 "user.name=Test User",
677 "commit",
678 "-m",
679 "initial",
680 ],
681 );
682 run_git(
683 &repo,
684 &[
685 "worktree",
686 "add",
687 "-b",
688 "linked-branch",
689 linked.to_str().unwrap(),
690 ],
691 );
692 (repo, linked)
693 }
694
695 fn clear_service_env() {
696 for key in [
697 "GOBBY_FALKORDB_HOST",
698 "GOBBY_FALKORDB_PORT",
699 "GOBBY_FALKORDB_PASSWORD",
700 "GOBBY_QDRANT_URL",
701 "GOBBY_QDRANT_API_KEY",
702 "GOBBY_EMBEDDING_URL",
703 "GOBBY_EMBEDDING_MODEL",
704 "GOBBY_EMBEDDING_API_KEY",
705 "GOBBY_EMBEDDING_VECTOR_DIM",
706 ] {
707 unsafe { std::env::remove_var(key) };
708 }
709 }
710
711 fn config_value_for<'a>(
712 values: &'a std::collections::HashMap<&'a str, &'a str>,
713 ) -> impl FnMut(&str) -> Option<String> + 'a {
714 |key| values.get(key).map(|value| (*value).to_string())
715 }
716
717 #[test]
718 #[serial_test::serial]
719 fn adapter_env_precedence_and_json_decode() {
720 clear_service_env();
721 unsafe { std::env::set_var("GOBBY_FALKORDB_HOST", "env-falkor.local") };
722 let values = std::collections::HashMap::from([
723 ("databases.falkordb.host", r#""stored-falkor.local""#),
724 ("databases.falkordb.port", r#""16380""#),
725 ("databases.falkordb.requirepass", r#""stored-pass""#),
726 ("databases.qdrant.url", r#""http://qdrant.local:6333""#),
727 ("databases.qdrant.api_key", r#""qdrant-key""#),
728 ("embeddings.api_base", r#""http://embeddings.local:11434""#),
729 ("embeddings.model", r#""embed-model""#),
730 ("embeddings.api_key", "null"),
731 ]);
732
733 let falkor = resolve_falkordb_config_from_values(config_value_for(&values), |value| {
734 Ok(value.to_string())
735 })
736 .expect("falkordb config");
737 let qdrant = resolve_qdrant_config_from_values(config_value_for(&values), |value| {
738 Ok(value.to_string())
739 })
740 .expect("qdrant config");
741 let embedding = resolve_embedding_config_from_values(config_value_for(&values), |value| {
742 Ok(value.to_string())
743 })
744 .expect("embedding config");
745
746 assert_eq!(falkor.host, "env-falkor.local");
747 assert_eq!(falkor.port, 16380);
748 assert_eq!(falkor.password.as_deref(), Some("stored-pass"));
749 assert_eq!(qdrant.url.as_deref(), Some("http://qdrant.local:6333"));
750 assert_eq!(qdrant.api_key.as_deref(), Some("qdrant-key"));
751 assert_eq!(embedding.api_base, "http://embeddings.local:11434");
752 assert_eq!(embedding.model, "embed-model");
753 assert_eq!(embedding.api_key, None);
754 clear_service_env();
755 }
756
757 #[test]
758 #[serial_test::serial]
759 fn adapter_resolves_config_store_secrets() {
760 clear_service_env();
761 let values = std::collections::HashMap::from([
762 ("databases.falkordb.host", "falkor.local"),
763 (
764 "databases.falkordb.requirepass",
765 "$secret:falkordb_password",
766 ),
767 ("databases.qdrant.url", "http://qdrant.local:6333"),
768 ("databases.qdrant.api_key", "$secret:qdrant_api_key"),
769 ("embeddings.api_base", "http://embeddings.local:11434"),
770 ("embeddings.api_key", "$secret:embedding_api_key"),
771 ]);
772
773 fn resolve_secret_stub(value: &str) -> anyhow::Result<String> {
774 match value {
775 "$secret:falkordb_password" => Ok("resolved-falkor".to_string()),
776 "$secret:qdrant_api_key" => Ok("resolved-qdrant".to_string()),
777 "$secret:embedding_api_key" => Ok("resolved-embedding".to_string()),
778 value => Ok(value.to_string()),
779 }
780 }
781
782 let falkor =
783 resolve_falkordb_config_from_values(config_value_for(&values), resolve_secret_stub)
784 .expect("falkordb config");
785 let qdrant =
786 resolve_qdrant_config_from_values(config_value_for(&values), resolve_secret_stub)
787 .expect("qdrant config");
788 let embedding =
789 resolve_embedding_config_from_values(config_value_for(&values), resolve_secret_stub)
790 .expect("embedding config");
791
792 assert_eq!(falkor.password.as_deref(), Some("resolved-falkor"));
793 assert_eq!(qdrant.api_key.as_deref(), Some("resolved-qdrant"));
794 assert_eq!(embedding.api_key.as_deref(), Some("resolved-embedding"));
795 }
796
797 #[test]
798 #[serial_test::serial]
799 fn vector_dim_setting_resolves_env_and_config_store() {
800 clear_service_env();
801 let values = std::collections::HashMap::from([("embeddings.vector_dim", "1536")]);
802
803 let settings = resolve_code_vector_settings_from_values(config_value_for(&values))
804 .expect("config-store vector settings");
805 assert_eq!(settings.vector_dim, Some(1536));
806
807 unsafe { std::env::set_var("GOBBY_EMBEDDING_VECTOR_DIM", "3072") };
808 let settings = resolve_code_vector_settings_from_values(config_value_for(&values))
809 .expect("env vector settings");
810 assert_eq!(settings.vector_dim, Some(3072));
811
812 unsafe { std::env::remove_var("GOBBY_EMBEDDING_VECTOR_DIM") };
813 let null_values = std::collections::HashMap::from([("embeddings.vector_dim", "null")]);
814 let settings = resolve_code_vector_settings_from_values(config_value_for(&null_values))
815 .expect("null config-store vector settings");
816 assert_eq!(settings.vector_dim, None);
817
818 let invalid_values =
819 std::collections::HashMap::from([("embeddings.vector_dim", r#""wide""#)]);
820 let err = resolve_code_vector_settings_from_values(config_value_for(&invalid_values))
821 .expect_err("invalid vector dim must error");
822 assert!(matches!(
823 err,
824 CodeVectorConfigError::InvalidVectorDim { .. }
825 ));
826 clear_service_env();
827 }
828
829 #[test]
830 fn falkor_config_wrapper_shape() {
831 let source = include_str!("config.rs");
832 assert!(source.contains("pub struct FalkorConfig"));
833 assert!(source.contains("pub graph_name: String"));
834 assert!(source.contains("gobby_core::config::resolve_falkordb_config"));
835 assert!(source.contains("graph_name: FALKORDB_GRAPH_NAME.to_string()"));
836 }
837
838 #[test]
839 fn phase7_context_and_falkor_resolver_visible() {
840 let source = include_str!("config.rs");
841 assert!(source.contains("pub falkordb: Option<FalkorConfig>"));
842 assert!(source.contains("let falkordb = resolve_falkordb_config("));
843 assert!(source.contains("pub const FALKORDB_GRAPH_NAME: &str = \"gobby_code\";"));
844 assert!(source.contains("graph_name: FALKORDB_GRAPH_NAME.to_string()"));
845 }
846
847 #[test]
848 fn phase7_falkordb_config_store_keys_visible() {
849 let source = include_str!("config.rs");
850 for key in [
851 FALKORDB_HOST_CONFIG_KEY,
852 FALKORDB_PORT_CONFIG_KEY,
853 FALKORDB_PASSWORD_CONFIG_KEY,
854 GOBBY_FALKORDB_HOST_ENV,
855 GOBBY_FALKORDB_PORT_ENV,
856 GOBBY_FALKORDB_PASSWORD_ENV,
857 ] {
858 assert!(source.contains(key), "missing {key}");
859 }
860 }
861
862 #[test]
863 fn phase7_neo4j_transition_state_absent() {
864 let source = include_str!("config.rs");
865 let config_type = ["pub struct Neo", "4jConfig"].concat();
866 let resolver = ["resolve_neo", "4j_config"].concat();
867 let context_field = ["pub neo", "4j: Option<Neo", "4jConfig>"].concat();
868 assert!(!source.contains(&config_type));
869 assert!(!source.contains(&resolver));
870 assert!(!source.contains(&context_field));
871 }
872
873 #[test]
874 fn test_resolve_project_id_requires_project_context() {
875 let tmp = tempfile::tempdir().expect("tempdir");
876 let err = resolve_project_id(tmp.path()).expect_err("missing project context must fail");
877
878 assert!(
879 err.to_string().contains("No gcode project found"),
880 "unexpected error: {err}"
881 );
882 assert!(
883 err.to_string().contains("gcode init"),
884 "unexpected error: {err}"
885 );
886 }
887
888 #[test]
889 fn main_repo_keeps_project_json_id() {
890 let tmp = tempfile::tempdir().expect("tempdir");
891 write_project_json(
892 tmp.path(),
893 serde_json::json!({
894 "id": "main-project-id",
895 "name": "main"
896 }),
897 );
898
899 let identity =
900 resolve_project_identity(tmp.path(), MissingIdentity::Error).expect("identity");
901
902 assert_eq!(identity.project_id, "main-project-id");
903 assert_eq!(identity.source, ProjectIdentitySource::ProjectJson);
904 assert!(!identity.should_write_gcode_json);
905 assert!(identity.warning.is_none());
906 }
907
908 #[test]
909 fn self_referential_parent_marker_keeps_project_json_id() {
910 let tmp = tempfile::tempdir().expect("tempdir");
911 let root = tmp.path().canonicalize().expect("canonical root");
912 write_project_json(
913 &root,
914 serde_json::json!({
915 "id": "main-project-id",
916 "name": "main",
917 "parent_project_path": root.to_string_lossy(),
918 "parent_project_id": "main-project-id"
919 }),
920 );
921
922 let identity = resolve_project_identity(&root, MissingIdentity::Error).expect("identity");
923
924 assert_eq!(identity.project_id, "main-project-id");
925 assert_eq!(identity.source, ProjectIdentitySource::ProjectJson);
926 assert!(!identity.should_write_gcode_json);
927 assert!(identity.warning.is_none());
928 }
929
930 #[test]
931 fn isolated_marker_uses_path_derived_id_without_warning() {
932 let tmp = tempfile::tempdir().expect("tempdir");
933 write_project_json(
934 tmp.path(),
935 serde_json::json!({
936 "id": "parent-id",
937 "parent_project_path": "/parent",
938 "parent_project_id": "parent-id"
939 }),
940 );
941
942 let identity =
943 resolve_project_identity(tmp.path(), MissingIdentity::Error).expect("identity");
944
945 assert_eq!(
946 identity.project_id,
947 crate::project::code_index_id_for_root(tmp.path())
948 );
949 assert_eq!(identity.source, ProjectIdentitySource::IsolatedRoot);
950 assert!(!identity.should_write_gcode_json);
951 assert!(identity.warning.is_none());
952 }
953
954 #[test]
955 fn linked_worktree_uses_path_id_and_warns_only_for_copied_project_id() {
956 let tmp = tempfile::tempdir().expect("tempdir");
957 let (_repo, linked) = create_linked_worktree(&tmp);
958
959 let identity = resolve_project_identity(&linked, MissingIdentity::Error).expect("identity");
960
961 assert_eq!(
962 identity.project_id,
963 crate::project::code_index_id_for_root(&linked)
964 );
965 assert_eq!(identity.source, ProjectIdentitySource::LinkedWorktree);
966 assert!(identity.warning.is_none());
967 assert!(!identity.should_write_gcode_json);
968
969 write_project_json(
970 &linked,
971 serde_json::json!({
972 "id": "copied-parent-id",
973 "name": "linked"
974 }),
975 );
976 let copied =
977 resolve_project_identity(&linked, MissingIdentity::Error).expect("copied identity");
978
979 assert_eq!(copied.source, ProjectIdentitySource::LinkedWorktree);
980 assert_eq!(
981 copied.project_id,
982 crate::project::code_index_id_for_root(&linked)
983 );
984 assert!(copied.warning.as_deref().unwrap_or("").contains("copied"));
985 assert!(!copied.should_write_gcode_json);
986 }
987
988 #[test]
989 fn generated_identity_writes_only_for_non_isolated_roots() {
990 let tmp = tempfile::tempdir().expect("tempdir");
991
992 let identity =
993 resolve_project_identity(tmp.path(), MissingIdentity::Generate).expect("identity");
994
995 assert_eq!(identity.source, ProjectIdentitySource::Generated);
996 assert!(identity.should_write_gcode_json);
997 assert_eq!(
998 identity.project_id,
999 crate::project::code_index_id_for_root(tmp.path())
1000 );
1001 }
1002}