Skip to main content

fomod_oxide/
condition.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Trait for types that can be evaluated against an installation context.
5pub trait Evaluate {
6    fn evaluate(&self, ctx: &EvalContext) -> bool;
7}
8
9/// Evaluate a slice of dependencies, yielding results via static dispatch.
10fn 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/// Composite dependency with AND/OR logic, potentially nested.
18#[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    /// Nested composite dependencies for complex logic.
36    #[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        // Case-insensitive file path matching (FOMOD is Windows-centric)
67        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/// FOMM (Fallout Mod Manager) / mod manager version dependency.
108#[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
120/// Check if `current` version satisfies `>= required`.
121fn check_version(current: &Option<String>, required: &str) -> bool {
122    current
123        .as_ref()
124        .map_or(false, |c| compare_versions(c, required))
125}
126
127/// Simple version comparison: current >= required.
128fn 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/// Runtime context for evaluating conditions.
136#[derive(Debug, Default, Clone)]
137pub struct EvalContext {
138    /// Flags set by user plugin selections.
139    pub flags: HashMap<String, String>,
140
141    /// Known file states in the game directory.
142    pub file_states: HashMap<String, FileState>,
143
144    /// Current game version (e.g. "1.5.0.0").
145    pub game_version: Option<String>,
146
147    /// Current mod manager version (for `fommDependency`).
148    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    // ---- compare_versions ----
187
188    #[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        // Shorter current < longer required (fewer components)
208        assert!(!compare_versions("1.2", "1.2.0"));
209        // Longer current with extra segments
210        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        // "alpha" is filtered out, leaving [1, 2]
223        assert!(compare_versions("1.2.alpha", "1.2"));
224        // Both have non-numeric → both filtered to same
225        assert!(compare_versions("1.beta.2", "1.alpha.2"));
226    }
227
228    #[test]
229    fn version_empty_string() {
230        // Empty parses to [] which is < any non-empty
231        assert!(!compare_versions("", "1.0"));
232        // Both empty → [] >= [] is true
233        assert!(compare_versions("", ""));
234    }
235
236    #[test]
237    fn version_leading_zeros() {
238        // "01" parses as 1, "001" as 1
239        assert!(compare_versions("01.002.003", "1.2.3"));
240    }
241
242    #[test]
243    fn version_gaps() {
244        // "1..3" → non-numeric empty filtered → [1, 3]
245        assert!(compare_versions("1..3", "1.3"));
246    }
247
248    // ---- EvalContext ----
249
250    #[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    // ---- FlagDependency ----
282
283    #[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(), // lowercase
322        };
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    // ---- FileDependency ----
338
339    #[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    // ---- GameDependency ----
404
405    #[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    // ---- FommDependency ----
435
436    #[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    // ---- CompositeDependency ----
456
457    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        // (a=1 AND (b=2 OR c=3))
539        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        // (a=WRONG AND (b=2 OR c=3))
561        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        // file not set → Missing, but we require Active
608
609        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        // 5 levels deep: AND(OR(AND(OR(flag=yes))))
626        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}