use std::path::{Path, PathBuf};
use anyhow::Context as _;
use gobby_core::project::{find_project_root, read_project_id};
use crate::git::{self, WorktreeKind};
use crate::secrets;
#[derive(Debug, Clone)]
pub struct Neo4jConfig {
pub url: String,
pub auth: Option<String>,
pub database: String,
}
#[derive(Debug, Clone)]
pub struct QdrantConfig {
pub url: Option<String>,
pub api_key: Option<String>,
pub collection_prefix: String,
}
#[derive(Debug, Clone)]
pub struct EmbeddingConfig {
pub api_base: String,
pub model: String,
pub api_key: Option<String>,
}
pub struct Context {
pub db_path: PathBuf,
pub project_root: PathBuf,
pub project_id: String,
pub quiet: bool,
pub neo4j: Option<Neo4jConfig>,
pub qdrant: Option<QdrantConfig>,
pub embedding: Option<EmbeddingConfig>,
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 project_root = match project_override {
Some(p) => {
let path = PathBuf::from(p);
if path.is_dir() {
path.canonicalize()?
} else {
resolve_project_by_name(p)?
}
}
None => detect_project_root()?,
};
let db_path = resolve_db_path(&project_root)?;
let identity = resolve_project_identity(&project_root, MissingIdentity::Error)?;
warn_project_identity(&identity, quiet);
let project_id = identity.project_id;
let neo4j = resolve_neo4j_config(&db_path, quiet);
let qdrant = resolve_qdrant_config(&db_path, quiet);
let embedding = resolve_embedding_config(&db_path, quiet);
let daemon_url = resolve_daemon_url();
Ok(Self {
db_path,
project_root,
project_id,
quiet,
neo4j,
qdrant,
embedding,
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 crate::project::read_isolation_marker(&root).is_some() {
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."
),
}
}
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) -> anyhow::Result<PathBuf> {
let gobby_dir = dirs::home_dir()
.context("cannot determine home directory")?
.join(".gobby");
let db_paths = [
gobby_dir.join("gobby-code-index.db"),
gobby_dir.join("gobby-hub.db"),
];
for db_path in &db_paths {
if !db_path.exists() {
continue;
}
let conn = match rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => continue,
};
let _ = conn.busy_timeout(std::time::Duration::from_millis(5000));
let has_table: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='code_indexed_projects')",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !has_table {
continue;
}
let result: Option<String> = conn
.query_row(
"SELECT root_path FROM code_indexed_projects WHERE root_path LIKE '%/' || ?1",
rusqlite::params![name],
|row| row.get(0),
)
.ok();
if let Some(root_path) = result {
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 resolve_db_path(project_root: &Path) -> anyhow::Result<PathBuf> {
let gobby_dir = dirs::home_dir()
.context("cannot determine home directory")?
.join(".gobby");
let has_project_json = project_root.join(".gobby").join("project.json").exists();
if has_project_json {
let bootstrap_path = gobby_dir.join("bootstrap.yaml");
if bootstrap_path.exists() {
let contents = std::fs::read_to_string(&bootstrap_path)?;
let yaml: serde_yaml::Value = serde_yaml::from_str(&contents)?;
if let Some(db) = yaml.get("database_path").and_then(|v| v.as_str()) {
let expanded = db.replace("~", &gobby_dir.parent().unwrap().to_string_lossy());
return Ok(PathBuf::from(expanded));
}
}
return Ok(gobby_dir.join("gobby-hub.db"));
}
Ok(gobby_dir.join("gobby-code-index.db"))
}
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") {
if !port.is_empty() {
return Some(format!("http://localhost:{port}"));
}
}
let bootstrap_path = dirs::home_dir()?.join(".gobby").join("bootstrap.yaml");
if let Ok(contents) = std::fs::read_to_string(&bootstrap_path) {
if let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents) {
if 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)
}
}
fn short_id(id: &str) -> &str {
id.get(..8).unwrap_or(id)
}
fn read_config_value(conn: &rusqlite::Connection, key: &str) -> Option<String> {
let raw = conn
.query_row(
"SELECT value FROM config_store WHERE key = ?1",
rusqlite::params![key],
|row| row.get::<_, String>(0),
)
.ok()?;
if raw.starts_with('"') && raw.ends_with('"') && raw.len() >= 2 {
Some(raw[1..raw.len() - 1].to_string())
} else {
Some(raw)
}
}
fn resolve_neo4j_config(db_path: &Path, quiet: bool) -> Option<Neo4jConfig> {
let conn = rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()?;
conn.busy_timeout(std::time::Duration::from_millis(5000))
.ok()?;
let has_config_store = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='config_store'",
[],
|_| Ok(()),
)
.is_ok();
let url = std::env::var("GOBBY_NEO4J_URL")
.ok()
.or_else(|| read_config_value(&conn, "databases.neo4j.url"))
.or_else(|| {
if has_config_store {
Some("http://localhost:8474".to_string())
} else {
None
}
})?;
let raw_auth = std::env::var("GOBBY_NEO4J_AUTH")
.ok()
.or_else(|| read_config_value(&conn, "databases.neo4j.auth"));
let auth = match raw_auth {
Some(v) => match secrets::resolve_config_value(&v, db_path) {
Ok(resolved) => Some(resolved),
Err(e) => {
if !quiet {
eprintln!("Warning: failed to resolve Neo4j auth: {e}");
}
None
}
},
None => None,
};
let database =
read_config_value(&conn, "databases.neo4j.database").unwrap_or_else(|| "neo4j".to_string());
Some(Neo4jConfig {
url,
auth,
database,
})
}
fn resolve_qdrant_config(db_path: &Path, quiet: bool) -> Option<QdrantConfig> {
let conn = rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()?;
conn.busy_timeout(std::time::Duration::from_millis(5000))
.ok()?;
let url = std::env::var("GOBBY_QDRANT_URL")
.ok()
.or_else(|| read_config_value(&conn, "databases.qdrant.url"));
let raw_api_key = read_config_value(&conn, "databases.qdrant.api_key");
let api_key = match raw_api_key {
Some(v) => match secrets::resolve_config_value(&v, db_path) {
Ok(resolved) => Some(resolved),
Err(e) => {
if !quiet {
eprintln!("Warning: failed to resolve Qdrant API key: {e}");
}
None
}
},
None => None,
};
let collection_prefix = read_config_value(&conn, "databases.qdrant.collection_prefix")
.unwrap_or_else(|| "code_symbols_".to_string());
url.as_ref()?;
Some(QdrantConfig {
url,
api_key,
collection_prefix,
})
}
fn resolve_embedding_config(db_path: &Path, quiet: bool) -> Option<EmbeddingConfig> {
let api_base = std::env::var("GOBBY_EMBEDDING_URL").ok();
let api_base = api_base.or_else(|| {
let conn = rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()?;
conn.busy_timeout(std::time::Duration::from_millis(5000))
.ok()?;
read_config_value(&conn, "embeddings.api_base")
})?;
let model = std::env::var("GOBBY_EMBEDDING_MODEL")
.ok()
.or_else(|| {
let conn = rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
| rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()?;
read_config_value(&conn, "embeddings.model")
})
.unwrap_or_else(|| "nomic-embed-text".to_string());
let api_key = std::env::var("GOBBY_EMBEDDING_API_KEY").ok().or_else(|| {
let conn = rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()?;
let raw = read_config_value(&conn, "embeddings.api_key")?;
match secrets::resolve_config_value(&raw, db_path) {
Ok(resolved) => Some(resolved),
Err(e) => {
if !quiet {
eprintln!("Warning: failed to resolve embedding API key: {e}");
}
None
}
}
});
Some(EmbeddingConfig {
api_base,
model,
api_key,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn create_test_db() -> (tempfile::NamedTempFile, rusqlite::Connection) {
let tmp = tempfile::NamedTempFile::new().unwrap();
let conn = rusqlite::Connection::open(tmp.path()).unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS config_store (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
source TEXT DEFAULT 'test',
is_secret INTEGER DEFAULT 0,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS secrets (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
encrypted_value TEXT NOT NULL,
category TEXT DEFAULT 'general',
description TEXT,
created_at TEXT,
updated_at TEXT
);",
)
.unwrap();
(tmp, conn)
}
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)
}
#[test]
fn test_read_config_store_values() {
let (tmp, conn) = create_test_db();
conn.execute(
"INSERT INTO config_store (key, value) VALUES ('memory.neo4j_url', 'http://test:7474')",
[],
)
.unwrap();
let value = read_config_value(&conn, "memory.neo4j_url");
assert_eq!(value, Some("http://test:7474".to_string()));
let missing = read_config_value(&conn, "memory.nonexistent");
assert_eq!(missing, None);
drop(tmp);
}
#[test]
#[serial_test::serial]
fn test_config_env_override() {
let (_tmp, _conn) = create_test_db();
unsafe { std::env::set_var("GOBBY_NEO4J_URL", "http://env-override:9999") };
let url = std::env::var("GOBBY_NEO4J_URL").unwrap();
assert_eq!(url, "http://env-override:9999");
unsafe { std::env::remove_var("GOBBY_NEO4J_URL") };
}
#[test]
#[serial_test::serial]
fn test_config_defaults() {
let (tmp, _conn) = create_test_db();
let config = resolve_neo4j_config(tmp.path(), false);
let config = config.expect("should return defaults when config_store exists");
assert_eq!(config.url, "http://localhost:8474");
assert_eq!(config.database, "neo4j");
}
#[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 isolated_marker_uses_path_derived_id_without_warning() {
let tmp = tempfile::tempdir().expect("tempdir");
write_project_json(
tmp.path(),
serde_json::json!({
"id": "copied-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())
);
}
}