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