use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::Deserialize;
use crate::config::{ConfigSource, ai_keys, embedding_keys, resolve_env_pattern};
use crate::degradation::CoreError;
pub const GCORE_CONFIG_FILENAME: &str = "gcore.yaml";
pub const SERVICES_DIRNAME: &str = "services";
pub const COMPOSE_FILENAME: &str = "docker-compose.yml";
pub const DEFAULT_POSTGRES_HOST: &str = "127.0.0.1";
pub const DEFAULT_POSTGRES_PORT: u16 = 60891;
pub const DEFAULT_POSTGRES_DB: &str = "gobby";
pub const DEFAULT_POSTGRES_USER: &str = "gobby";
pub const DEFAULT_POSTGRES_PASSWORD: &str = "gobby_dev";
pub const DEFAULT_FALKORDB_HOST: &str = "127.0.0.1";
pub const DEFAULT_FALKORDB_PORT: u16 = 16379;
pub const DEFAULT_FALKORDB_BROWSER_PORT: u16 = 13000;
pub const DEFAULT_FALKORDB_PASSWORD: &str = "gobbyfalkor";
pub const DEFAULT_QDRANT_HOST: &str = "127.0.0.1";
pub const DEFAULT_QDRANT_HTTP_PORT: u16 = 6333;
pub const DEFAULT_QDRANT_GRPC_PORT: u16 = 6334;
pub const DEFAULT_LM_STUDIO_API_BASE: &str = "http://localhost:1234/v1";
pub const DEFAULT_LM_STUDIO_MODEL: &str = "text-embedding-nomic-embed-text-v1.5@f16";
pub const DEFAULT_LM_STUDIO_TEXT_MODEL: &str = "qwen2.5-vl-7b-instruct";
pub const DEFAULT_OLLAMA_API_BASE: &str = "http://localhost:11434/v1";
pub const DEFAULT_OLLAMA_MODEL: &str = "nomic-embed-text";
pub const DEFAULT_OLLAMA_TEXT_MODEL: &str = "qwen3-coder";
pub const DEFAULT_EMBEDDING_VECTOR_DIM: usize = 768;
pub const COMPOSE_TEMPLATE: &str = include_str!("../../assets/docker-compose.services.yml");
const PGSEARCH_DOCKERFILE: &str = include_str!("../../assets/postgres-pgsearch/Dockerfile");
const PGSEARCH_VERSION: &str = include_str!("../../assets/postgres-pgsearch/version.json");
const PGSEARCH_INIT_PG_SEARCH: &str =
include_str!("../../assets/postgres-pgsearch/initdb.d/01-pg_search.sql");
const PGSEARCH_INIT_PGAUDIT: &str =
include_str!("../../assets/postgres-pgsearch/initdb.d/02-pgaudit.sql");
const PG_AUDIT_EXPORT: &str =
include_str!("../../assets/postgres-pgsearch/scripts/pg_audit_export.sh");
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StandaloneConfig {
values: BTreeMap<String, String>,
}
impl StandaloneConfig {
pub fn new(values: BTreeMap<String, String>) -> Self {
Self { values }
}
pub fn empty() -> Self {
Self::default()
}
pub fn read_at(path: &Path) -> anyhow::Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(path)
.map_err(|err| anyhow::anyhow!("failed to read {}: {err}", path.display()))?;
Self::from_yaml_str(&contents)
.map(Some)
.map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display()))
}
pub fn from_yaml_str(contents: &str) -> anyhow::Result<Self> {
if contents.trim().is_empty() {
return Ok(Self::default());
}
let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
let mut values = BTreeMap::new();
flatten_yaml_value(None, &yaml, &mut values)?;
let mut config = Self { values };
config.apply_text_generation_defaults_from_embeddings();
Ok(config)
}
pub fn write_at(&self, path: &Path) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut mapping = serde_yaml::Mapping::new();
for (key, value) in &self.values {
insert_nested_yaml_value(&mut mapping, key, value)?;
}
let yaml = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))?;
fs::write(path, yaml)?;
Ok(())
}
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.values.insert(key.into(), value.into());
}
pub fn remove(&mut self, key: &str) {
self.values.remove(key);
}
pub fn values(&self) -> &BTreeMap<String, String> {
&self.values
}
fn apply_text_generation_defaults_from_embeddings(&mut self) {
if self.get(ai_keys::TEXT_GENERATE_ROUTING).is_some()
|| self.get(ai_keys::TEXT_GENERATE_API_BASE).is_some()
|| self.get(ai_keys::TEXT_GENERATE_MODEL).is_some()
{
return;
}
let api_key = self.get(embedding_keys::AI_API_KEY).map(str::to_string);
apply_text_generation_bootstrap(
self,
&TextGenerationBootstrap::from_endpoint(None, DEFAULT_LM_STUDIO_API_BASE, api_key),
);
}
}
impl ConfigSource for StandaloneConfig {
fn config_value(&mut self, key: &str) -> Option<String> {
self.values.get(key).cloned()
}
fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
if value.contains("$secret:") {
anyhow::bail!("secret resolution requires daemon config_store");
}
resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
}
}
pub fn gcore_config_path(gobby_home: &Path) -> PathBuf {
gobby_home.join(GCORE_CONFIG_FILENAME)
}
pub fn services_dir(gobby_home: &Path) -> PathBuf {
gobby_home.join(SERVICES_DIRNAME)
}
pub fn compose_file_path(gobby_home: &Path) -> PathBuf {
services_dir(gobby_home).join(COMPOSE_FILENAME)
}
pub fn default_database_url(port: u16) -> String {
format!(
"postgresql://{user}:{password}@{host}:{port}/{db}",
user = DEFAULT_POSTGRES_USER,
password = DEFAULT_POSTGRES_PASSWORD,
host = DEFAULT_POSTGRES_HOST,
port = port,
db = DEFAULT_POSTGRES_DB
)
}
fn insert_nested_yaml_value(
mapping: &mut serde_yaml::Mapping,
key: &str,
value: &str,
) -> anyhow::Result<()> {
let parts = key
.split('.')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if !parts.is_empty() {
insert_nested_yaml_parts(mapping, &parts, value, key, String::new())?;
}
Ok(())
}
fn insert_nested_yaml_parts(
mapping: &mut serde_yaml::Mapping,
parts: &[&str],
value: &str,
full_key: &str,
prefix: String,
) -> anyhow::Result<()> {
let yaml_key = serde_yaml::Value::String(parts[0].to_string());
let current_path = if prefix.is_empty() {
parts[0].to_string()
} else {
format!("{prefix}.{}", parts[0])
};
if parts.len() == 1 {
if matches!(mapping.get(&yaml_key), Some(serde_yaml::Value::Mapping(_))) {
anyhow::bail!(
"gcore config key '{full_key}' collides with nested YAML mapping '{current_path}'"
);
}
mapping.insert(yaml_key, serde_yaml::Value::String(value.to_string()));
return Ok(());
}
let entry = mapping
.entry(yaml_key)
.or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
if !matches!(entry, serde_yaml::Value::Mapping(_)) {
anyhow::bail!(
"gcore config key '{full_key}' cannot be nested under scalar YAML key '{current_path}'"
);
}
let serde_yaml::Value::Mapping(child) = entry else {
unreachable!("entry was normalized to a mapping");
};
insert_nested_yaml_parts(child, &parts[1..], value, full_key, current_path)
}
mod bootstrap;
mod docker;
mod hub;
pub use bootstrap::*;
pub use docker::*;
pub use hub::*;
#[cfg(test)]
mod tests;