1use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9use crate::camera::{AuthMethod, Protocol, StreamType, Transport};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct CameraConfig {
14 pub name: String,
16 pub host: String,
18 #[serde(default)]
20 pub port: Option<u16>,
21 #[serde(default)]
23 pub username: Option<String>,
24 #[serde(default)]
26 pub password: Option<String>,
27 #[serde(default)]
29 pub protocol: Option<Protocol>,
30 #[serde(default)]
32 pub transport: Option<Transport>,
33 #[serde(default)]
35 pub stream_type: Option<StreamType>,
36 #[serde(default)]
38 pub custom_path: Option<String>,
39 #[serde(default)]
41 pub audio_enabled: Option<bool>,
42 #[serde(default)]
44 pub auth_method: Option<AuthMethod>,
45 #[serde(default)]
47 pub timeout_secs: Option<u64>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
52pub struct AppConfig {
53 #[serde(default)]
55 pub cameras: Vec<CameraConfig>,
56}
57
58impl AppConfig {
59 pub fn new() -> Self {
61 Self::default()
62 }
63
64 pub fn len(&self) -> usize {
66 self.cameras.len()
67 }
68
69 pub fn is_empty(&self) -> bool {
71 self.cameras.is_empty()
72 }
73}
74
75#[derive(Debug, Error)]
77pub enum ConfigError {
78 #[error("I/O error accessing {0}: {1}")]
79 IoError(PathBuf, #[source] std::io::Error),
80
81 #[error("Failed to parse config from {0}: {1}")]
82 ParseError(PathBuf, String),
83
84 #[error("Failed to serialize config: {0}")]
85 SerializeError(String),
86}
87
88pub fn default_config_path() -> PathBuf {
90 dirs::config_dir()
91 .unwrap_or_else(|| PathBuf::from("."))
92 .join("camgrab")
93 .join("config.toml")
94}
95
96pub fn load(path: &Path) -> Result<AppConfig, ConfigError> {
99 if !path.exists() {
100 tracing::debug!(
101 "Config file not found at {}, returning empty config",
102 path.display()
103 );
104 return Ok(AppConfig::default());
105 }
106
107 let contents =
108 std::fs::read_to_string(path).map_err(|e| ConfigError::IoError(path.to_path_buf(), e))?;
109
110 let config: AppConfig = toml::from_str(&contents)
111 .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
112
113 tracing::debug!(
114 "Loaded config with {} camera(s) from {}",
115 config.len(),
116 path.display()
117 );
118 Ok(config)
119}
120
121pub fn save(path: &Path, config: &AppConfig) -> Result<(), ConfigError> {
124 if let Some(parent) = path.parent() {
125 std::fs::create_dir_all(parent)
126 .map_err(|e| ConfigError::IoError(parent.to_path_buf(), e))?;
127 }
128
129 let contents =
130 toml::to_string_pretty(config).map_err(|e| ConfigError::SerializeError(e.to_string()))?;
131
132 std::fs::write(path, &contents).map_err(|e| ConfigError::IoError(path.to_path_buf(), e))?;
133
134 #[cfg(unix)]
135 {
136 use std::os::unix::fs::PermissionsExt;
137 let perms = std::fs::Permissions::from_mode(0o600);
138 let _ = std::fs::set_permissions(path, perms);
139 }
140
141 tracing::debug!(
142 "Saved config with {} camera(s) to {}",
143 config.len(),
144 path.display()
145 );
146 Ok(())
147}
148
149pub fn upsert_camera(config: &mut AppConfig, camera: CameraConfig) -> bool {
152 if let Some(existing) = config.cameras.iter_mut().find(|c| c.name == camera.name) {
153 *existing = camera;
154 false
155 } else {
156 config.cameras.push(camera);
157 true
158 }
159}
160
161pub fn find_camera<'a>(config: &'a AppConfig, name: &str) -> Option<&'a CameraConfig> {
163 config.cameras.iter().find(|c| c.name == name)
164}
165
166pub fn remove_camera(config: &mut AppConfig, name: &str) -> bool {
169 let len = config.cameras.len();
170 config.cameras.retain(|c| c.name != name);
171 config.cameras.len() < len
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use tempfile::TempDir;
178
179 fn sample_camera(name: &str) -> CameraConfig {
180 CameraConfig {
181 name: name.to_string(),
182 host: "192.168.1.100".to_string(),
183 port: Some(554),
184 username: Some("admin".to_string()),
185 password: Some("secret".to_string()),
186 protocol: Some(Protocol::Rtsp),
187 transport: Some(Transport::Tcp),
188 stream_type: None,
189 custom_path: None,
190 audio_enabled: Some(false),
191 auth_method: Some(AuthMethod::Auto),
192 timeout_secs: Some(10),
193 }
194 }
195
196 #[test]
197 fn test_default_config_path() {
198 let path = default_config_path();
199 assert!(path.ends_with("camgrab/config.toml"));
200 }
201
202 #[test]
203 fn test_load_nonexistent() {
204 let path = Path::new("/tmp/nonexistent-camgrab-test/config.toml");
205 let config = load(path).unwrap();
206 assert!(config.is_empty());
207 }
208
209 #[test]
210 fn test_save_and_load_roundtrip() {
211 let dir = TempDir::new().unwrap();
212 let path = dir.path().join("config.toml");
213
214 let mut config = AppConfig::new();
215 config.cameras.push(sample_camera("front_door"));
216 config.cameras.push(sample_camera("backyard"));
217
218 save(&path, &config).unwrap();
219 let loaded = load(&path).unwrap();
220
221 assert_eq!(loaded.len(), 2);
222 assert_eq!(loaded.cameras[0].name, "front_door");
223 assert_eq!(loaded.cameras[1].name, "backyard");
224 assert_eq!(loaded.cameras[0].host, "192.168.1.100");
225 assert_eq!(loaded.cameras[0].password, Some("secret".to_string()));
226 }
227
228 #[test]
229 fn test_upsert_new_camera() {
230 let mut config = AppConfig::new();
231 let is_new = upsert_camera(&mut config, sample_camera("test"));
232 assert!(is_new);
233 assert_eq!(config.len(), 1);
234 }
235
236 #[test]
237 fn test_upsert_existing_camera() {
238 let mut config = AppConfig::new();
239 upsert_camera(&mut config, sample_camera("test"));
240
241 let mut updated = sample_camera("test");
242 updated.host = "10.0.0.1".to_string();
243 let is_new = upsert_camera(&mut config, updated);
244
245 assert!(!is_new);
246 assert_eq!(config.len(), 1);
247 assert_eq!(config.cameras[0].host, "10.0.0.1");
248 }
249
250 #[test]
251 fn test_find_camera() {
252 let mut config = AppConfig::new();
253 config.cameras.push(sample_camera("front_door"));
254 config.cameras.push(sample_camera("backyard"));
255
256 assert!(find_camera(&config, "front_door").is_some());
257 assert!(find_camera(&config, "backyard").is_some());
258 assert!(find_camera(&config, "nonexistent").is_none());
259 }
260
261 #[test]
262 fn test_remove_camera() {
263 let mut config = AppConfig::new();
264 config.cameras.push(sample_camera("front_door"));
265 config.cameras.push(sample_camera("backyard"));
266
267 assert!(remove_camera(&mut config, "front_door"));
268 assert_eq!(config.len(), 1);
269 assert_eq!(config.cameras[0].name, "backyard");
270
271 assert!(!remove_camera(&mut config, "nonexistent"));
272 assert_eq!(config.len(), 1);
273 }
274
275 #[test]
276 fn test_save_creates_parent_dirs() {
277 let dir = TempDir::new().unwrap();
278 let path = dir.path().join("nested").join("deep").join("config.toml");
279
280 let config = AppConfig::new();
281 save(&path, &config).unwrap();
282 assert!(path.exists());
283 }
284
285 #[test]
286 fn test_toml_serialization_readable() {
287 let mut config = AppConfig::new();
288 config.cameras.push(sample_camera("test_cam"));
289
290 let toml_str = toml::to_string_pretty(&config).unwrap();
291 assert!(toml_str.contains("name = \"test_cam\""));
292 assert!(toml_str.contains("host = \"192.168.1.100\""));
293 assert!(toml_str.contains("[[cameras]]"));
294 }
295
296 #[test]
297 fn test_app_config_default() {
298 let config = AppConfig::default();
299 assert!(config.is_empty());
300 assert_eq!(config.len(), 0);
301 }
302}