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