//! JSON file–backed `SettingsRepository`. //! //! Path: `/settings.json` //! //! Uses write-then-rename with fsync for crash safety. #![allow( clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap )] use std::io::Write; use std::path::Path; use anyhow::{Context, Result}; use crate::domain::repository::SettingsRepository; use crate::domain::settings::Settings; /// Persists `Settings` as pretty-printed JSON at `/settings.json`. #[derive(Debug, Clone, Default)] pub struct JsonSettingsRepository; impl JsonSettingsRepository { /// Create a new repository instance. pub fn new() -> Self { Self } } impl SettingsRepository for JsonSettingsRepository { fn load(&self, base_dir: &Path) -> Result { let path = base_dir.join("settings.json"); match std::fs::read_to_string(&path) { Ok(s) => serde_json::from_str(&s) .map_err(|e| anyhow::anyhow!("failed to parse settings.json: {e}")), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { tracing::info!("settings.json not found, using defaults"); Ok(Settings::default()) } Err(e) => Err(anyhow::anyhow!("failed to read settings.json: {e}")), } } fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()> { std::fs::create_dir_all(base_dir) .with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?; let path = base_dir.join("settings.json"); let tmp = base_dir.join("settings.json.tmp"); let json = serde_json::to_string_pretty(settings) .context("failed to serialize settings")?; { let mut f = std::fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&tmp) .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; f.write_all(json.as_bytes())?; f.sync_all()?; } std::fs::rename(&tmp, &path) .with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?; if let Some(parent) = path.parent() { if let Ok(d) = std::fs::File::open(parent) { let _ = d.sync_all(); } } tracing::debug!("settings saved to '{}'", path.display()); Ok(()) } }