Skip to main content

camgrab_core/config/
mod.rs

1//! Configuration module for camgrab
2//!
3//! Provides config loading/saving (TOML), camera lookup, and upsert operations.
4
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9use crate::camera::{AuthMethod, Protocol, StreamType, Transport};
10
11/// Camera configuration structure
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct CameraConfig {
14    /// Camera name/identifier
15    pub name: String,
16    /// Hostname or IP address
17    pub host: String,
18    /// RTSP port (default: 554)
19    #[serde(default)]
20    pub port: Option<u16>,
21    /// Username for authentication
22    #[serde(default)]
23    pub username: Option<String>,
24    /// Password for authentication
25    #[serde(default)]
26    pub password: Option<String>,
27    /// RTSP protocol (default: rtsp)
28    #[serde(default)]
29    pub protocol: Option<Protocol>,
30    /// Network transport (default: tcp)
31    #[serde(default)]
32    pub transport: Option<Transport>,
33    /// Stream type (default: main)
34    #[serde(default)]
35    pub stream_type: Option<StreamType>,
36    /// Custom stream path override
37    #[serde(default)]
38    pub custom_path: Option<String>,
39    /// Whether to enable audio (default: false)
40    #[serde(default)]
41    pub audio_enabled: Option<bool>,
42    /// Authentication method (default: auto)
43    #[serde(default)]
44    pub auth_method: Option<AuthMethod>,
45    /// Connection timeout in seconds (default: 10)
46    #[serde(default)]
47    pub timeout_secs: Option<u64>,
48}
49
50/// Application configuration wrapping multiple cameras
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
52pub struct AppConfig {
53    /// List of configured cameras
54    #[serde(default)]
55    pub cameras: Vec<CameraConfig>,
56}
57
58impl AppConfig {
59    /// Creates a new empty config
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Returns the number of cameras
65    pub fn len(&self) -> usize {
66        self.cameras.len()
67    }
68
69    /// Returns true if no cameras configured
70    pub fn is_empty(&self) -> bool {
71        self.cameras.is_empty()
72    }
73}
74
75/// Configuration errors
76#[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
88/// Returns the default config path: ~/.config/camgrab/config.toml
89pub fn default_config_path() -> PathBuf {
90    dirs::config_dir()
91        .unwrap_or_else(|| PathBuf::from("."))
92        .join("camgrab")
93        .join("config.toml")
94}
95
96/// Loads configuration from a TOML file.
97/// Returns empty AppConfig if file doesn't exist (not an error).
98pub 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
121/// Saves configuration to a TOML file with 0o600 permissions.
122/// Creates parent directories if they don't exist.
123pub 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
149/// Inserts or updates a camera in the config.
150/// Returns true if camera was newly added, false if updated.
151pub 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
161/// Finds a camera by name.
162pub fn find_camera<'a>(config: &'a AppConfig, name: &str) -> Option<&'a CameraConfig> {
163    config.cameras.iter().find(|c| c.name == name)
164}
165
166/// Removes a camera by name.
167/// Returns true if camera was found and removed.
168pub 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}