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 #[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 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 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}