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