holodeck-simctl-core 0.9.0

Core simctl automation (simulator lifecycle, recording, screenshots) for the holodeck-simctl CLI/TUI
Documentation
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::config_resolver::ConfigResolver;
use crate::models::{Platform, ScreenshotType, ThemeName, VideoCodec};

fn default_screenshots_directory() -> String {
    "~/Desktop".to_string()
}

fn default_poll_interval_seconds() -> f64 {
    2.0
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
    #[serde(default)]
    pub default_platform: Option<Platform>,
    #[serde(default = "default_screenshots_directory")]
    pub screenshots_directory: String,
    #[serde(default)]
    pub video_codec: VideoCodec,
    #[serde(default)]
    pub screenshot_type: ScreenshotType,
    #[serde(default = "default_poll_interval_seconds")]
    pub poll_interval_seconds: f64,
    #[serde(default)]
    pub theme: ThemeName,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            default_platform: None,
            screenshots_directory: default_screenshots_directory(),
            video_codec: VideoCodec::default(),
            screenshot_type: ScreenshotType::default(),
            poll_interval_seconds: default_poll_interval_seconds(),
            theme: ThemeName::default(),
        }
    }
}

impl Config {
    pub fn resolved_screenshots_directory(&self) -> PathBuf {
        PathBuf::from(shellexpand::tilde(&self.screenshots_directory).into_owned())
    }
}

const CONFIG_FILE_NAME: &str = "config.json";

pub struct ConfigLoader {
    path: PathBuf,
}

impl ConfigLoader {
    pub fn new(resolver: &ConfigResolver) -> Self {
        Self { path: resolver.file(CONFIG_FILE_NAME) }
    }

    pub fn load(&self) -> Result<Config, ConfigLoadError> {
        let data = match std::fs::read(&self.path) {
            Ok(data) => data,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
            Err(err) => return Err(ConfigLoadError::Io(err)),
        };
        serde_json::from_slice(&data).map_err(ConfigLoadError::Json)
    }

    pub fn load_or_default(&self) -> Config {
        self.load().unwrap_or_default()
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ConfigLoadError {
    #[error(transparent)]
    Io(std::io::Error),
    #[error(transparent)]
    Json(serde_json::Error),
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn missing_file_returns_default() {
        let dir = tempfile::tempdir().unwrap();
        let resolver = ConfigResolver::mock(dir.path());
        let config = ConfigLoader::new(&resolver).load().unwrap();
        assert_eq!(config, Config::default());
    }

    #[test]
    fn missing_fields_fall_back_to_defaults() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(CONFIG_FILE_NAME), r#"{"videoCodec":"hevc"}"#).unwrap();
        let resolver = ConfigResolver::mock(dir.path());
        let config = ConfigLoader::new(&resolver).load().unwrap();
        assert_eq!(config.video_codec, VideoCodec::Hevc);
        assert_eq!(config.screenshots_directory, "~/Desktop");
    }

    #[test]
    fn malformed_json_errors() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(CONFIG_FILE_NAME), "not json").unwrap();
        let resolver = ConfigResolver::mock(dir.path());
        assert!(ConfigLoader::new(&resolver).load().is_err());
    }

    #[test]
    fn load_or_default_swallows_errors() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(CONFIG_FILE_NAME), "not json").unwrap();
        let resolver = ConfigResolver::mock(dir.path());
        assert_eq!(ConfigLoader::new(&resolver).load_or_default(), Config::default());
    }

    #[test]
    fn resolves_tilde_in_screenshots_directory() {
        let config = Config::default();
        assert!(!config.resolved_screenshots_directory().to_string_lossy().contains('~'));
    }

    #[test]
    fn theme_defaults_to_default_plus_and_can_be_overridden() {
        let dir = tempfile::tempdir().unwrap();
        let resolver = ConfigResolver::mock(dir.path());
        let default_config = ConfigLoader::new(&resolver).load().unwrap();
        assert_eq!(default_config.theme, ThemeName::DefaultPlus);

        std::fs::write(dir.path().join(CONFIG_FILE_NAME), r#"{"theme":"ansi"}"#).unwrap();
        let overridden = ConfigLoader::new(&resolver).load().unwrap();
        assert_eq!(overridden.theme, ThemeName::Ansi);
    }
}