Skip to main content

tokf_common/
test_case.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Deserialize, Serialize)]
4pub struct TestCase {
5    pub name: String,
6    #[serde(default)]
7    pub fixture: Option<String>,
8    #[serde(default)]
9    pub inline: Option<String>,
10    #[serde(default)]
11    pub exit_code: i32,
12    #[serde(default)]
13    pub args: Vec<String>,
14    #[serde(rename = "expect", default)]
15    pub expects: Vec<Expectation>,
16}
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
19pub struct Expectation {
20    #[serde(default)]
21    pub contains: Option<String>,
22    #[serde(default)]
23    pub not_contains: Option<String>,
24    #[serde(default)]
25    pub equals: Option<String>,
26    #[serde(default)]
27    pub starts_with: Option<String>,
28    #[serde(default)]
29    pub ends_with: Option<String>,
30    #[serde(default)]
31    pub line_count: Option<usize>,
32    #[serde(default)]
33    pub matches: Option<String>,
34    #[serde(default)]
35    pub not_matches: Option<String>,
36}
37
38/// Validate test case bytes: checks UTF-8, TOML parsing, non-empty name,
39/// at least one `[[expect]]` block, and regex compilation for `matches`
40/// and `not_matches` fields.
41///
42/// # Errors
43///
44/// Returns a human-readable error string if validation fails.
45#[cfg(feature = "validation")]
46pub fn validate(bytes: &[u8]) -> Result<TestCase, String> {
47    let text = std::str::from_utf8(bytes).map_err(|_| "test file is not valid UTF-8")?;
48    let tc: TestCase = toml::from_str(text).map_err(|e| format!("invalid test case TOML: {e}"))?;
49    if tc.name.trim().is_empty() {
50        return Err("test case 'name' must be non-empty".to_string());
51    }
52    if tc.expects.is_empty() {
53        return Err("test case must have at least one [[expect]] block".to_string());
54    }
55    for (i, exp) in tc.expects.iter().enumerate() {
56        if let Some(pat) = &exp.matches {
57            regex::Regex::new(pat)
58                .map_err(|e| format!("expect[{i}].matches: invalid regex: {e}"))?;
59        }
60        if let Some(pat) = &exp.not_matches {
61            regex::Regex::new(pat)
62                .map_err(|e| format!("expect[{i}].not_matches: invalid regex: {e}"))?;
63        }
64    }
65    Ok(tc)
66}
67
68#[cfg(test)]
69#[allow(clippy::unwrap_used)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn deserialize_minimal_test_case() {
75        let toml_str = r#"
76name = "basic"
77
78[[expect]]
79contains = "hello"
80"#;
81        let tc: TestCase = toml::from_str(toml_str).unwrap();
82        assert_eq!(tc.name, "basic");
83        assert_eq!(tc.expects.len(), 1);
84        assert_eq!(tc.expects[0].contains.as_deref(), Some("hello"));
85    }
86
87    #[test]
88    fn deserialize_full_test_case() {
89        let toml_str = r#"
90name = "full"
91fixture = "output.txt"
92exit_code = 1
93args = ["--verbose"]
94
95[[expect]]
96contains = "error"
97not_contains = "success"
98matches = "\\d+ errors?"
99"#;
100        let tc: TestCase = toml::from_str(toml_str).unwrap();
101        assert_eq!(tc.name, "full");
102        assert_eq!(tc.fixture.as_deref(), Some("output.txt"));
103        assert_eq!(tc.exit_code, 1);
104        assert_eq!(tc.args, vec!["--verbose"]);
105        assert_eq!(tc.expects[0].matches.as_deref(), Some("\\d+ errors?"));
106    }
107
108    #[test]
109    fn serialize_round_trip() {
110        let tc = TestCase {
111            name: "roundtrip".to_string(),
112            fixture: None,
113            inline: Some("hello world".to_string()),
114            exit_code: 0,
115            args: vec![],
116            expects: vec![Expectation {
117                contains: Some("hello".to_string()),
118                not_contains: None,
119                equals: None,
120                starts_with: None,
121                ends_with: None,
122                line_count: None,
123                matches: None,
124                not_matches: None,
125            }],
126        };
127        let json = serde_json::to_string(&tc).unwrap();
128        let parsed: TestCase = serde_json::from_str(&json).unwrap();
129        assert_eq!(parsed.name, "roundtrip");
130        assert_eq!(parsed.expects[0].contains.as_deref(), Some("hello"));
131    }
132}
133
134#[cfg(all(test, feature = "validation"))]
135#[allow(clippy::unwrap_used)]
136mod validation_tests {
137    use super::*;
138
139    #[test]
140    fn validate_accepts_valid_test_case() {
141        let bytes = br#"
142name = "basic"
143
144[[expect]]
145contains = "hello"
146"#;
147        let tc = validate(bytes).unwrap();
148        assert_eq!(tc.name, "basic");
149    }
150
151    #[test]
152    fn validate_rejects_invalid_utf8() {
153        let bytes = &[0xFF, 0xFE, 0x00];
154        let err = validate(bytes).unwrap_err();
155        assert!(err.contains("UTF-8"), "expected UTF-8 error, got: {err}");
156    }
157
158    #[test]
159    fn validate_rejects_invalid_toml() {
160        let bytes = b"not valid toml [[[";
161        let err = validate(bytes).unwrap_err();
162        assert!(err.contains("TOML"), "expected TOML error, got: {err}");
163    }
164
165    #[test]
166    fn validate_rejects_empty_name() {
167        let bytes = br#"
168name = ""
169
170[[expect]]
171contains = "x"
172"#;
173        let err = validate(bytes).unwrap_err();
174        assert!(
175            err.contains("non-empty"),
176            "expected non-empty name error, got: {err}"
177        );
178    }
179
180    #[test]
181    fn validate_rejects_missing_expects() {
182        let bytes = br#"name = "no expects""#;
183        let err = validate(bytes).unwrap_err();
184        assert!(
185            err.contains("[[expect]]"),
186            "expected expect error, got: {err}"
187        );
188    }
189
190    #[test]
191    fn validate_rejects_invalid_regex_in_matches() {
192        let bytes = br#"
193name = "bad regex"
194
195[[expect]]
196matches = "[invalid("
197"#;
198        let err = validate(bytes).unwrap_err();
199        assert!(
200            err.contains("invalid regex"),
201            "expected regex error, got: {err}"
202        );
203    }
204
205    #[test]
206    fn validate_rejects_invalid_regex_in_not_matches() {
207        let bytes = br#"
208name = "bad not_matches"
209
210[[expect]]
211not_matches = "(?P<>)"
212"#;
213        let err = validate(bytes).unwrap_err();
214        assert!(
215            err.contains("invalid regex"),
216            "expected regex error, got: {err}"
217        );
218    }
219
220    #[test]
221    fn validate_rejects_whitespace_only_name() {
222        let bytes = br#"
223name = "   "
224
225[[expect]]
226contains = "x"
227"#;
228        let err = validate(bytes).unwrap_err();
229        assert!(
230            err.contains("non-empty"),
231            "expected non-empty name error, got: {err}"
232        );
233    }
234
235    #[test]
236    fn validate_accepts_multiple_valid_expects() {
237        let bytes = br#"
238name = "multi-expect"
239
240[[expect]]
241contains = "hello"
242
243[[expect]]
244not_contains = "error"
245starts_with = "OK"
246"#;
247        let tc = validate(bytes).unwrap();
248        assert_eq!(tc.expects.len(), 2);
249    }
250
251    #[test]
252    fn validate_rejects_second_expect_with_bad_regex() {
253        let bytes = br#"
254name = "mixed"
255
256[[expect]]
257contains = "valid"
258
259[[expect]]
260matches = "[bad("
261"#;
262        let err = validate(bytes).unwrap_err();
263        assert!(
264            err.contains("expect[1]"),
265            "expected error on second expect block, got: {err}"
266        );
267    }
268}