1use crate::source::{RelPath, SourceSpan};
8pub use foch_annotation::builtin::MAX_ADVANCE_DAYS;
9pub use foch_annotation::value::{Clause, EventId, GameDate, NativeBlock, Tag, identifier};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::collections::BTreeSet;
13use std::fmt;
14use std::str::FromStr;
15
16macro_rules! string_newtype {
17 ($name:ident, $parse:path) => {
18 impl $name {
19 pub fn as_str(&self) -> &str {
20 &self.0
21 }
22 }
23
24 impl TryFrom<String> for $name {
25 type Error = String;
26
27 fn try_from(value: String) -> Result<Self, String> {
28 $parse(&value)
29 }
30 }
31
32 impl From<$name> for String {
33 fn from(value: $name) -> Self {
34 value.0
35 }
36 }
37
38 impl fmt::Display for $name {
39 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40 formatter.write_str(&self.0)
41 }
42 }
43 };
44}
45
46#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
48#[serde(try_from = "String", into = "String")]
49pub struct FixtureName(String);
50
51impl FixtureName {
52 pub fn parse(value: &str) -> Result<Self, String> {
53 if identifier(value) && value.len() <= 128 {
54 Ok(Self(value.into()))
55 } else {
56 Err(format!(
57 "invalid fixture name {value:?}; expected an identifier"
58 ))
59 }
60 }
61}
62string_newtype!(FixtureName, FixtureName::parse);
63
64#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
67#[serde(try_from = "String", into = "String")]
68pub struct RunId(String);
69
70impl RunId {
71 pub fn parse(value: &str) -> Result<Self, String> {
72 if !value.is_empty()
73 && value.len() <= 64
74 && value
75 .bytes()
76 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
77 {
78 Ok(Self(value.into()))
79 } else {
80 Err("run ID must contain 1..=64 ASCII letters, digits, underscores or hyphens".into())
81 }
82 }
83}
84string_newtype!(RunId, RunId::parse);
85
86pub(crate) fn valid_case_name(name: &str) -> Result<(), String> {
87 if name.trim().is_empty()
88 || name.chars().count() > 128
89 || name.chars().any(char::is_control)
90 || name.contains("::")
91 || name.contains(['[', ']'])
92 || name.starts_with('#')
93 {
94 Err("test name must contain 1..=128 characters, without control characters, \"::\", brackets or a leading #".into())
95 } else {
96 Ok(())
97 }
98}
99
100pub(crate) fn valid_mod_name(name: &str) -> Result<(), String> {
101 if name.trim().is_empty() || name.contains("::") || name.chars().any(char::is_control) {
102 Err(format!(
103 "invalid mod name {name:?}; it must be nonempty without \"::\" or control characters"
104 ))
105 } else {
106 Ok(())
107 }
108}
109
110#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum CaseName {
116 Named(String),
117 Index(u32),
118}
119
120impl fmt::Display for CaseName {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 match self {
123 Self::Named(name) => formatter.write_str(name),
124 Self::Index(index) => write!(formatter, "#{index}"),
125 }
126 }
127}
128
129#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
132pub struct NodeId {
133 pub mod_name: String,
134 pub path: RelPath,
135 pub target: Option<EventId>,
137 pub name: CaseName,
138 pub params: Vec<(String, String)>,
139}
140
141impl NodeId {
142 pub fn local(&self) -> String {
144 let mut text = self.path.to_string();
145 if let Some(target) = &self.target {
146 text.push_str("::");
147 text.push_str(target.as_str());
148 }
149 text.push_str("::");
150 text.push_str(&self.name.to_string());
151 if !self.params.is_empty() {
152 let params: Vec<_> = self
153 .params
154 .iter()
155 .map(|(key, value)| format!("{key}={value}"))
156 .collect();
157 text.push('[');
158 text.push_str(¶ms.join(","));
159 text.push(']');
160 }
161 text
162 }
163}
164
165impl fmt::Display for NodeId {
166 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167 write!(formatter, "{}::{}", self.mod_name, self.local())
168 }
169}
170
171impl FromStr for NodeId {
172 type Err = String;
173
174 fn from_str(value: &str) -> Result<Self, String> {
175 let (body, params) = match value.strip_suffix(']') {
176 Some(rest) => {
177 let (body, params) = rest
178 .rsplit_once('[')
179 .ok_or("unbalanced parameter brackets")?;
180 let params = params
181 .split(',')
182 .map(|pair| {
183 pair.split_once('=')
184 .map(|(key, value)| (key.to_string(), value.to_string()))
185 .ok_or_else(|| format!("invalid parameter {pair:?}"))
186 })
187 .collect::<Result<Vec<_>, _>>()?;
188 (body, params)
189 }
190 None => (value, Vec::new()),
191 };
192 let segments: Vec<_> = body.split("::").collect();
193 let (mod_name, path, target, name) = match segments.as_slice() {
194 [mod_name, path, target, name] => (mod_name, path, Some(EventId::parse(target)?), name),
195 [mod_name, path, name] => (mod_name, path, None, name),
196 _ => {
197 return Err(format!(
198 "node ID {value:?} must be mod::path[::event]::name"
199 ));
200 }
201 };
202 valid_mod_name(mod_name)?;
203 let name = match name.strip_prefix('#') {
204 Some(index) => CaseName::Index(
205 index
206 .parse()
207 .map_err(|_| format!("invalid case index {name:?}"))?,
208 ),
209 None => {
210 valid_case_name(name)?;
211 CaseName::Named((*name).into())
212 }
213 };
214 Ok(Self {
215 mod_name: (*mod_name).into(),
216 path: RelPath::new(path)?,
217 target,
218 name,
219 params,
220 })
221 }
222}
223
224#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
228#[serde(transparent)]
229pub struct ContentId(pub(crate) String);
230
231impl ContentId {
232 pub fn as_str(&self) -> &str {
233 &self.0
234 }
235}
236
237#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
239#[serde(transparent)]
240pub struct CaseIndex(pub usize);
241
242#[derive(
243 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
244)]
245#[serde(rename_all = "snake_case")]
246pub enum AiMode {
247 On,
250 #[default]
255 Off,
256}
257
258#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
259#[serde(rename_all = "snake_case")]
260pub enum SessionRequest {
261 #[default]
263 Auto,
264 Alone,
266}
267
268#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
269pub struct Start {
270 pub date: GameDate,
271 pub tag: Tag,
272}
273
274#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
275#[serde(tag = "step", rename_all = "snake_case")]
276#[non_exhaustive]
277pub enum Step {
278 Effect { block: NativeBlock },
279 Fire { event: EventId, span: SourceSpan },
280 AdvanceDays { days: u32, span: SourceSpan },
281 Expect { clauses: Vec<Clause> },
282}
283
284#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
285pub struct Marker {
286 pub reason: Option<String>,
287}
288
289#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
290pub struct XFail {
291 pub reason: Option<String>,
292 pub strict: bool,
294}
295
296#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
297pub struct Marks {
298 pub skip: Option<Marker>,
299 pub ignore: Option<Marker>,
300 pub xfail: Option<XFail>,
301 pub labels: BTreeSet<String>,
302 pub session: Option<SessionRequest>,
303 pub requires_player: bool,
305}
306
307#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
308pub struct TestCase {
309 pub node: NodeId,
310 pub content_id: ContentId,
311 pub origin: SourceSpan,
312 pub start: Start,
313 pub ai: AiMode,
314 pub uses: Vec<FixtureName>,
315 pub steps: Vec<Step>,
316 pub marks: Marks,
317}
318
319impl TestCase {
320 pub fn is_smoke(&self) -> bool {
322 !self
323 .steps
324 .iter()
325 .any(|step| matches!(step, Step::Expect { .. }))
326 }
327
328 pub(crate) fn compute_content_id(&mut self) {
329 #[derive(Serialize)]
330 struct Identity<'a> {
331 node: String,
332 start: &'a Start,
333 ai: AiMode,
334 uses: &'a [FixtureName],
335 steps: Vec<String>,
336 }
337 let steps = self
338 .steps
339 .iter()
340 .map(|step| match step {
341 Step::Effect { block } => format!("effect {}", block.text),
342 Step::Fire { event, .. } => format!("fire {event}"),
343 Step::AdvanceDays { days, .. } => format!("advance {days}"),
344 Step::Expect { clauses } => {
345 let clauses: Vec<_> =
346 clauses.iter().map(|clause| clause.text.as_str()).collect();
347 format!("expect {}", clauses.join("\n"))
348 }
349 })
350 .collect();
351 let identity = Identity {
352 node: self.node.to_string(),
353 start: &self.start,
354 ai: self.ai,
355 uses: &self.uses,
356 steps,
357 };
358 self.content_id = ContentId(digest(&identity));
359 }
360}
361
362#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
366pub struct Fixture {
367 pub name: FixtureName,
368 pub origin: SourceSpan,
369 pub uses: Vec<FixtureName>,
370 pub effect: Option<NativeBlock>,
371 pub check: Vec<Clause>,
374}
375
376#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
379pub struct EventInfo {
380 pub id: EventId,
381 pub origin: SourceSpan,
382 pub invocable: bool,
384 pub body: String,
386 pub helper: bool,
388}
389
390#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
392pub struct HelperFile {
393 pub path: RelPath,
394 pub text: String,
395}
396
397pub(crate) fn digest(value: &impl Serialize) -> String {
398 let bytes = serde_json::to_vec(value).expect("test model values serialize");
399 format!("{:x}", Sha256::digest(bytes))
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn run_ids_reject_script_injection() {
408 assert!(RunId::parse("run-1_a").is_ok());
409 for bad in ["", "bad run", "../x", "x\"", &"x".repeat(65)] {
410 assert!(RunId::parse(bad).is_err(), "{bad:?}");
411 }
412 assert!(FixtureName::parse("at_war").is_ok());
413 assert!(FixtureName::parse("1war").is_err());
414 }
415
416 #[test]
417 fn node_ids_round_trip() {
418 for text in [
419 "My Mod::events/reforms.txt::reforms.1::grants flag",
420 "m::events/a.txt::a.1::#2",
421 "m::tests/war.txt::declare_then_peace",
422 "m::events/a.txt::a.1::union[tag=DAN,time=1500.1.1]",
423 ] {
424 let id: NodeId = text.parse().unwrap();
425 assert_eq!(id.to_string(), text);
426 }
427 assert!("m::a.txt".parse::<NodeId>().is_err());
428 assert!("m::a.txt::x::y::z".parse::<NodeId>().is_err());
429 }
430}