Skip to main content

hblank_cli/
config.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4};
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9pub const CONFIG_PATH: &str = ".hblank/config.toml";
10
11#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
12#[serde(default, deny_unknown_fields)]
13pub struct Config {
14    pub fixtures: Vec<String>,
15    pub ignore: Vec<String>,
16    pub theme_hook: Option<String>,
17    pub window: WindowConfig,
18}
19
20impl Default for Config {
21    fn default() -> Self {
22        Self {
23            fixtures: vec!["src/**/*.hblank.rs".to_owned()],
24            ignore: vec!["target/**".to_owned(), ".hblank/**".to_owned()],
25            theme_hook: None,
26            window: WindowConfig::default(),
27        }
28    }
29}
30
31#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
32#[serde(default, deny_unknown_fields)]
33pub struct WindowConfig {
34    pub title: String,
35    pub width: u32,
36    pub height: u32,
37}
38
39impl Default for WindowConfig {
40    fn default() -> Self {
41        Self {
42            title: "Hblank".to_owned(),
43            width: 1440,
44            height: 900,
45        }
46    }
47}
48
49impl Config {
50    /// Creates the conventional configuration for a Rust package.
51    #[must_use]
52    pub fn for_project(package_name: &str) -> Self {
53        let mut config = Self::default();
54        config.window.title = format!("{package_name} ยท Hblank");
55        config
56    }
57
58    /// Loads and validates configuration from the project's Hblank directory.
59    ///
60    /// # Errors
61    /// Returns an error when the file cannot be read, parsed, or validated.
62    pub fn load(project_root: &Path) -> Result<Self, ConfigError> {
63        let path = project_root.join(CONFIG_PATH);
64        let source = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
65            path: path.clone(),
66            source,
67        })?;
68        let config = toml::from_str::<Self>(&source).map_err(|source| ConfigError::Parse {
69            path: path.clone(),
70            source,
71        })?;
72        config.validate()?;
73        Ok(config)
74    }
75
76    /// Serializes validated configuration as TOML.
77    ///
78    /// # Errors
79    /// Returns an error when configuration is invalid or serialization fails.
80    pub fn to_toml(&self) -> Result<String, ConfigError> {
81        self.validate()?;
82        toml::to_string_pretty(self).map_err(ConfigError::Serialize)
83    }
84
85    fn validate(&self) -> Result<(), ConfigError> {
86        if self.fixtures.is_empty() || self.fixtures.iter().any(String::is_empty) {
87            return Err(ConfigError::NoFixtureFilePatterns);
88        }
89        if self
90            .theme_hook
91            .as_ref()
92            .is_some_and(|hook| hook.trim().is_empty())
93        {
94            return Err(ConfigError::EmptyThemeHook);
95        }
96        if self.window.title.trim().is_empty() {
97            return Err(ConfigError::EmptyWindowTitle);
98        }
99        if self.window.width == 0 || self.window.height == 0 {
100            return Err(ConfigError::InvalidWindowSize {
101                width: self.window.width,
102                height: self.window.height,
103            });
104        }
105        Ok(())
106    }
107}
108
109#[derive(Debug, Error)]
110pub enum ConfigError {
111    #[error("could not read Hblank config at {path}: {source}")]
112    Read {
113        path: PathBuf,
114        source: std::io::Error,
115    },
116    #[error("could not parse Hblank config at {path}: {source}")]
117    Parse {
118        path: PathBuf,
119        source: toml::de::Error,
120    },
121    #[error("could not serialize Hblank config: {0}")]
122    Serialize(toml::ser::Error),
123    #[error("Hblank config must include at least one non-empty fixture file pattern")]
124    NoFixtureFilePatterns,
125    #[error("Hblank theme hook path cannot be empty")]
126    EmptyThemeHook,
127    #[error("Hblank window title cannot be empty")]
128    EmptyWindowTitle,
129    #[error("Hblank window size must be positive, received {width}x{height}")]
130    InvalidWindowSize { width: u32, height: u32 },
131}