mdbook_validator/
config.rs

1//! Configuration parsing from book.toml
2//!
3//! Parses [preprocessor.validator] section including validator definitions.
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8use anyhow::Result;
9use tracing::debug;
10
11use crate::error::ValidatorError;
12use serde::Deserialize;
13
14/// Configuration for a single validator
15#[derive(Debug, Clone, Deserialize)]
16pub struct ValidatorConfig {
17    /// Docker image (e.g., "osquery/osquery:5.17.0-ubuntu22.04")
18    pub container: String,
19    /// Path to validator script relative to book root
20    pub script: PathBuf,
21    /// Command to execute content in container (e.g., "sqlite3 -json /tmp/test.db")
22    /// If not set, defaults based on validator type
23    #[serde(default)]
24    pub exec_command: Option<String>,
25}
26
27/// Main preprocessor configuration from book.toml
28#[derive(Debug, Clone, Deserialize, Default)]
29pub struct Config {
30    /// Map of validator name to config
31    #[serde(default)]
32    pub validators: HashMap<String, ValidatorConfig>,
33    /// Stop on first validation failure (default: true)
34    #[serde(default = "default_fail_fast")]
35    pub fail_fast: bool,
36    /// Optional path to fixtures directory - mounted to /fixtures in containers.
37    /// Path must be absolute. Relative paths are resolved from book root.
38    #[serde(default)]
39    pub fixtures_dir: Option<PathBuf>,
40}
41
42const fn default_fail_fast() -> bool {
43    true
44}
45
46impl Config {
47    /// Parse config from mdBook preprocessor context.
48    ///
49    /// # Errors
50    ///
51    /// Returns error if the config section is missing or malformed.
52    pub fn from_context(ctx: &mdbook_preprocessor::PreprocessorContext) -> Result<Self> {
53        // Use the new mdbook 0.5 config API to get preprocessor config
54        let config: Option<Config> = ctx.config.get("preprocessor.validator")?;
55        let config = config.ok_or_else(|| ValidatorError::Config {
56            message: "No [preprocessor.validator] section in book.toml".into(),
57        })?;
58
59        debug!(
60            validators = config.validators.len(),
61            fail_fast = config.fail_fast,
62            fixtures_dir = ?config.fixtures_dir,
63            "Loaded config"
64        );
65
66        for name in config.validators.keys() {
67            debug!(validator = %name, "Registered validator");
68        }
69
70        Ok(config)
71    }
72
73    /// Get validator config by name.
74    ///
75    /// # Errors
76    ///
77    /// Returns error if the validator is not defined.
78    pub fn get_validator(&self, name: &str) -> Result<&ValidatorConfig> {
79        self.validators.get(name).ok_or_else(|| {
80            ValidatorError::UnknownValidator {
81                name: name.to_owned(),
82            }
83            .into()
84        })
85    }
86}
87
88impl ValidatorConfig {
89    /// Validate the configuration values.
90    ///
91    /// # Errors
92    ///
93    /// Returns error if container or script are empty.
94    pub fn validate(&self, name: &str) -> Result<()> {
95        if self.container.is_empty() {
96            return Err(ValidatorError::InvalidConfig {
97                name: name.to_owned(),
98                reason: "container cannot be empty".into(),
99            }
100            .into());
101        }
102        if self.script.as_os_str().is_empty() {
103            return Err(ValidatorError::InvalidConfig {
104                name: name.to_owned(),
105                reason: "script path cannot be empty".into(),
106            }
107            .into());
108        }
109        Ok(())
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::error::ValidatorError;
117
118    // ==================== ValidatorConfig tests ====================
119
120    #[test]
121    fn validator_config_valid() {
122        let config = ValidatorConfig {
123            container: "ubuntu:22.04".to_owned(),
124            script: PathBuf::from("validators/validate.sh"),
125            exec_command: None,
126        };
127        assert!(config.validate("test").is_ok());
128    }
129
130    #[test]
131    fn validator_config_empty_container() {
132        let config = ValidatorConfig {
133            container: String::new(),
134            script: PathBuf::from("validators/validate.sh"),
135            exec_command: None,
136        };
137        let err = config
138            .validate("test")
139            .unwrap_err()
140            .downcast::<ValidatorError>()
141            .expect("should be ValidatorError");
142        assert!(matches!(
143            err,
144            ValidatorError::InvalidConfig { reason, .. } if reason.contains("container cannot be empty")
145        ));
146    }
147
148    #[test]
149    fn validator_config_empty_script() {
150        let config = ValidatorConfig {
151            container: "ubuntu:22.04".to_owned(),
152            script: PathBuf::new(),
153            exec_command: None,
154        };
155        let err = config
156            .validate("test")
157            .unwrap_err()
158            .downcast::<ValidatorError>()
159            .expect("should be ValidatorError");
160        assert!(matches!(
161            err,
162            ValidatorError::InvalidConfig { reason, .. } if reason.contains("script path cannot be empty")
163        ));
164    }
165
166    #[test]
167    fn validator_config_with_exec_command() {
168        let config = ValidatorConfig {
169            container: "ubuntu:22.04".to_owned(),
170            script: PathBuf::from("validators/validate.sh"),
171            exec_command: Some("sqlite3 -json /tmp/test.db".to_owned()),
172        };
173        assert!(config.validate("test").is_ok());
174        assert_eq!(
175            config.exec_command,
176            Some("sqlite3 -json /tmp/test.db".to_owned())
177        );
178    }
179
180    // ==================== Config tests ====================
181
182    #[test]
183    fn config_get_validator_exists() {
184        let mut validators = HashMap::new();
185        validators.insert(
186            "sqlite".to_owned(),
187            ValidatorConfig {
188                container: "keinos/sqlite3:3.47.2".to_owned(),
189                script: PathBuf::from("validators/validate-sqlite.sh"),
190                exec_command: None,
191            },
192        );
193        let config = Config {
194            validators,
195            fail_fast: true,
196            fixtures_dir: None,
197        };
198
199        let result = config.get_validator("sqlite");
200        assert!(result.is_ok());
201        assert_eq!(result.unwrap().container, "keinos/sqlite3:3.47.2");
202    }
203
204    #[test]
205    fn config_get_validator_not_found() {
206        let config = Config::default();
207        let result = config.get_validator("nonexistent");
208        assert!(result.is_err());
209        let err = result
210            .unwrap_err()
211            .downcast::<ValidatorError>()
212            .expect("should be ValidatorError");
213        assert!(matches!(
214            err,
215            ValidatorError::UnknownValidator { name } if name == "nonexistent"
216        ));
217    }
218
219    #[test]
220    fn config_default_fail_fast_true() {
221        // Test the default_fail_fast function returns true
222        assert!(default_fail_fast());
223    }
224
225    // ==================== TOML parsing tests ====================
226
227    #[test]
228    fn config_parse_from_toml() {
229        let toml_str = r#"
230            fail_fast = false
231            [validators.sqlite]
232            container = "keinos/sqlite3:3.47.2"
233            script = "validators/validate-sqlite.sh"
234        "#;
235        let config: Config = toml::from_str(toml_str).unwrap();
236        assert!(!config.fail_fast);
237        assert!(config.validators.contains_key("sqlite"));
238    }
239
240    #[test]
241    fn config_parse_multiple_validators() {
242        let toml_str = r#"
243            [validators.sqlite]
244            container = "keinos/sqlite3:3.47.2"
245            script = "validators/validate-sqlite.sh"
246
247            [validators.osquery]
248            container = "osquery/osquery:5.17.0-ubuntu22.04"
249            script = "validators/validate-osquery.sh"
250        "#;
251        let config: Config = toml::from_str(toml_str).unwrap();
252        assert_eq!(config.validators.len(), 2);
253        assert!(config.validators.contains_key("sqlite"));
254        assert!(config.validators.contains_key("osquery"));
255    }
256
257    #[test]
258    fn config_parse_with_exec_command() {
259        let toml_str = r#"
260            [validators.custom]
261            container = "ubuntu:22.04"
262            script = "validators/validate-custom.sh"
263            exec_command = "python3 -c"
264        "#;
265        let config: Config = toml::from_str(toml_str).unwrap();
266        let custom = config.validators.get("custom").unwrap();
267        assert_eq!(custom.exec_command, Some("python3 -c".to_owned()));
268    }
269
270    #[test]
271    fn config_parse_with_fixtures_dir() {
272        let toml_str = r#"
273            fixtures_dir = "test-fixtures"
274            [validators.sqlite]
275            container = "keinos/sqlite3:3.47.2"
276            script = "validators/validate-sqlite.sh"
277        "#;
278        let config: Config = toml::from_str(toml_str).unwrap();
279        assert_eq!(config.fixtures_dir, Some(PathBuf::from("test-fixtures")));
280    }
281
282    #[test]
283    fn config_parse_empty_validators() {
284        let toml_str = r"
285            fail_fast = true
286        ";
287        let config: Config = toml::from_str(toml_str).unwrap();
288        assert!(config.validators.is_empty());
289        assert!(config.fail_fast);
290    }
291}