rustriff_lib/infrastructure/persistence/
json_amp_config_repository.rs1use 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
9pub struct JsonFileAmpConfigRepository {
19 config_path: PathBuf,
20}
21
22#[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 pub fn new(config_path: PathBuf) -> Self {
61 Self { config_path }
62 }
63}
64
65impl AmpConfigPersistence for JsonFileAmpConfigRepository {
66 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 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 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 tmp_file
140 .sync_all()
141 .map_err(|e| format!("Failed to sync temp file '{}': {e}", tmp_path.display()))?;
142 } 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}