use std::fmt;
use std::path::{Path, PathBuf};
use gobby_core::config::ConfigSource;
use gobby_core::project::{find_project_root, read_project_id};
use gobby_core::provisioning::{GCORE_CONFIG_FILENAME, StandaloneConfig};
use postgres::Client;
use crate::db;
use crate::git::{self, WorktreeKind};
use crate::secrets;
use crate::utils::short_id;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FalkorConfig {
pub host: String,
pub port: u16,
pub password: Option<String>,
pub graph_name: String,
}
pub type QdrantConfig = gobby_core::config::QdrantConfig;
pub type EmbeddingConfig = gobby_core::config::EmbeddingConfig;
pub const FALKORDB_GRAPH_NAME: &str = "gobby_code";
pub const CODE_SYMBOL_COLLECTION_PREFIX: &str = "code_symbols_";
pub const GOBBY_EMBEDDING_VECTOR_DIM_ENV: &str = "GOBBY_EMBEDDING_VECTOR_DIM";
pub const EMBEDDING_VECTOR_DIM_CONFIG_KEY: &str = "embeddings.vector_dim";
pub const GOBBY_FALKORDB_HOST_ENV: &str = "GOBBY_FALKORDB_HOST";
pub const GOBBY_FALKORDB_PORT_ENV: &str = "GOBBY_FALKORDB_PORT";
pub const GOBBY_FALKORDB_PASSWORD_ENV: &str = "GOBBY_FALKORDB_PASSWORD";
pub const FALKORDB_HOST_CONFIG_KEY: &str = "databases.falkordb.host";
pub const FALKORDB_PORT_CONFIG_KEY: &str = "databases.falkordb.port";
pub const FALKORDB_PASSWORD_CONFIG_KEY: &str = "databases.falkordb.requirepass";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CodeVectorSettings {
pub vector_dim: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodeVectorConfigError {
InvalidVectorDim { source: &'static str, value: String },
}
impl fmt::Display for CodeVectorConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidVectorDim { source, value } => write!(
f,
"invalid code vector dimension from {source}: `{value}` must be a positive integer"
),
}
}
}
impl std::error::Error for CodeVectorConfigError {}
impl FalkorConfig {
pub fn connection_config(&self) -> gobby_core::config::FalkorConfig {
gobby_core::config::FalkorConfig {
host: self.host.clone(),
port: self.port,
password: self.password.clone(),
}
}
}
pub struct Context {
pub database_url: String,
pub project_root: PathBuf,
pub project_id: String,
pub quiet: bool,
pub falkordb: Option<FalkorConfig>,
pub qdrant: Option<QdrantConfig>,
pub embedding: Option<EmbeddingConfig>,
pub code_vectors: CodeVectorSettings,
pub daemon_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MissingIdentity {
Error,
Generate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectIdentitySource {
ProjectJson,
GcodeJson,
IsolatedRoot,
LinkedWorktree,
Generated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectIdentity {
pub project_id: String,
pub root: PathBuf,
pub source: ProjectIdentitySource,
pub warning: Option<String>,
pub should_write_gcode_json: bool,
}
impl Context {
pub fn resolve(project_override: Option<&str>, quiet: bool) -> anyhow::Result<Self> {
let database_url = db::resolve_database_url()?;
let project_root = match project_override {
Some(p) => {
let path = PathBuf::from(p);
if path.is_dir() {
path.canonicalize()?
} else {
resolve_project_by_name(p, &database_url)?
}
}
None => detect_project_root()?,
};
let identity = resolve_project_identity(&project_root, MissingIdentity::Error)?;
warn_project_identity(&identity, quiet);
let project_id = identity.project_id;
let standalone_config = read_standalone_config();
let mut conn = db::connect_readonly(&database_url)?;
let falkordb = resolve_falkordb_config(&mut conn, standalone_config.clone(), quiet);
let qdrant = resolve_qdrant_config(&mut conn, standalone_config.clone(), quiet);
let embedding = resolve_embedding_config(&mut conn, standalone_config.clone(), quiet);
let code_vectors = resolve_code_vector_settings(&mut conn, standalone_config)?;
let daemon_url = resolve_daemon_url();
Ok(Self {
database_url,
project_root,
project_id,
quiet,
falkordb,
qdrant,
embedding,
code_vectors,
daemon_url,
})
}
pub fn resolve_for_project_id(project_id: &str, quiet: bool) -> anyhow::Result<Self> {
let project_id = normalize_project_id(project_id)?;
let database_url = db::resolve_database_url()?;
let standalone_config = read_standalone_config();
let mut conn = db::connect_readonly(&database_url)?;
let falkordb = resolve_falkordb_config(&mut conn, standalone_config, quiet);
let daemon_url = resolve_daemon_url();
Ok(Self {
database_url,
project_root: PathBuf::new(),
project_id,
quiet,
falkordb,
qdrant: None,
embedding: None,
code_vectors: CodeVectorSettings::default(),
daemon_url,
})
}
}
pub fn resolve_project_identity(
project_root: &Path,
missing: MissingIdentity,
) -> anyhow::Result<ProjectIdentity> {
let root = project_root
.canonicalize()
.unwrap_or_else(|_| absolute_fallback(project_root));
if let Some(marker) = crate::project::read_isolation_marker(&root)
&& !is_self_referential_isolation_marker(&marker, &root)
{
return Ok(ProjectIdentity {
project_id: crate::project::code_index_id_for_root(&root),
root,
source: ProjectIdentitySource::IsolatedRoot,
warning: None,
should_write_gcode_json: false,
});
}
let worktree = git::worktree_info(&root)?;
if worktree.kind == WorktreeKind::Linked {
let project_id = crate::project::code_index_id_for_root(&worktree.top_level);
let copied_id = read_project_id(&worktree.top_level).ok();
let warning = copied_id
.filter(|id| id != &project_id)
.map(|id| {
format!(
"linked git worktree {} has copied .gobby/project.json id {}; using filesystem-scoped code index id {}",
worktree.top_level.display(),
short_id(&id),
short_id(&project_id)
)
});
return Ok(ProjectIdentity {
project_id,
root: worktree.top_level,
source: ProjectIdentitySource::LinkedWorktree,
warning,
should_write_gcode_json: false,
});
}
let gobby_dir = root.join(".gobby");
if gobby_dir.join("project.json").exists() {
return Ok(ProjectIdentity {
project_id: read_project_id(&root)?,
root,
source: ProjectIdentitySource::ProjectJson,
warning: None,
should_write_gcode_json: false,
});
}
if gobby_dir.join("gcode.json").exists() {
return Ok(ProjectIdentity {
project_id: crate::project::read_gcode_json(&root)?,
root,
source: ProjectIdentitySource::GcodeJson,
warning: None,
should_write_gcode_json: false,
});
}
match missing {
MissingIdentity::Generate => Ok(ProjectIdentity {
project_id: crate::project::code_index_id_for_root(&root),
root,
source: ProjectIdentitySource::Generated,
warning: None,
should_write_gcode_json: true,
}),
MissingIdentity::Error => anyhow::bail!(
"No gcode project found. Run `gcode init` to initialize, \
or use `--project <path>` to specify a project directory."
),
}
}
fn is_self_referential_isolation_marker(
marker: &crate::project::IsolationMarker,
root: &Path,
) -> bool {
let Some(parent_project_path) = marker.parent_project_path.as_deref() else {
return false;
};
let parent = PathBuf::from(parent_project_path);
let parent = if parent.is_absolute() {
parent
} else {
root.join(parent)
};
let parent = parent.canonicalize().unwrap_or(parent);
parent == root
}
fn normalize_project_id(project_id: &str) -> anyhow::Result<String> {
let project_id = project_id.trim();
if project_id.is_empty() {
anyhow::bail!("--project-id must not be empty");
}
Ok(project_id.to_string())
}
pub fn warn_project_identity(identity: &ProjectIdentity, quiet: bool) {
if quiet {
return;
}
if let Some(warning) = &identity.warning {
eprintln!("Warning: {warning}");
}
}
fn resolve_project_by_name(name: &str, database_url: &str) -> anyhow::Result<PathBuf> {
let mut conn = db::connect_readonly(database_url)?;
let rows = conn.query(
"SELECT root_path FROM code_indexed_projects
WHERE root_path = $1 OR root_path LIKE '%' || '/' || $1
ORDER BY last_indexed_at DESC NULLS LAST
LIMIT 1",
&[&name],
)?;
if let Some(row) = rows.first() {
let root_path: String = row.try_get("root_path")?;
let path = PathBuf::from(&root_path);
if path.is_dir() {
return Ok(path);
}
}
anyhow::bail!(
"Project '{}' not found. Run `gcode projects` to see indexed projects.",
name
)
}
pub fn detect_project_root() -> anyhow::Result<PathBuf> {
let cwd = std::env::current_dir()?;
detect_project_root_from(&cwd)
}
pub fn detect_project_root_from(start: &Path) -> anyhow::Result<PathBuf> {
let start = start
.canonicalize()
.unwrap_or_else(|_| absolute_fallback(start));
let start = if start.is_file() {
start
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| start.clone())
} else {
start
};
if let Some(root) = find_project_root(&start) {
return Ok(root.canonicalize().unwrap_or(root));
}
if let Ok(info) = git::worktree_info(&start)
&& info.kind != WorktreeKind::NotGit
{
return Ok(info.top_level);
}
let mut dir = start.as_path();
loop {
if dir.join(".git").exists() || dir.join(".hg").exists() {
return Ok(dir.to_path_buf());
}
match dir.parent() {
Some(parent) => dir = parent,
None => return Ok(start), }
}
}
pub(crate) fn resolve_daemon_url() -> Option<String> {
if let Ok(port) = std::env::var("GOBBY_PORT")
&& !port.is_empty()
{
return Some(format!("http://localhost:{port}"));
}
let bootstrap_path = db::bootstrap_path().ok()?;
if let Ok(contents) = std::fs::read_to_string(&bootstrap_path)
&& let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents)
&& let Some(port) = yaml.get("daemon_port").and_then(|v| v.as_u64())
{
let host = yaml
.get("bind_host")
.and_then(|v| v.as_str())
.unwrap_or("localhost");
return Some(format!("http://{host}:{port}"));
}
Some("http://localhost:60887".to_string())
}
#[cfg(test)]
fn resolve_project_id(project_root: &Path) -> anyhow::Result<String> {
Ok(resolve_project_identity(project_root, MissingIdentity::Error)?.project_id)
}
fn absolute_fallback(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
}
pub(crate) struct PostgresConfigSource<'a> {
conn: &'a mut Client,
}
impl gobby_core::config::ConfigSource for PostgresConfigSource<'_> {
fn config_value(&mut self, key: &str) -> Option<String> {
let key = canonical_config_key(key);
gobby_core::postgres::read_config_value(self.conn, key)
.ok()
.flatten()
.and_then(|raw| gobby_core::config::decode_config_value(&raw))
}
fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
secrets::resolve_config_value(value, self.conn)
}
}
struct FallbackConfigSource<'a> {
postgres: PostgresConfigSource<'a>,
standalone: Option<StandaloneConfig>,
}
impl ConfigSource for FallbackConfigSource<'_> {
fn config_value(&mut self, key: &str) -> Option<String> {
self.postgres.config_value(key).or_else(|| {
self.standalone
.as_mut()
.and_then(|standalone| standalone.config_value(key))
})
}
fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
self.postgres.resolve_value(value)
}
}
fn read_standalone_config() -> Option<StandaloneConfig> {
let home = db::gobby_home().ok()?;
StandaloneConfig::read_at(&home.join(GCORE_CONFIG_FILENAME))
.ok()
.flatten()
}
#[cfg(test)]
struct ClosureConfigSource<R, S> {
read_config_value: R,
resolve_value: S,
}
#[cfg(test)]
impl<R, S> ConfigSource for ClosureConfigSource<R, S>
where
R: FnMut(&str) -> Option<String>,
S: FnMut(&str) -> anyhow::Result<String>,
{
fn config_value(&mut self, key: &str) -> Option<String> {
(self.read_config_value)(key).and_then(|raw| gobby_core::config::decode_config_value(&raw))
}
fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
(self.resolve_value)(value)
}
}
fn canonical_config_key(key: &str) -> &str {
match key {
FALKORDB_HOST_CONFIG_KEY => FALKORDB_HOST_CONFIG_KEY,
FALKORDB_PORT_CONFIG_KEY => FALKORDB_PORT_CONFIG_KEY,
FALKORDB_PASSWORD_CONFIG_KEY => FALKORDB_PASSWORD_CONFIG_KEY,
_ => key,
}
}
#[cfg(test)]
fn resolve_falkordb_config_from_values<R, S>(
read_config_value: R,
resolve_value: S,
) -> Option<FalkorConfig>
where
R: FnMut(&str) -> Option<String>,
S: FnMut(&str) -> anyhow::Result<String>,
{
let mut source = ClosureConfigSource {
read_config_value,
resolve_value,
};
resolve_falkordb_config_from_source(&mut source)
}
#[cfg(test)]
fn resolve_qdrant_config_from_values<R, S>(
read_config_value: R,
resolve_value: S,
) -> Option<QdrantConfig>
where
R: FnMut(&str) -> Option<String>,
S: FnMut(&str) -> anyhow::Result<String>,
{
let mut source = ClosureConfigSource {
read_config_value,
resolve_value,
};
gobby_core::config::resolve_qdrant_config(&mut source)
}
#[cfg(test)]
fn resolve_embedding_config_from_values<R, S>(
read_config_value: R,
resolve_value: S,
) -> Option<EmbeddingConfig>
where
R: FnMut(&str) -> Option<String>,
S: FnMut(&str) -> anyhow::Result<String>,
{
let mut source = ClosureConfigSource {
read_config_value,
resolve_value,
};
gobby_core::config::resolve_embedding_config(&mut source)
}
#[cfg(test)]
fn resolve_code_vector_settings_from_values<R>(
read_config_value: R,
) -> Result<CodeVectorSettings, CodeVectorConfigError>
where
R: FnMut(&str) -> Option<String>,
{
let mut source = ClosureConfigSource {
read_config_value,
resolve_value: |value: &str| Ok(value.to_string()),
};
resolve_code_vector_settings_from_source(&mut source)
}
fn resolve_falkordb_config(
conn: &mut Client,
standalone: Option<StandaloneConfig>,
_quiet: bool,
) -> Option<FalkorConfig> {
let mut source = FallbackConfigSource {
postgres: PostgresConfigSource { conn },
standalone,
};
resolve_falkordb_config_from_source(&mut source)
}
fn resolve_falkordb_config_from_source(source: &mut impl ConfigSource) -> Option<FalkorConfig> {
let connection = gobby_core::config::resolve_falkordb_config(source)?;
Some(FalkorConfig {
host: connection.host,
port: connection.port,
password: connection.password,
graph_name: FALKORDB_GRAPH_NAME.to_string(),
})
}
fn resolve_qdrant_config(
conn: &mut Client,
standalone: Option<StandaloneConfig>,
_quiet: bool,
) -> Option<QdrantConfig> {
let mut source = FallbackConfigSource {
postgres: PostgresConfigSource { conn },
standalone,
};
gobby_core::config::resolve_qdrant_config(&mut source)
}
fn resolve_embedding_config(
conn: &mut Client,
standalone: Option<StandaloneConfig>,
_quiet: bool,
) -> Option<EmbeddingConfig> {
let mut source = FallbackConfigSource {
postgres: PostgresConfigSource { conn },
standalone,
};
gobby_core::config::resolve_embedding_config(&mut source)
}
pub(crate) fn resolve_code_vector_settings(
conn: &mut Client,
standalone: Option<StandaloneConfig>,
) -> Result<CodeVectorSettings, CodeVectorConfigError> {
let mut source = FallbackConfigSource {
postgres: PostgresConfigSource { conn },
standalone,
};
resolve_code_vector_settings_from_source(&mut source)
}
pub(crate) fn resolve_code_vector_settings_from_source(
source: &mut impl ConfigSource,
) -> Result<CodeVectorSettings, CodeVectorConfigError> {
let vector_dim = match std::env::var(GOBBY_EMBEDDING_VECTOR_DIM_ENV)
.ok()
.filter(|value| !value.trim().is_empty())
{
Some(value) => Some(parse_vector_dim(
GOBBY_EMBEDDING_VECTOR_DIM_ENV,
value.trim(),
)?),
None => source
.config_value(EMBEDDING_VECTOR_DIM_CONFIG_KEY)
.map(|value| parse_vector_dim(EMBEDDING_VECTOR_DIM_CONFIG_KEY, value.trim()))
.transpose()?,
};
Ok(CodeVectorSettings { vector_dim })
}
fn parse_vector_dim(source: &'static str, value: &str) -> Result<usize, CodeVectorConfigError> {
value
.parse::<usize>()
.ok()
.filter(|size| *size > 0)
.ok_or_else(|| CodeVectorConfigError::InvalidVectorDim {
source,
value: value.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn write_project_json(root: &Path, json: serde_json::Value) {
let gobby_dir = root.join(".gobby");
std::fs::create_dir_all(&gobby_dir).expect("create .gobby");
std::fs::write(
gobby_dir.join("project.json"),
serde_json::to_string_pretty(&json).expect("serialize project json"),
)
.expect("write project json");
}
fn run_git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("run git");
assert!(status.success(), "git {:?} failed", args);
}
fn create_linked_worktree(tmp: &tempfile::TempDir) -> (PathBuf, PathBuf) {
let repo = tmp.path().join("repo");
let linked = tmp.path().join("linked");
std::fs::create_dir(&repo).expect("create repo");
run_git(&repo, &["init"]);
std::fs::write(repo.join("README.md"), "hello\n").expect("write readme");
run_git(&repo, &["add", "README.md"]);
run_git(
&repo,
&[
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-m",
"initial",
],
);
run_git(
&repo,
&[
"worktree",
"add",
"-b",
"linked-branch",
linked.to_str().unwrap(),
],
);
(repo, linked)
}
fn clear_service_env() {
for key in [
"GOBBY_FALKORDB_HOST",
"GOBBY_FALKORDB_PORT",
"GOBBY_FALKORDB_PASSWORD",
"GOBBY_QDRANT_URL",
"GOBBY_QDRANT_API_KEY",
"GOBBY_EMBEDDING_URL",
"GOBBY_EMBEDDING_MODEL",
"GOBBY_EMBEDDING_API_KEY",
"GOBBY_EMBEDDING_VECTOR_DIM",
] {
unsafe { std::env::remove_var(key) };
}
}
fn config_value_for<'a>(
values: &'a std::collections::HashMap<&'a str, &'a str>,
) -> impl FnMut(&str) -> Option<String> + 'a {
|key| values.get(key).map(|value| (*value).to_string())
}
#[test]
#[serial_test::serial]
fn adapter_env_precedence_and_json_decode() {
clear_service_env();
unsafe { std::env::set_var("GOBBY_FALKORDB_HOST", "env-falkor.local") };
let values = std::collections::HashMap::from([
("databases.falkordb.host", r#""stored-falkor.local""#),
("databases.falkordb.port", r#""16380""#),
("databases.falkordb.requirepass", r#""stored-pass""#),
("databases.qdrant.url", r#""http://qdrant.local:6333""#),
("databases.qdrant.api_key", r#""qdrant-key""#),
("embeddings.api_base", r#""http://embeddings.local:11434""#),
("embeddings.model", r#""embed-model""#),
("embeddings.api_key", "null"),
]);
let falkor = resolve_falkordb_config_from_values(config_value_for(&values), |value| {
Ok(value.to_string())
})
.expect("falkordb config");
let qdrant = resolve_qdrant_config_from_values(config_value_for(&values), |value| {
Ok(value.to_string())
})
.expect("qdrant config");
let embedding = resolve_embedding_config_from_values(config_value_for(&values), |value| {
Ok(value.to_string())
})
.expect("embedding config");
assert_eq!(falkor.host, "env-falkor.local");
assert_eq!(falkor.port, 16380);
assert_eq!(falkor.password.as_deref(), Some("stored-pass"));
assert_eq!(falkor.graph_name, FALKORDB_GRAPH_NAME);
assert_eq!(qdrant.url.as_deref(), Some("http://qdrant.local:6333"));
assert_eq!(qdrant.api_key.as_deref(), Some("qdrant-key"));
assert_eq!(embedding.api_base, "http://embeddings.local:11434");
assert_eq!(embedding.model, "embed-model");
assert_eq!(embedding.api_key, None);
clear_service_env();
}
#[test]
#[serial_test::serial]
fn adapter_resolves_config_store_secrets() {
clear_service_env();
let values = std::collections::HashMap::from([
("databases.falkordb.host", "falkor.local"),
(
"databases.falkordb.requirepass",
"$secret:falkordb_password",
),
("databases.qdrant.url", "http://qdrant.local:6333"),
("databases.qdrant.api_key", "$secret:qdrant_api_key"),
("embeddings.api_base", "http://embeddings.local:11434"),
("embeddings.api_key", "$secret:embedding_api_key"),
]);
fn resolve_secret_stub(value: &str) -> anyhow::Result<String> {
match value {
"$secret:falkordb_password" => Ok("resolved-falkor".to_string()),
"$secret:qdrant_api_key" => Ok("resolved-qdrant".to_string()),
"$secret:embedding_api_key" => Ok("resolved-embedding".to_string()),
value => Ok(value.to_string()),
}
}
let falkor =
resolve_falkordb_config_from_values(config_value_for(&values), resolve_secret_stub)
.expect("falkordb config");
let qdrant =
resolve_qdrant_config_from_values(config_value_for(&values), resolve_secret_stub)
.expect("qdrant config");
let embedding =
resolve_embedding_config_from_values(config_value_for(&values), resolve_secret_stub)
.expect("embedding config");
assert_eq!(falkor.password.as_deref(), Some("resolved-falkor"));
assert_eq!(qdrant.api_key.as_deref(), Some("resolved-qdrant"));
assert_eq!(embedding.api_key.as_deref(), Some("resolved-embedding"));
}
#[test]
#[serial_test::serial]
fn vector_dim_setting_resolves_env_and_config_store() {
clear_service_env();
let values = std::collections::HashMap::from([("embeddings.vector_dim", "1536")]);
let settings = resolve_code_vector_settings_from_values(config_value_for(&values))
.expect("config-store vector settings");
assert_eq!(settings.vector_dim, Some(1536));
unsafe { std::env::set_var("GOBBY_EMBEDDING_VECTOR_DIM", "3072") };
let settings = resolve_code_vector_settings_from_values(config_value_for(&values))
.expect("env vector settings");
assert_eq!(settings.vector_dim, Some(3072));
unsafe { std::env::remove_var("GOBBY_EMBEDDING_VECTOR_DIM") };
let null_values = std::collections::HashMap::from([("embeddings.vector_dim", "null")]);
let settings = resolve_code_vector_settings_from_values(config_value_for(&null_values))
.expect("null config-store vector settings");
assert_eq!(settings.vector_dim, None);
let invalid_values =
std::collections::HashMap::from([("embeddings.vector_dim", r#""wide""#)]);
let err = resolve_code_vector_settings_from_values(config_value_for(&invalid_values))
.expect_err("invalid vector dim must error");
assert!(matches!(
err,
CodeVectorConfigError::InvalidVectorDim { .. }
));
clear_service_env();
}
#[test]
#[serial_test::serial]
fn phase7_config_resolution_returns_gcode_falkor_config_with_core_fields_and_graph_name() {
clear_service_env();
let values = std::collections::HashMap::from([
("databases.falkordb.host", r#""stored-falkor.local""#),
("databases.falkordb.port", r#""16380""#),
("databases.falkordb.requirepass", r#""stored-pass""#),
]);
let falkor = resolve_falkordb_config_from_values(config_value_for(&values), |value| {
Ok(value.to_string())
})
.expect("falkordb config");
assert_eq!(falkor.host, "stored-falkor.local");
assert_eq!(falkor.port, 16380);
assert_eq!(falkor.password.as_deref(), Some("stored-pass"));
assert_eq!(falkor.graph_name, "gobby_code");
let connection = falkor.connection_config();
assert_eq!(connection.host, falkor.host);
assert_eq!(connection.port, falkor.port);
assert_eq!(connection.password, falkor.password);
clear_service_env();
}
#[test]
fn test_resolve_project_id_requires_project_context() {
let tmp = tempfile::tempdir().expect("tempdir");
let err = resolve_project_id(tmp.path()).expect_err("missing project context must fail");
assert!(
err.to_string().contains("No gcode project found"),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("gcode init"),
"unexpected error: {err}"
);
}
#[test]
fn main_repo_keeps_project_json_id() {
let tmp = tempfile::tempdir().expect("tempdir");
write_project_json(
tmp.path(),
serde_json::json!({
"id": "main-project-id",
"name": "main"
}),
);
let identity =
resolve_project_identity(tmp.path(), MissingIdentity::Error).expect("identity");
assert_eq!(identity.project_id, "main-project-id");
assert_eq!(identity.source, ProjectIdentitySource::ProjectJson);
assert!(!identity.should_write_gcode_json);
assert!(identity.warning.is_none());
}
#[test]
fn self_referential_parent_marker_keeps_project_json_id() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().canonicalize().expect("canonical root");
write_project_json(
&root,
serde_json::json!({
"id": "main-project-id",
"name": "main",
"parent_project_path": root.to_string_lossy(),
"parent_project_id": "main-project-id"
}),
);
let identity = resolve_project_identity(&root, MissingIdentity::Error).expect("identity");
assert_eq!(identity.project_id, "main-project-id");
assert_eq!(identity.source, ProjectIdentitySource::ProjectJson);
assert!(!identity.should_write_gcode_json);
assert!(identity.warning.is_none());
}
#[test]
fn isolated_marker_uses_path_derived_id_without_warning() {
let tmp = tempfile::tempdir().expect("tempdir");
write_project_json(
tmp.path(),
serde_json::json!({
"id": "parent-id",
"parent_project_path": "/parent",
"parent_project_id": "parent-id"
}),
);
let identity =
resolve_project_identity(tmp.path(), MissingIdentity::Error).expect("identity");
assert_eq!(
identity.project_id,
crate::project::code_index_id_for_root(tmp.path())
);
assert_eq!(identity.source, ProjectIdentitySource::IsolatedRoot);
assert!(!identity.should_write_gcode_json);
assert!(identity.warning.is_none());
}
#[test]
fn linked_worktree_uses_path_id_and_warns_only_for_copied_project_id() {
let tmp = tempfile::tempdir().expect("tempdir");
let (_repo, linked) = create_linked_worktree(&tmp);
let identity = resolve_project_identity(&linked, MissingIdentity::Error).expect("identity");
assert_eq!(
identity.project_id,
crate::project::code_index_id_for_root(&linked)
);
assert_eq!(identity.source, ProjectIdentitySource::LinkedWorktree);
assert!(identity.warning.is_none());
assert!(!identity.should_write_gcode_json);
write_project_json(
&linked,
serde_json::json!({
"id": "copied-parent-id",
"name": "linked"
}),
);
let copied =
resolve_project_identity(&linked, MissingIdentity::Error).expect("copied identity");
assert_eq!(copied.source, ProjectIdentitySource::LinkedWorktree);
assert_eq!(
copied.project_id,
crate::project::code_index_id_for_root(&linked)
);
assert!(copied.warning.as_deref().unwrap_or("").contains("copied"));
assert!(!copied.should_write_gcode_json);
}
#[test]
fn generated_identity_writes_only_for_non_isolated_roots() {
let tmp = tempfile::tempdir().expect("tempdir");
let identity =
resolve_project_identity(tmp.path(), MissingIdentity::Generate).expect("identity");
assert_eq!(identity.source, ProjectIdentitySource::Generated);
assert!(identity.should_write_gcode_json);
assert_eq!(
identity.project_id,
crate::project::code_index_id_for_root(tmp.path())
);
}
#[test]
fn project_id_only_context_rejects_empty_id_before_runtime_resolution() {
let err = match Context::resolve_for_project_id(" ", true) {
Ok(_) => panic!("empty project id should fail before DB resolution"),
Err(err) => err,
};
assert!(err.to_string().contains("--project-id must not be empty"));
}
}