Skip to main content

holodeck_simctl_core/
config.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5use crate::config_resolver::ConfigResolver;
6use crate::models::{Platform, ScreenshotType, ThemeName, VideoCodec};
7
8fn default_screenshots_directory() -> String {
9    "~/Desktop".to_string()
10}
11
12fn default_poll_interval_seconds() -> f64 {
13    2.0
14}
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct Config {
19    #[serde(default)]
20    pub default_platform: Option<Platform>,
21    #[serde(default = "default_screenshots_directory")]
22    pub screenshots_directory: String,
23    #[serde(default)]
24    pub video_codec: VideoCodec,
25    #[serde(default)]
26    pub screenshot_type: ScreenshotType,
27    #[serde(default = "default_poll_interval_seconds")]
28    pub poll_interval_seconds: f64,
29    #[serde(default)]
30    pub theme: ThemeName,
31}
32
33impl Default for Config {
34    fn default() -> Self {
35        Self {
36            default_platform: None,
37            screenshots_directory: default_screenshots_directory(),
38            video_codec: VideoCodec::default(),
39            screenshot_type: ScreenshotType::default(),
40            poll_interval_seconds: default_poll_interval_seconds(),
41            theme: ThemeName::default(),
42        }
43    }
44}
45
46impl Config {
47    pub fn resolved_screenshots_directory(&self) -> PathBuf {
48        PathBuf::from(shellexpand::tilde(&self.screenshots_directory).into_owned())
49    }
50}
51
52const CONFIG_FILE_NAME: &str = "config.json";
53
54pub struct ConfigLoader {
55    path: PathBuf,
56}
57
58impl ConfigLoader {
59    pub fn new(resolver: &ConfigResolver) -> Self {
60        Self { path: resolver.file(CONFIG_FILE_NAME) }
61    }
62
63    pub fn load(&self) -> Result<Config, ConfigLoadError> {
64        let data = match std::fs::read(&self.path) {
65            Ok(data) => data,
66            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
67            Err(err) => return Err(ConfigLoadError::Io(err)),
68        };
69        serde_json::from_slice(&data).map_err(ConfigLoadError::Json)
70    }
71
72    pub fn load_or_default(&self) -> Config {
73        self.load().unwrap_or_default()
74    }
75}
76
77#[derive(Debug, thiserror::Error)]
78pub enum ConfigLoadError {
79    #[error(transparent)]
80    Io(std::io::Error),
81    #[error(transparent)]
82    Json(serde_json::Error),
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn missing_file_returns_default() {
91        let dir = tempfile::tempdir().unwrap();
92        let resolver = ConfigResolver::mock(dir.path());
93        let config = ConfigLoader::new(&resolver).load().unwrap();
94        assert_eq!(config, Config::default());
95    }
96
97    #[test]
98    fn missing_fields_fall_back_to_defaults() {
99        let dir = tempfile::tempdir().unwrap();
100        std::fs::write(dir.path().join(CONFIG_FILE_NAME), r#"{"videoCodec":"hevc"}"#).unwrap();
101        let resolver = ConfigResolver::mock(dir.path());
102        let config = ConfigLoader::new(&resolver).load().unwrap();
103        assert_eq!(config.video_codec, VideoCodec::Hevc);
104        assert_eq!(config.screenshots_directory, "~/Desktop");
105    }
106
107    #[test]
108    fn malformed_json_errors() {
109        let dir = tempfile::tempdir().unwrap();
110        std::fs::write(dir.path().join(CONFIG_FILE_NAME), "not json").unwrap();
111        let resolver = ConfigResolver::mock(dir.path());
112        assert!(ConfigLoader::new(&resolver).load().is_err());
113    }
114
115    #[test]
116    fn load_or_default_swallows_errors() {
117        let dir = tempfile::tempdir().unwrap();
118        std::fs::write(dir.path().join(CONFIG_FILE_NAME), "not json").unwrap();
119        let resolver = ConfigResolver::mock(dir.path());
120        assert_eq!(ConfigLoader::new(&resolver).load_or_default(), Config::default());
121    }
122
123    #[test]
124    fn resolves_tilde_in_screenshots_directory() {
125        let config = Config::default();
126        assert!(!config.resolved_screenshots_directory().to_string_lossy().contains('~'));
127    }
128
129    #[test]
130    fn theme_defaults_to_default_plus_and_can_be_overridden() {
131        let dir = tempfile::tempdir().unwrap();
132        let resolver = ConfigResolver::mock(dir.path());
133        let default_config = ConfigLoader::new(&resolver).load().unwrap();
134        assert_eq!(default_config.theme, ThemeName::DefaultPlus);
135
136        std::fs::write(dir.path().join(CONFIG_FILE_NAME), r#"{"theme":"ansi"}"#).unwrap();
137        let overridden = ConfigLoader::new(&resolver).load().unwrap();
138        assert_eq!(overridden.theme, ThemeName::Ansi);
139    }
140}