Skip to main content

axon/
config_persistence.rs

1//! Config Persistence — save and restore AxonServer runtime configuration.
2//!
3//! Persists `ConfigSnapshot` to a JSON file on disk, allowing runtime
4//! configuration changes to survive server restarts.
5//!
6//! Features:
7//!   - Save current config snapshot to file
8//!   - Load config snapshot from file on startup
9//!   - Convert loaded snapshot into a `ConfigUpdate` for applying
10//!   - Backup previous config before overwriting
11//!   - Metadata: save timestamp, server version, save count
12//!
13//! Default path: `axon-server-config.json` in the working directory.
14
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use serde::{Deserialize, Serialize};
19
20use crate::server_config::{ConfigSnapshot, ConfigUpdate, RateLimitUpdate, RequestLogUpdate};
21
22// ── Persisted config envelope ───────────────────────────────────────────
23
24/// Envelope wrapping a ConfigSnapshot with persistence metadata.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PersistedConfig {
27    /// Schema version for forward compatibility.
28    pub version: u32,
29    /// Timestamp when this config was saved (Unix seconds).
30    pub saved_at: u64,
31    /// Server version that wrote this config.
32    pub axon_version: String,
33    /// Number of times this file has been updated.
34    pub save_count: u64,
35    /// The actual configuration snapshot.
36    pub config: ConfigSnapshot,
37}
38
39/// Result of a save operation.
40#[derive(Debug, Clone, Serialize)]
41pub struct SaveResult {
42    pub success: bool,
43    pub path: String,
44    pub save_count: u64,
45    pub error: Option<String>,
46}
47
48/// Result of a load operation.
49#[derive(Debug, Clone, Serialize)]
50pub struct LoadResult {
51    pub success: bool,
52    pub path: String,
53    pub saved_at: Option<u64>,
54    pub save_count: Option<u64>,
55    pub error: Option<String>,
56}
57
58// ── Default path ────────────────────────────────────────────────────────
59
60/// Default config file name.
61pub const DEFAULT_CONFIG_FILE: &str = "axon-server-config.json";
62
63/// Resolve the config file path. If a custom path is given, use it;
64/// otherwise use the default filename in the current directory.
65pub fn resolve_path(custom: Option<&str>) -> PathBuf {
66    match custom {
67        Some(p) => PathBuf::from(p),
68        None => PathBuf::from(DEFAULT_CONFIG_FILE),
69    }
70}
71
72// ── Save ────────────────────────────────────────────────────────────────
73
74/// Save a config snapshot to disk.
75///
76/// If the file already exists, reads the current save_count and increments it.
77/// The file is written atomically (write to .tmp, then rename).
78pub fn save(snapshot: &ConfigSnapshot, path: &Path, axon_version: &str) -> SaveResult {
79    // Read existing save count
80    let prev_count = match std::fs::read_to_string(path) {
81        Ok(content) => {
82            serde_json::from_str::<PersistedConfig>(&content)
83                .map(|p| p.save_count)
84                .unwrap_or(0)
85        }
86        Err(_) => 0,
87    };
88
89    let persisted = PersistedConfig {
90        version: 1,
91        saved_at: now_secs(),
92        axon_version: axon_version.to_string(),
93        save_count: prev_count + 1,
94        config: snapshot.clone(),
95    };
96
97    let json = match serde_json::to_string_pretty(&persisted) {
98        Ok(j) => j,
99        Err(e) => {
100            return SaveResult {
101                success: false,
102                path: path.display().to_string(),
103                save_count: prev_count,
104                error: Some(format!("serialize error: {e}")),
105            };
106        }
107    };
108
109    // Write to temp file then rename for atomicity
110    let tmp_path = path.with_extension("json.tmp");
111    if let Err(e) = std::fs::write(&tmp_path, &json) {
112        return SaveResult {
113            success: false,
114            path: path.display().to_string(),
115            save_count: prev_count,
116            error: Some(format!("write error: {e}")),
117        };
118    }
119
120    if let Err(e) = std::fs::rename(&tmp_path, path) {
121        // Fallback: try direct write if rename fails (cross-device)
122        if let Err(e2) = std::fs::write(path, &json) {
123            return SaveResult {
124                success: false,
125                path: path.display().to_string(),
126                save_count: prev_count,
127                error: Some(format!("rename error: {e}, write fallback error: {e2}")),
128            };
129        }
130        // Clean up tmp
131        let _ = std::fs::remove_file(&tmp_path);
132    }
133
134    SaveResult {
135        success: true,
136        path: path.display().to_string(),
137        save_count: persisted.save_count,
138        error: None,
139    }
140}
141
142// ── Load ────────────────────────────────────────────────────────────────
143
144/// Load a persisted config from disk.
145/// Returns the PersistedConfig envelope if successful.
146pub fn load(path: &Path) -> Result<PersistedConfig, String> {
147    let content = std::fs::read_to_string(path)
148        .map_err(|e| format!("read error: {e}"))?;
149
150    let persisted: PersistedConfig = serde_json::from_str(&content)
151        .map_err(|e| format!("parse error: {e}"))?;
152
153    if persisted.version != 1 {
154        return Err(format!("unsupported config version: {}", persisted.version));
155    }
156
157    Ok(persisted)
158}
159
160/// Check if a persisted config file exists.
161pub fn exists(path: &Path) -> bool {
162    path.is_file()
163}
164
165/// Delete the persisted config file.
166pub fn remove(path: &Path) -> bool {
167    std::fs::remove_file(path).is_ok()
168}
169
170// ── Convert to update ───────────────────────────────────────────────────
171
172/// Convert a ConfigSnapshot into a ConfigUpdate that can be applied to
173/// restore the configuration. Auth section is skipped (not configurable).
174pub fn snapshot_to_update(snapshot: &ConfigSnapshot) -> ConfigUpdate {
175    ConfigUpdate {
176        rate_limit: Some(RateLimitUpdate {
177            max_requests: Some(snapshot.rate_limit.max_requests),
178            window_secs: Some(snapshot.rate_limit.window_secs),
179            enabled: Some(snapshot.rate_limit.enabled),
180        }),
181        request_log: Some(RequestLogUpdate {
182            capacity: Some(snapshot.request_log.capacity),
183            enabled: Some(snapshot.request_log.enabled),
184        }),
185    }
186}
187
188// ── Helpers ──────────────────────────────────────────────────────────────
189
190fn now_secs() -> u64 {
191    SystemTime::now()
192        .duration_since(UNIX_EPOCH)
193        .unwrap_or_default()
194        .as_secs()
195}
196
197// ── Tests ────────────────────────────────────────────────────────────────
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::server_config::{AuthSection, RateLimitSection, RequestLogSection};
203    use std::fs;
204
205    fn sample_snapshot() -> ConfigSnapshot {
206        ConfigSnapshot {
207            rate_limit: RateLimitSection {
208                max_requests: 200,
209                window_secs: 120,
210                enabled: true,
211            },
212            request_log: RequestLogSection {
213                capacity: 500,
214                enabled: false,
215            },
216            auth: AuthSection {
217                enabled: true,
218                active_keys: 2,
219                total_keys: 3,
220            },
221        }
222    }
223
224    fn temp_path(name: &str) -> PathBuf {
225        let dir = std::env::temp_dir();
226        dir.join(format!("axon_test_{name}_{}.json", std::process::id()))
227    }
228
229    #[test]
230    fn save_and_load_roundtrip() {
231        let path = temp_path("roundtrip");
232        let snap = sample_snapshot();
233
234        let result = save(&snap, &path, "0.30.0-test");
235        assert!(result.success);
236        assert_eq!(result.save_count, 1);
237
238        let loaded = load(&path).unwrap();
239        assert_eq!(loaded.version, 1);
240        assert_eq!(loaded.axon_version, "0.30.0-test");
241        assert_eq!(loaded.save_count, 1);
242        assert_eq!(loaded.config.rate_limit.max_requests, 200);
243        assert_eq!(loaded.config.rate_limit.window_secs, 120);
244        assert_eq!(loaded.config.request_log.capacity, 500);
245        assert!(!loaded.config.request_log.enabled);
246
247        fs::remove_file(&path).ok();
248    }
249
250    #[test]
251    fn save_increments_count() {
252        let path = temp_path("increment");
253        let snap = sample_snapshot();
254
255        let r1 = save(&snap, &path, "v1");
256        assert_eq!(r1.save_count, 1);
257
258        let r2 = save(&snap, &path, "v1");
259        assert_eq!(r2.save_count, 2);
260
261        let r3 = save(&snap, &path, "v1");
262        assert_eq!(r3.save_count, 3);
263
264        let loaded = load(&path).unwrap();
265        assert_eq!(loaded.save_count, 3);
266
267        fs::remove_file(&path).ok();
268    }
269
270    #[test]
271    fn load_nonexistent_file() {
272        let path = temp_path("nonexistent_98765");
273        let result = load(&path);
274        assert!(result.is_err());
275        assert!(result.unwrap_err().contains("read error"));
276    }
277
278    #[test]
279    fn load_invalid_json() {
280        let path = temp_path("invalid");
281        fs::write(&path, "not json at all").unwrap();
282
283        let result = load(&path);
284        assert!(result.is_err());
285        assert!(result.unwrap_err().contains("parse error"));
286
287        fs::remove_file(&path).ok();
288    }
289
290    #[test]
291    fn load_wrong_version() {
292        let path = temp_path("wrong_ver");
293        let json = serde_json::json!({
294            "version": 99,
295            "saved_at": 0,
296            "axon_version": "test",
297            "save_count": 1,
298            "config": {
299                "rate_limit": { "max_requests": 100, "window_secs": 60, "enabled": true },
300                "request_log": { "capacity": 1000, "enabled": true },
301                "auth": { "enabled": false, "active_keys": 0, "total_keys": 0 }
302            }
303        });
304        fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
305
306        let result = load(&path);
307        assert!(result.is_err());
308        assert!(result.unwrap_err().contains("unsupported config version"));
309
310        fs::remove_file(&path).ok();
311    }
312
313    #[test]
314    fn exists_and_remove() {
315        let path = temp_path("exists_test");
316        assert!(!exists(&path));
317
318        let snap = sample_snapshot();
319        save(&snap, &path, "test");
320        assert!(exists(&path));
321
322        assert!(remove(&path));
323        assert!(!exists(&path));
324        assert!(!remove(&path)); // already gone
325    }
326
327    #[test]
328    fn snapshot_to_update_conversion() {
329        let snap = sample_snapshot();
330        let update = snapshot_to_update(&snap);
331
332        let rl = update.rate_limit.unwrap();
333        assert_eq!(rl.max_requests, Some(200));
334        assert_eq!(rl.window_secs, Some(120));
335        assert_eq!(rl.enabled, Some(true));
336
337        let log = update.request_log.unwrap();
338        assert_eq!(log.capacity, Some(500));
339        assert_eq!(log.enabled, Some(false));
340    }
341
342    #[test]
343    fn resolve_path_default() {
344        let p = resolve_path(None);
345        assert_eq!(p, PathBuf::from(DEFAULT_CONFIG_FILE));
346    }
347
348    #[test]
349    fn resolve_path_custom() {
350        let p = resolve_path(Some("/tmp/my-config.json"));
351        assert_eq!(p, PathBuf::from("/tmp/my-config.json"));
352    }
353
354    #[test]
355    fn persisted_config_serializes() {
356        let snap = sample_snapshot();
357        let persisted = PersistedConfig {
358            version: 1,
359            saved_at: 1700000000,
360            axon_version: "0.30.0".into(),
361            save_count: 5,
362            config: snap,
363        };
364
365        let json = serde_json::to_value(&persisted).unwrap();
366        assert_eq!(json["version"], 1);
367        assert_eq!(json["save_count"], 5);
368        assert_eq!(json["config"]["rate_limit"]["max_requests"], 200);
369    }
370
371    #[test]
372    fn save_result_serializes() {
373        let result = SaveResult {
374            success: true,
375            path: "/tmp/test.json".into(),
376            save_count: 3,
377            error: None,
378        };
379        let json = serde_json::to_value(&result).unwrap();
380        assert_eq!(json["success"], true);
381        assert_eq!(json["save_count"], 3);
382    }
383}