holodeck_simctl_core/
config.rs1use 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}