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