Skip to main content

rustriff_lib/infrastructure/persistence/
json_amp_config_repository.rs

1use crate::domain::dto::amp_config_dto::AmpConfigDto;
2use crate::domain::dto::channel_dto::ChannelDto;
3use crate::infrastructure::persistence::amp_config_persistence_trait::AmpConfigPersistence;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::io::Write;
7use std::path::PathBuf;
8
9/// File-based amp-config repository backed by a single JSON document.
10///
11/// The repository stores one full amplifier snapshot at `config_path`. It is
12/// intentionally simple: every save overwrites the entire file and every load
13/// reads the whole document into memory.
14///
15/// This implementation is useful while the configuration remains relatively
16/// small and the project does not yet need querying, concurrency control, or
17/// schema migrations provided by a database.
18pub struct JsonFileAmpConfigRepository {
19    config_path: PathBuf,
20}
21
22/// Persistence-only representation of the amplifier configuration.
23///
24/// This struct deliberately differs from [`AmpConfigDto`]: it excludes
25/// `is_active`, because loopback state is considered runtime-only and the app
26/// should always restart in an "off" state.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28struct PersistedAmpConfig {
29    master_volume: f32,
30    channels: Vec<ChannelDto>,
31    current_channel: u32,
32}
33
34impl From<&AmpConfigDto> for PersistedAmpConfig {
35    fn from(config: &AmpConfigDto) -> Self {
36        Self {
37            master_volume: config.master_volume,
38            channels: config.channels.clone(),
39            current_channel: config.current_channel,
40        }
41    }
42}
43
44impl From<PersistedAmpConfig> for AmpConfigDto {
45    fn from(config: PersistedAmpConfig) -> Self {
46        Self {
47            master_volume: config.master_volume,
48            is_active: false,
49            channels: config.channels,
50            current_channel: config.current_channel,
51        }
52    }
53}
54
55impl JsonFileAmpConfigRepository {
56    /// Creates a JSON repository that reads from and writes to `config_path`.
57    ///
58    /// The path is not validated eagerly. Missing parent directories are
59    /// created on the first successful `save` call.
60    pub fn new(config_path: PathBuf) -> Self {
61        Self { config_path }
62    }
63}
64
65impl AmpConfigPersistence for JsonFileAmpConfigRepository {
66    /// Loads and deserializes the persisted JSON file.
67    ///
68    /// Behavior summary:
69    /// - missing file -> `Ok(None)`
70    /// - unreadable file -> `Err(String)`
71    /// - invalid JSON -> `Err(String)`
72    /// - valid JSON -> `Ok(Some(AmpConfigDto))`
73    fn load(&self) -> Result<Option<AmpConfigDto>, String> {
74        if !self.config_path.exists() {
75            return Ok(None);
76        }
77
78        let payload = fs::read_to_string(&self.config_path).map_err(|e| {
79            format!(
80                "Failed to read amp config '{}': {e}",
81                self.config_path.display()
82            )
83        })?;
84
85        let persisted = serde_json::from_str::<PersistedAmpConfig>(&payload).map_err(|e| {
86            format!(
87                "Failed to parse amp config JSON '{}': {e}",
88                self.config_path.display()
89            )
90        })?;
91
92        Ok(Some(AmpConfigDto::from(persisted)))
93    }
94
95    /// Serializes the supplied config snapshot and writes it to disk **atomically**.
96    ///
97    /// The write strategy is:
98    /// 1. Serialize the snapshot to JSON.
99    /// 2. Write the JSON to a sibling temporary file (same directory as the target,
100    ///    so the subsequent rename stays on the same filesystem/volume).
101    /// 3. `sync_all` the temporary file so the bytes are flushed to the OS.
102    /// 4. `rename` the temporary file over the target path. On all major OSes this
103    ///    rename is atomic at the filesystem level, so a crash between steps 2-3
104    ///    leaves the old file intact and a crash between steps 3-4 leaves a
105    ///    harmless temporary file that is cleaned up on the next successful save.
106    ///
107    /// Parent directories are created automatically when necessary. The JSON is
108    /// formatted with `to_string_pretty` so it remains reasonably human-readable
109    /// during development and debugging.
110    fn save(&self, config: &AmpConfigDto) -> Result<(), String> {
111        let parent = self
112            .config_path
113            .parent()
114            .filter(|p| !p.as_os_str().is_empty());
115
116        if let Some(dir) = parent {
117            fs::create_dir_all(dir).map_err(|e| {
118                format!("Failed to create config directory '{}': {e}", dir.display())
119            })?;
120        }
121
122        let persisted = PersistedAmpConfig::from(config);
123        let json = serde_json::to_string_pretty(&persisted)
124            .map_err(|e| format!("Failed to serialize amp config: {e}"))?;
125
126        // Build a temp-file path in the same directory so rename is always
127        // same-volume (cross-device rename would fail with EXDEV on Unix).
128        let tmp_path = self.config_path.with_extension("json.tmp");
129
130        {
131            let mut tmp_file = fs::File::create(&tmp_path)
132                .map_err(|e| format!("Failed to create temp file '{}': {e}", tmp_path.display()))?;
133
134            tmp_file
135                .write_all(json.as_bytes())
136                .map_err(|e| format!("Failed to write temp file '{}': {e}", tmp_path.display()))?;
137
138            // Flush kernel buffers to disk before we rename.
139            tmp_file
140                .sync_all()
141                .map_err(|e| format!("Failed to sync temp file '{}': {e}", tmp_path.display()))?;
142        } // file handle is dropped (closed) here before rename
143
144        fs::rename(&tmp_path, &self.config_path).map_err(|e| {
145            format!(
146                "Failed to atomically replace config '{}': {e}",
147                self.config_path.display()
148            )
149        })
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use std::time::{SystemTime, UNIX_EPOCH};
157
158    fn unique_test_path() -> PathBuf {
159        let nanos = SystemTime::now()
160            .duration_since(UNIX_EPOCH)
161            .expect("time should be monotonic")
162            .as_nanos();
163        std::env::temp_dir().join(format!("rustriff-amp-config-{nanos}.json"))
164    }
165
166    #[test]
167    fn save_leaves_no_tmp_file_after_success() {
168        let path = unique_test_path();
169        let repo = JsonFileAmpConfigRepository::new(path.clone());
170
171        let config = AmpConfigDto {
172            master_volume: 0.5,
173            is_active: false,
174            channels: Vec::new(),
175            current_channel: 0,
176        };
177
178        repo.save(&config).expect("save should succeed");
179
180        let tmp = path.with_extension("json.tmp");
181        assert!(
182            !tmp.exists(),
183            "temp file should be gone after a successful save"
184        );
185
186        let _ = fs::remove_file(path);
187    }
188
189    #[test]
190    fn save_then_load_roundtrip_succeeds() {
191        let path = unique_test_path();
192        let repo = JsonFileAmpConfigRepository::new(path.clone());
193
194        let config = AmpConfigDto {
195            master_volume: 0.8,
196            is_active: true,
197            channels: Vec::new(),
198            current_channel: 0,
199        };
200
201        repo.save(&config).expect("save should succeed");
202        let loaded = repo
203            .load()
204            .expect("load should succeed")
205            .expect("config should exist");
206        let raw_json = fs::read_to_string(path.clone()).expect("saved file should be readable");
207
208        assert!((loaded.master_volume - config.master_volume).abs() < 1e-6);
209        assert_eq!(loaded.current_channel, config.current_channel);
210        assert!(!loaded.is_active);
211        assert!(!raw_json.contains("is_active"));
212
213        let _ = fs::remove_file(path);
214    }
215
216    #[test]
217    fn load_returns_none_when_file_missing() {
218        let path = unique_test_path();
219        let repo = JsonFileAmpConfigRepository::new(path);
220
221        let loaded = repo.load().expect("load should succeed");
222        assert!(loaded.is_none());
223    }
224}