1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4pub trait Evaluate {
6 fn evaluate(&self, ctx: &EvalContext) -> bool;
7}
8
9fn eval_iter<'a, T: Evaluate>(
11 deps: &'a [T],
12 ctx: &'a EvalContext,
13) -> impl Iterator<Item = bool> + 'a {
14 deps.iter().map(move |d| d.evaluate(ctx))
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CompositeDependency {
20 #[serde(rename = "@operator", default = "default_operator")]
21 pub operator: Operator,
22
23 #[serde(rename = "fileDependency", default)]
24 pub file_deps: Vec<FileDependency>,
25
26 #[serde(rename = "flagDependency", default)]
27 pub flag_deps: Vec<FlagDependency>,
28
29 #[serde(rename = "gameDependency", default)]
30 pub game_deps: Vec<GameDependency>,
31
32 #[serde(rename = "fommDependency", default)]
33 pub fomm_deps: Vec<FommDependency>,
34
35 #[serde(rename = "dependencies", default)]
37 pub nested: Vec<CompositeDependency>,
38}
39
40impl Evaluate for CompositeDependency {
41 fn evaluate(&self, ctx: &EvalContext) -> bool {
42 let mut results = eval_iter(&self.file_deps, ctx)
43 .chain(eval_iter(&self.flag_deps, ctx))
44 .chain(eval_iter(&self.game_deps, ctx))
45 .chain(eval_iter(&self.fomm_deps, ctx))
46 .chain(eval_iter(&self.nested, ctx));
47
48 match self.operator {
49 Operator::And => results.all(|v| v),
50 Operator::Or => results.any(|v| v),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct FileDependency {
57 #[serde(rename = "@file")]
58 pub file: String,
59
60 #[serde(rename = "@state")]
61 pub state: FileState,
62}
63
64impl Evaluate for FileDependency {
65 fn evaluate(&self, ctx: &EvalContext) -> bool {
66 let actual = ctx
68 .file_states
69 .iter()
70 .find(|(k, _)| k.eq_ignore_ascii_case(&self.file))
71 .map(|(_, v)| *v)
72 .unwrap_or(FileState::Missing);
73 actual == self.state
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct FlagDependency {
79 #[serde(rename = "@flag")]
80 pub flag: String,
81
82 #[serde(rename = "@value")]
83 pub value: String,
84}
85
86impl Evaluate for FlagDependency {
87 fn evaluate(&self, ctx: &EvalContext) -> bool {
88 ctx.flags
89 .get(&self.flag)
90 .map(|v| v == &self.value)
91 .unwrap_or(false)
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct GameDependency {
97 #[serde(rename = "@version")]
98 pub version: String,
99}
100
101impl Evaluate for GameDependency {
102 fn evaluate(&self, ctx: &EvalContext) -> bool {
103 check_version(&ctx.game_version, &self.version)
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct FommDependency {
110 #[serde(rename = "@version")]
111 pub version: String,
112}
113
114impl Evaluate for FommDependency {
115 fn evaluate(&self, ctx: &EvalContext) -> bool {
116 check_version(&ctx.manager_version, &self.version)
117 }
118}
119
120fn check_version(current: &Option<String>, required: &str) -> bool {
122 current
123 .as_ref()
124 .map_or(false, |c| compare_versions(c, required))
125}
126
127fn compare_versions(current: &str, required: &str) -> bool {
129 let parse = |s: &str| -> Vec<u32> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
130 let cur = parse(current);
131 let req = parse(required);
132 cur >= req
133}
134
135#[derive(Debug, Default, Clone)]
137pub struct EvalContext {
138 pub flags: HashMap<String, String>,
140
141 pub file_states: HashMap<String, FileState>,
143
144 pub game_version: Option<String>,
146
147 pub manager_version: Option<String>,
149}
150
151impl EvalContext {
152 pub fn new() -> Self {
153 Self::default()
154 }
155
156 pub fn set_flag(&mut self, name: impl Into<String>, value: impl Into<String>) {
157 self.flags.insert(name.into(), value.into());
158 }
159
160 pub fn set_file_state(&mut self, file: impl Into<String>, state: FileState) {
161 self.file_states.insert(file.into(), state);
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166pub enum FileState {
167 Active,
168 Inactive,
169 Missing,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173pub enum Operator {
174 And,
175 Or,
176}
177
178fn default_operator() -> Operator {
179 Operator::And
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
189 fn version_equal() {
190 assert!(compare_versions("1.2.0", "1.2.0"));
191 }
192
193 #[test]
194 fn version_current_greater() {
195 assert!(compare_versions("1.3.0", "1.2.0"));
196 assert!(compare_versions("2.0.0", "1.9.9"));
197 }
198
199 #[test]
200 fn version_current_less() {
201 assert!(!compare_versions("1.1.0", "1.2.0"));
202 assert!(!compare_versions("0.9.9", "1.0.0"));
203 }
204
205 #[test]
206 fn version_different_lengths() {
207 assert!(!compare_versions("1.2", "1.2.0"));
209 assert!(compare_versions("1.2.0.1", "1.2.0"));
211 }
212
213 #[test]
214 fn version_single_segment() {
215 assert!(compare_versions("2", "1"));
216 assert!(compare_versions("1", "1"));
217 assert!(!compare_versions("0", "1"));
218 }
219
220 #[test]
221 fn version_non_numeric_segments_filtered() {
222 assert!(compare_versions("1.2.alpha", "1.2"));
224 assert!(compare_versions("1.beta.2", "1.alpha.2"));
226 }
227
228 #[test]
229 fn version_empty_string() {
230 assert!(!compare_versions("", "1.0"));
232 assert!(compare_versions("", ""));
234 }
235
236 #[test]
237 fn version_leading_zeros() {
238 assert!(compare_versions("01.002.003", "1.2.3"));
240 }
241
242 #[test]
243 fn version_gaps() {
244 assert!(compare_versions("1..3", "1.3"));
246 }
247
248 #[test]
251 fn eval_context_default_is_empty() {
252 let ctx = EvalContext::new();
253 assert!(ctx.flags.is_empty());
254 assert!(ctx.file_states.is_empty());
255 assert!(ctx.game_version.is_none());
256 assert!(ctx.manager_version.is_none());
257 }
258
259 #[test]
260 fn eval_context_set_flag() {
261 let mut ctx = EvalContext::new();
262 ctx.set_flag("test", "value");
263 assert_eq!(ctx.flags.get("test"), Some(&"value".to_string()));
264 }
265
266 #[test]
267 fn eval_context_set_flag_overwrite() {
268 let mut ctx = EvalContext::new();
269 ctx.set_flag("key", "old");
270 ctx.set_flag("key", "new");
271 assert_eq!(ctx.flags.get("key"), Some(&"new".to_string()));
272 }
273
274 #[test]
275 fn eval_context_set_file_state() {
276 let mut ctx = EvalContext::new();
277 ctx.set_file_state("mod.esp", FileState::Active);
278 assert_eq!(ctx.file_states.get("mod.esp"), Some(&FileState::Active));
279 }
280
281 #[test]
284 fn flag_dep_matches() {
285 let mut ctx = EvalContext::new();
286 ctx.set_flag("flag1", "yes");
287 let dep = FlagDependency {
288 flag: "flag1".into(),
289 value: "yes".into(),
290 };
291 assert!(dep.evaluate(&ctx));
292 }
293
294 #[test]
295 fn flag_dep_wrong_value() {
296 let mut ctx = EvalContext::new();
297 ctx.set_flag("flag1", "no");
298 let dep = FlagDependency {
299 flag: "flag1".into(),
300 value: "yes".into(),
301 };
302 assert!(!dep.evaluate(&ctx));
303 }
304
305 #[test]
306 fn flag_dep_missing_flag() {
307 let ctx = EvalContext::new();
308 let dep = FlagDependency {
309 flag: "missing".into(),
310 value: "yes".into(),
311 };
312 assert!(!dep.evaluate(&ctx));
313 }
314
315 #[test]
316 fn flag_dep_case_sensitive() {
317 let mut ctx = EvalContext::new();
318 ctx.set_flag("Flag", "On");
319 let dep = FlagDependency {
320 flag: "Flag".into(),
321 value: "on".into(), };
323 assert!(!dep.evaluate(&ctx), "flag values are case-sensitive");
324 }
325
326 #[test]
327 fn flag_dep_empty_value() {
328 let mut ctx = EvalContext::new();
329 ctx.set_flag("flag", "");
330 let dep = FlagDependency {
331 flag: "flag".into(),
332 value: "".into(),
333 };
334 assert!(dep.evaluate(&ctx));
335 }
336
337 #[test]
340 fn file_dep_active() {
341 let mut ctx = EvalContext::new();
342 ctx.set_file_state("mod.esp", FileState::Active);
343 let dep = FileDependency {
344 file: "mod.esp".into(),
345 state: FileState::Active,
346 };
347 assert!(dep.evaluate(&ctx));
348 }
349
350 #[test]
351 fn file_dep_inactive() {
352 let mut ctx = EvalContext::new();
353 ctx.set_file_state("mod.esp", FileState::Inactive);
354 let dep = FileDependency {
355 file: "mod.esp".into(),
356 state: FileState::Inactive,
357 };
358 assert!(dep.evaluate(&ctx));
359 }
360
361 #[test]
362 fn file_dep_missing_default() {
363 let ctx = EvalContext::new();
364 let dep = FileDependency {
365 file: "nonexistent.esp".into(),
366 state: FileState::Missing,
367 };
368 assert!(dep.evaluate(&ctx), "unknown files default to Missing");
369 }
370
371 #[test]
372 fn file_dep_missing_but_expected_active() {
373 let ctx = EvalContext::new();
374 let dep = FileDependency {
375 file: "nonexistent.esp".into(),
376 state: FileState::Active,
377 };
378 assert!(!dep.evaluate(&ctx));
379 }
380
381 #[test]
382 fn file_dep_case_insensitive() {
383 let mut ctx = EvalContext::new();
384 ctx.set_file_state("Data/Textures/Mod.dds", FileState::Active);
385 let dep = FileDependency {
386 file: "data/textures/mod.dds".into(),
387 state: FileState::Active,
388 };
389 assert!(dep.evaluate(&ctx));
390 }
391
392 #[test]
393 fn file_dep_wrong_state() {
394 let mut ctx = EvalContext::new();
395 ctx.set_file_state("mod.esp", FileState::Active);
396 let dep = FileDependency {
397 file: "mod.esp".into(),
398 state: FileState::Inactive,
399 };
400 assert!(!dep.evaluate(&ctx));
401 }
402
403 #[test]
406 fn game_dep_sufficient() {
407 let mut ctx = EvalContext::new();
408 ctx.game_version = Some("1.5.0".into());
409 let dep = GameDependency {
410 version: "1.5.0".into(),
411 };
412 assert!(dep.evaluate(&ctx));
413 }
414
415 #[test]
416 fn game_dep_insufficient() {
417 let mut ctx = EvalContext::new();
418 ctx.game_version = Some("1.4.0".into());
419 let dep = GameDependency {
420 version: "1.5.0".into(),
421 };
422 assert!(!dep.evaluate(&ctx));
423 }
424
425 #[test]
426 fn game_dep_no_version_set() {
427 let ctx = EvalContext::new();
428 let dep = GameDependency {
429 version: "1.0.0".into(),
430 };
431 assert!(!dep.evaluate(&ctx));
432 }
433
434 #[test]
437 fn fomm_dep_sufficient() {
438 let mut ctx = EvalContext::new();
439 ctx.manager_version = Some("2.0.0".into());
440 let dep = FommDependency {
441 version: "1.0.0".into(),
442 };
443 assert!(dep.evaluate(&ctx));
444 }
445
446 #[test]
447 fn fomm_dep_no_version_set() {
448 let ctx = EvalContext::new();
449 let dep = FommDependency {
450 version: "1.0.0".into(),
451 };
452 assert!(!dep.evaluate(&ctx));
453 }
454
455 fn make_flag_dep(flag: &str, value: &str) -> FlagDependency {
458 FlagDependency {
459 flag: flag.into(),
460 value: value.into(),
461 }
462 }
463
464 fn make_composite(op: Operator, flag_deps: Vec<FlagDependency>) -> CompositeDependency {
465 CompositeDependency {
466 operator: op,
467 file_deps: vec![],
468 flag_deps,
469 game_deps: vec![],
470 fomm_deps: vec![],
471 nested: vec![],
472 }
473 }
474
475 #[test]
476 fn composite_and_all_true() {
477 let mut ctx = EvalContext::new();
478 ctx.set_flag("a", "1");
479 ctx.set_flag("b", "2");
480 let comp = make_composite(
481 Operator::And,
482 vec![make_flag_dep("a", "1"), make_flag_dep("b", "2")],
483 );
484 assert!(comp.evaluate(&ctx));
485 }
486
487 #[test]
488 fn composite_and_one_false() {
489 let mut ctx = EvalContext::new();
490 ctx.set_flag("a", "1");
491 let comp = make_composite(
492 Operator::And,
493 vec![make_flag_dep("a", "1"), make_flag_dep("b", "2")],
494 );
495 assert!(!comp.evaluate(&ctx));
496 }
497
498 #[test]
499 fn composite_or_one_true() {
500 let mut ctx = EvalContext::new();
501 ctx.set_flag("a", "1");
502 let comp = make_composite(
503 Operator::Or,
504 vec![make_flag_dep("a", "1"), make_flag_dep("b", "2")],
505 );
506 assert!(comp.evaluate(&ctx));
507 }
508
509 #[test]
510 fn composite_or_none_true() {
511 let ctx = EvalContext::new();
512 let comp = make_composite(
513 Operator::Or,
514 vec![make_flag_dep("a", "1"), make_flag_dep("b", "2")],
515 );
516 assert!(!comp.evaluate(&ctx));
517 }
518
519 #[test]
520 fn composite_and_empty_is_true() {
521 let ctx = EvalContext::new();
522 let comp = make_composite(Operator::And, vec![]);
523 assert!(comp.evaluate(&ctx), "AND over empty set is vacuously true");
524 }
525
526 #[test]
527 fn composite_or_empty_is_false() {
528 let ctx = EvalContext::new();
529 let comp = make_composite(Operator::Or, vec![]);
530 assert!(
531 !comp.evaluate(&ctx),
532 "OR over empty set is false (no element satisfies)"
533 );
534 }
535
536 #[test]
537 fn composite_nested_and_or() {
538 let mut ctx = EvalContext::new();
540 ctx.set_flag("a", "1");
541 ctx.set_flag("c", "3");
542
543 let inner = make_composite(
544 Operator::Or,
545 vec![make_flag_dep("b", "2"), make_flag_dep("c", "3")],
546 );
547 let outer = CompositeDependency {
548 operator: Operator::And,
549 flag_deps: vec![make_flag_dep("a", "1")],
550 nested: vec![inner],
551 file_deps: vec![],
552 game_deps: vec![],
553 fomm_deps: vec![],
554 };
555 assert!(outer.evaluate(&ctx));
556 }
557
558 #[test]
559 fn composite_nested_fails_outer() {
560 let mut ctx = EvalContext::new();
562 ctx.set_flag("a", "wrong");
563 ctx.set_flag("c", "3");
564
565 let inner = make_composite(
566 Operator::Or,
567 vec![make_flag_dep("b", "2"), make_flag_dep("c", "3")],
568 );
569 let outer = CompositeDependency {
570 operator: Operator::And,
571 flag_deps: vec![make_flag_dep("a", "1")],
572 nested: vec![inner],
573 file_deps: vec![],
574 game_deps: vec![],
575 fomm_deps: vec![],
576 };
577 assert!(!outer.evaluate(&ctx));
578 }
579
580 #[test]
581 fn composite_mixed_dep_types() {
582 let mut ctx = EvalContext::new();
583 ctx.set_flag("flag", "yes");
584 ctx.set_file_state("mod.esp", FileState::Active);
585 ctx.game_version = Some("1.5.0".into());
586
587 let comp = CompositeDependency {
588 operator: Operator::And,
589 flag_deps: vec![make_flag_dep("flag", "yes")],
590 file_deps: vec![FileDependency {
591 file: "mod.esp".into(),
592 state: FileState::Active,
593 }],
594 game_deps: vec![GameDependency {
595 version: "1.5.0".into(),
596 }],
597 fomm_deps: vec![],
598 nested: vec![],
599 };
600 assert!(comp.evaluate(&ctx));
601 }
602
603 #[test]
604 fn composite_mixed_one_fails() {
605 let mut ctx = EvalContext::new();
606 ctx.set_flag("flag", "yes");
607 let comp = CompositeDependency {
610 operator: Operator::And,
611 flag_deps: vec![make_flag_dep("flag", "yes")],
612 file_deps: vec![FileDependency {
613 file: "mod.esp".into(),
614 state: FileState::Active,
615 }],
616 game_deps: vec![],
617 fomm_deps: vec![],
618 nested: vec![],
619 };
620 assert!(!comp.evaluate(&ctx));
621 }
622
623 #[test]
624 fn composite_deeply_nested() {
625 let mut ctx = EvalContext::new();
627 ctx.set_flag("deep", "yes");
628
629 let level4 = make_composite(Operator::Or, vec![make_flag_dep("deep", "yes")]);
630 let level3 = CompositeDependency {
631 operator: Operator::And,
632 nested: vec![level4],
633 flag_deps: vec![],
634 file_deps: vec![],
635 game_deps: vec![],
636 fomm_deps: vec![],
637 };
638 let level2 = CompositeDependency {
639 operator: Operator::Or,
640 nested: vec![level3],
641 flag_deps: vec![],
642 file_deps: vec![],
643 game_deps: vec![],
644 fomm_deps: vec![],
645 };
646 let level1 = CompositeDependency {
647 operator: Operator::And,
648 nested: vec![level2],
649 flag_deps: vec![],
650 file_deps: vec![],
651 game_deps: vec![],
652 fomm_deps: vec![],
653 };
654 assert!(level1.evaluate(&ctx));
655 }
656}