Skip to main content

synheart_sensor_agent/
config.rs

1//! Configuration for the Synheart Sensor Agent.
2//!
3//! The main type is [`Config`], which controls window duration, input sources,
4//! export paths, and session boundaries. Use [`Config::load`] / [`Config::save`]
5//! for persistence.
6
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use std::time::Duration;
10
11/// Main configuration for the sensor agent.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Config {
14    /// Duration of each collection window
15    #[serde(with = "duration_serde")]
16    pub window_duration: Duration,
17
18    /// Which input sources to capture
19    pub sources: SourceConfig,
20
21    /// Path for exporting HSI snapshots
22    pub export_path: PathBuf,
23
24    /// Path for storing state and transparency logs
25    pub data_path: PathBuf,
26
27    /// Whether collection is currently paused
28    pub paused: bool,
29
30    /// Gap threshold for session boundaries (in seconds)
31    pub session_gap_threshold_secs: u64,
32}
33
34impl Default for Config {
35    fn default() -> Self {
36        let data_dir = dirs::data_local_dir()
37            .unwrap_or_else(|| PathBuf::from("."))
38            .join("synheart-sensor-agent");
39
40        Self {
41            window_duration: Duration::from_secs(10),
42            sources: SourceConfig::default(),
43            export_path: data_dir.join("exports"),
44            data_path: data_dir,
45            paused: false,
46            session_gap_threshold_secs: 300, // 5 minutes
47        }
48    }
49}
50
51impl Config {
52    /// Load configuration from the default location.
53    pub fn load() -> Result<Self, ConfigError> {
54        let config_path = Self::config_path();
55
56        if config_path.exists() {
57            let content = std::fs::read_to_string(&config_path)
58                .map_err(|e| ConfigError::IoError(e.to_string()))?;
59            let config: Config = serde_json::from_str(&content)
60                .map_err(|e| ConfigError::ParseError(e.to_string()))?;
61            Ok(config)
62        } else {
63            Ok(Self::default())
64        }
65    }
66
67    /// Save configuration to the default location.
68    pub fn save(&self) -> Result<(), ConfigError> {
69        let config_path = Self::config_path();
70
71        // Ensure parent directory exists
72        if let Some(parent) = config_path.parent() {
73            std::fs::create_dir_all(parent).map_err(|e| ConfigError::IoError(e.to_string()))?;
74        }
75
76        let content = serde_json::to_string_pretty(self)
77            .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
78
79        std::fs::write(&config_path, content).map_err(|e| ConfigError::IoError(e.to_string()))?;
80
81        Ok(())
82    }
83
84    /// Get the path to the configuration file.
85    pub fn config_path() -> PathBuf {
86        dirs::config_dir()
87            .unwrap_or_else(|| PathBuf::from("."))
88            .join("synheart-sensor-agent")
89            .join("config.json")
90    }
91
92    /// Ensure all required directories exist.
93    pub fn ensure_directories(&self) -> Result<(), ConfigError> {
94        std::fs::create_dir_all(&self.export_path)
95            .map_err(|e| ConfigError::IoError(e.to_string()))?;
96        std::fs::create_dir_all(&self.data_path)
97            .map_err(|e| ConfigError::IoError(e.to_string()))?;
98        Ok(())
99    }
100}
101
102/// Configuration for which input sources to capture.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct SourceConfig {
105    /// Capture keyboard events.
106    pub keyboard: bool,
107    /// Capture mouse events.
108    pub mouse: bool,
109}
110
111impl Default for SourceConfig {
112    fn default() -> Self {
113        Self {
114            keyboard: true,
115            mouse: true,
116        }
117    }
118}
119
120impl SourceConfig {
121    /// Parse source configuration from a comma-separated string.
122    pub fn from_csv(s: &str) -> Self {
123        let sources: Vec<String> = s.split(',').map(|s| s.trim().to_lowercase()).collect();
124
125        Self {
126            keyboard: sources.iter().any(|s| s == "keyboard" || s == "all"),
127            mouse: sources.iter().any(|s| s == "mouse" || s == "all"),
128        }
129    }
130
131    /// Check if at least one source is enabled.
132    pub fn any_enabled(&self) -> bool {
133        self.keyboard || self.mouse
134    }
135}
136
137/// Configuration errors.
138#[derive(Debug)]
139pub enum ConfigError {
140    /// File system I/O error (e.g., config file not found).
141    IoError(String),
142    /// Failed to parse the configuration file (invalid TOML/JSON).
143    ParseError(String),
144    /// Failed to serialize configuration back to disk.
145    SerializeError(String),
146}
147
148impl std::fmt::Display for ConfigError {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        match self {
151            ConfigError::IoError(e) => write!(f, "IO error: {e}"),
152            ConfigError::ParseError(e) => write!(f, "Parse error: {e}"),
153            ConfigError::SerializeError(e) => write!(f, "Serialize error: {e}"),
154        }
155    }
156}
157
158impl std::error::Error for ConfigError {}
159
160/// Serde support for Duration.
161mod duration_serde {
162    use serde::{Deserialize, Deserializer, Serialize, Serializer};
163    use std::time::Duration;
164
165    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
166    where
167        S: Serializer,
168    {
169        duration.as_secs().serialize(serializer)
170    }
171
172    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
173    where
174        D: Deserializer<'de>,
175    {
176        let secs = u64::deserialize(deserializer)?;
177        Ok(Duration::from_secs(secs))
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_source_config_parsing() {
187        let config = SourceConfig::from_csv("keyboard,mouse");
188        assert!(config.keyboard);
189        assert!(config.mouse);
190
191        let config = SourceConfig::from_csv("keyboard");
192        assert!(config.keyboard);
193        assert!(!config.mouse);
194
195        let config = SourceConfig::from_csv("all");
196        assert!(config.keyboard);
197        assert!(config.mouse);
198    }
199
200    #[test]
201    fn test_default_config() {
202        let config = Config::default();
203        assert_eq!(config.window_duration, Duration::from_secs(10));
204        assert!(config.sources.keyboard);
205        assert!(config.sources.mouse);
206        assert!(!config.paused);
207    }
208}