use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::TalaDbError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DurabilityConfig {
#[serde(default = "default_true")]
pub flush_every_write: bool,
#[serde(default)]
pub flush_ms: Option<u32>,
}
fn default_true() -> bool {
true
}
impl Default for DurabilityConfig {
fn default() -> Self {
Self {
flush_every_write: true,
flush_ms: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct TalaDbConfig {
#[serde(default)]
pub durability: DurabilityConfig,
#[serde(default)]
pub vector_cache_bytes: Option<usize>,
#[serde(default, deserialize_with = "positive_memory_hint")]
pub vector_cache_memory_bytes: Option<u64>,
}
fn positive_memory_hint<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<u64>, D::Error> {
let value = Option::<u64>::deserialize(deserializer)?;
if value == Some(0) {
return Err(serde::de::Error::custom(
"vector_cache_memory_bytes must be positive",
));
}
Ok(value)
}
impl TalaDbConfig {
pub fn apply_vector_cache(&self, db: &crate::Database) -> Result<(), TalaDbError> {
if let Some(bytes) = self.vector_cache_bytes {
db.set_vector_cache_budget(bytes);
} else if let Some(bytes) = self.vector_cache_memory_bytes {
db.set_vector_cache_adaptive(Some(bytes))?;
}
Ok(())
}
}
pub fn load_from_path(path: &Path) -> Result<TalaDbConfig, TalaDbError> {
let content = std::fs::read_to_string(path).map_err(|e| {
TalaDbError::Config(format!("failed to read config at {}: {e}", path.display()))
})?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let config: TalaDbConfig = match ext {
#[cfg(feature = "config-yaml")]
"yml" | "yaml" => serde_yaml_ng::from_str(&content)
.map_err(|e| TalaDbError::Config(format!("invalid YAML config: {e}")))?,
#[cfg(not(feature = "config-yaml"))]
"yml" | "yaml" => {
return Err(TalaDbError::Config(
"YAML config support is not compiled into this build \
(enable the `config-yaml` feature) — use .json instead"
.into(),
));
}
"json" => serde_json::from_str(&content)
.map_err(|e| TalaDbError::Config(format!("invalid JSON config: {e}")))?,
other => {
return Err(TalaDbError::Config(format!(
"unsupported config extension \".{other}\" — use .yml, .yaml, or .json"
)));
}
};
Ok(config)
}
pub fn load_auto(cwd: &Path) -> Result<TalaDbConfig, TalaDbError> {
for name in [
"taladb.config.yml",
"taladb.config.yaml",
"taladb.config.json",
] {
let path = cwd.join(name);
if path.exists() {
return load_from_path(&path);
}
}
Ok(TalaDbConfig::default())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
use tempfile::NamedTempFile;
#[test]
fn adaptive_memory_hints_support_large_hosts_and_fixed_budgets_win() {
let db = crate::Database::open_in_memory().unwrap();
let config: TalaDbConfig =
serde_json::from_str(r#"{"vector_cache_memory_bytes":8589934592}"#).unwrap();
config.apply_vector_cache(&db).unwrap();
assert_eq!(db.vector_cache_stats().budget_bytes, 16 * 1024 * 1024);
let config: TalaDbConfig = serde_json::from_str(
r#"{"vector_cache_memory_bytes":8589934592,"vector_cache_bytes":0}"#,
)
.unwrap();
config.apply_vector_cache(&db).unwrap();
assert_eq!(db.vector_cache_stats().budget_bytes, 0);
assert_eq!(
db.vector_cache_stats().policy,
crate::VectorCachePolicy::Fixed
);
for value in ["0", "-1", "1.5", "true"] {
assert!(
serde_json::from_str::<TalaDbConfig>(&format!(
"{{\"vector_cache_memory_bytes\":{value}}}"
))
.is_err()
);
}
}
fn write_tmp(content: &str, ext: &str) -> NamedTempFile {
let mut f = tempfile::Builder::new()
.suffix(&format!(".{ext}"))
.tempfile()
.unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn parses_valid_yaml() {
let f = write_tmp(
r#"
durability:
flush_every_write: false
flush_ms: 250
"#,
"yml",
);
let cfg = load_from_path(f.path()).unwrap();
assert!(!cfg.durability.flush_every_write);
assert_eq!(cfg.durability.flush_ms, Some(250));
}
#[test]
fn parses_yaml_with_yaml_extension() {
let f = write_tmp("durability:\n flush_every_write: false\n", "yaml");
let cfg = load_from_path(f.path()).unwrap();
assert!(!cfg.durability.flush_every_write);
}
#[test]
fn parses_valid_json() {
let f = write_tmp(
r#"{ "durability": { "flush_every_write": false, "flush_ms": 100 } }"#,
"json",
);
let cfg = load_from_path(f.path()).unwrap();
assert!(!cfg.durability.flush_every_write);
assert_eq!(cfg.durability.flush_ms, Some(100));
}
#[test]
fn flush_every_write_defaults_to_true() {
let f = write_tmp("durability:\n flush_ms: 50\n", "yml");
let cfg = load_from_path(f.path()).unwrap();
assert!(cfg.durability.flush_every_write);
assert_eq!(cfg.durability.flush_ms, Some(50));
let empty = write_tmp("{}", "json");
assert!(
load_from_path(empty.path())
.unwrap()
.durability
.flush_every_write
);
}
#[test]
fn defaults_when_no_config_file() {
let dir = tempfile::tempdir().unwrap();
let cfg = load_auto(dir.path()).unwrap();
assert_eq!(cfg, TalaDbConfig::default());
assert!(cfg.durability.flush_every_write);
}
#[test]
fn load_auto_finds_yml_before_json() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("taladb.config.yml"),
"durability:\n flush_ms: 111\n",
)
.unwrap();
std::fs::write(
dir.path().join("taladb.config.json"),
r#"{"durability":{"flush_ms":222}}"#,
)
.unwrap();
let cfg = load_auto(dir.path()).unwrap();
assert_eq!(cfg.durability.flush_ms, Some(111));
}
#[test]
fn ignores_unknown_keys() {
let f = write_tmp(
r#"
durability:
flush_every_write: true
webhook:
enabled: true
endpoint: "https://api.example.com/hook"
unknown_top_level: "ignored"
"#,
"yml",
);
let cfg = load_from_path(f.path()).unwrap();
assert!(cfg.durability.flush_every_write);
}
#[test]
fn returns_error_on_unreadable_file() {
let err = load_from_path(Path::new("/nonexistent/taladb.config.yml")).unwrap_err();
assert!(matches!(err, TalaDbError::Config(_)));
}
#[test]
fn returns_error_on_unsupported_extension() {
let f = write_tmp("durability:\n flush_ms: 1\n", "toml");
let err = load_from_path(f.path()).unwrap_err().to_string();
assert!(err.contains("unsupported config extension"), "{err}");
}
}