Skip to main content

meerkat_core/
config_store.rs

1//! Config store abstraction.
2
3use crate::config::{Config, ConfigDelta, ConfigError};
4#[cfg(target_arch = "wasm32")]
5use crate::tokio;
6use async_trait::async_trait;
7use serde_json::Value;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use tokio::io::AsyncWriteExt;
11use uuid::Uuid;
12
13/// Resolved paths attached to a config store context.
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct ConfigResolvedPaths {
16    pub root: String,
17    pub manifest_path: String,
18    pub config_path: String,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub sessions_sqlite_path: Option<String>,
21    pub sessions_jsonl_dir: String,
22}
23
24/// Optional metadata for config endpoints.
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct ConfigStoreMetadata {
27    pub realm_id: Option<String>,
28    pub instance_id: Option<String>,
29    pub backend: Option<String>,
30    pub resolved_paths: Option<ConfigResolvedPaths>,
31}
32
33/// Abstraction over config persistence backends.
34#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
35#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
36pub trait ConfigStore: Send + Sync {
37    /// Fetch the current config.
38    async fn get(&self) -> Result<Config, ConfigError>;
39
40    /// Persist the provided config.
41    async fn set(&self, config: Config) -> Result<(), ConfigError>;
42
43    /// Apply a config patch and return the updated config.
44    async fn patch(&self, delta: ConfigDelta) -> Result<Config, ConfigError>;
45
46    /// Optional metadata to expose on config APIs.
47    fn metadata(&self) -> Option<ConfigStoreMetadata> {
48        None
49    }
50}
51
52/// In-memory config store for ephemeral settings.
53pub struct MemoryConfigStore {
54    config: tokio::sync::RwLock<Config>,
55}
56
57impl MemoryConfigStore {
58    pub fn new(config: Config) -> Self {
59        Self {
60            config: tokio::sync::RwLock::new(config),
61        }
62    }
63}
64
65#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
66#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
67impl ConfigStore for MemoryConfigStore {
68    async fn get(&self) -> Result<Config, ConfigError> {
69        Ok(self.config.read().await.clone())
70    }
71
72    async fn set(&self, config: Config) -> Result<(), ConfigError> {
73        config.validate()?;
74        *self.config.write().await = config;
75        Ok(())
76    }
77
78    async fn patch(&self, delta: ConfigDelta) -> Result<Config, ConfigError> {
79        let mut config = self.config.write().await;
80        let mut value = serde_json::to_value(&*config).map_err(ConfigError::Json)?;
81        merge_patch(&mut value, delta.0);
82        let updated: Config = serde_json::from_value(value).map_err(ConfigError::Json)?;
83        updated.validate()?;
84        *config = updated.clone();
85        Ok(updated)
86    }
87}
88
89/// Metadata-tagged config store wrapper.
90pub struct TaggedConfigStore {
91    inner: Arc<dyn ConfigStore>,
92    metadata: ConfigStoreMetadata,
93}
94
95impl TaggedConfigStore {
96    pub fn new(inner: Arc<dyn ConfigStore>, metadata: ConfigStoreMetadata) -> Self {
97        Self { inner, metadata }
98    }
99}
100
101#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
102#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
103impl ConfigStore for TaggedConfigStore {
104    async fn get(&self) -> Result<Config, ConfigError> {
105        self.inner.get().await
106    }
107
108    async fn set(&self, config: Config) -> Result<(), ConfigError> {
109        self.inner.set(config).await
110    }
111
112    async fn patch(&self, delta: ConfigDelta) -> Result<Config, ConfigError> {
113        self.inner.patch(delta).await
114    }
115
116    fn metadata(&self) -> Option<ConfigStoreMetadata> {
117        Some(self.metadata.clone())
118    }
119}
120
121/// File-backed config store with optional bootstrap template.
122pub struct FileConfigStore {
123    path: PathBuf,
124    create_if_missing: bool,
125}
126
127impl FileConfigStore {
128    /// Create a new file-backed store for an explicit path.
129    pub fn new(path: PathBuf) -> Self {
130        Self {
131            path,
132            create_if_missing: false,
133        }
134    }
135
136    /// Create a store that bootstraps a global config file if missing.
137    pub async fn global() -> Result<Self, ConfigError> {
138        let path = Config::global_config_path()
139            .ok_or_else(|| ConfigError::MissingField("HOME".to_string()))?;
140        let store = Self {
141            path,
142            create_if_missing: true,
143        };
144        store.ensure_exists().await?;
145        Ok(store)
146    }
147
148    /// Create a store rooted at the provided project directory.
149    pub fn project(project_root: impl Into<PathBuf>) -> Self {
150        let root = project_root.into();
151        Self::new(root.join(".rkat").join("config.toml"))
152    }
153
154    /// Return the config file path.
155    pub fn path(&self) -> &Path {
156        &self.path
157    }
158
159    async fn ensure_exists(&self) -> Result<(), ConfigError> {
160        if tokio::fs::try_exists(&self.path).await? {
161            return Ok(());
162        }
163        if let Some(parent) = self.path.parent() {
164            tokio::fs::create_dir_all(parent).await?;
165        }
166        let content = Config::template_toml();
167        tokio::fs::write(&self.path, content).await?;
168        Ok(())
169    }
170}
171
172#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
173#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
174impl ConfigStore for FileConfigStore {
175    async fn get(&self) -> Result<Config, ConfigError> {
176        if self.create_if_missing {
177            self.ensure_exists().await?;
178        }
179
180        if !tokio::fs::try_exists(&self.path).await? {
181            return Ok(Config::default());
182        }
183
184        let bytes = tokio::fs::read(&self.path).await?;
185        let content = String::from_utf8(bytes).map_err(ConfigError::Utf8)?;
186        toml::from_str(&content).map_err(ConfigError::Parse)
187    }
188
189    async fn set(&self, config: Config) -> Result<(), ConfigError> {
190        config.validate()?;
191        if let Some(parent) = self.path.parent() {
192            tokio::fs::create_dir_all(parent).await?;
193        }
194        let content = toml::to_string_pretty(&config).map_err(ConfigError::TomlSerialize)?;
195        let parent = self
196            .path
197            .parent()
198            .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
199        let tmp_path = parent.join(format!(".config.tmp.{}", Uuid::now_v7()));
200        let mut tmp = tokio::fs::OpenOptions::new()
201            .write(true)
202            .create_new(true)
203            .open(&tmp_path)
204            .await?;
205        tmp.write_all(content.as_bytes()).await?;
206        tmp.sync_all().await?;
207        drop(tmp);
208        tokio::fs::rename(&tmp_path, &self.path).await?;
209        Ok(())
210    }
211
212    async fn patch(&self, delta: ConfigDelta) -> Result<Config, ConfigError> {
213        let mut value = serde_json::to_value(self.get().await?).map_err(ConfigError::Json)?;
214        merge_patch(&mut value, delta.0);
215        let updated: Config = serde_json::from_value(value).map_err(ConfigError::Json)?;
216        updated.validate()?;
217        self.set(updated.clone()).await?;
218        Ok(updated)
219    }
220}
221
222/// Internal utility for JSON merge patch application
223pub(crate) fn merge_patch(base: &mut Value, patch: Value) {
224    match (base, patch) {
225        (Value::Object(base_map), Value::Object(patch_map)) => {
226            for (k, v) in patch_map {
227                if v.is_null() {
228                    base_map.remove(&k);
229                } else {
230                    merge_patch(base_map.entry(k).or_insert(Value::Null), v);
231                }
232            }
233        }
234        (base_val, patch_val) => {
235            *base_val = patch_val;
236        }
237    }
238}