loot_condition_interpreter/
lib.rs

1#![allow(
2    clippy::doc_markdown,
3    clippy::exhaustive_enums,
4    clippy::must_use_candidate,
5    clippy::missing_errors_doc
6)]
7#![cfg_attr(
8    test,
9    allow(
10        clippy::assertions_on_result_states,
11        clippy::indexing_slicing,
12        clippy::missing_asserts_for_indexing,
13        clippy::panic,
14        clippy::unwrap_used,
15    )
16)]
17mod error;
18mod function;
19
20use std::collections::{HashMap, HashSet};
21use std::fmt;
22use std::ops::DerefMut;
23use std::path::PathBuf;
24use std::str;
25use std::sync::{PoisonError, RwLock, RwLockWriteGuard};
26
27use nom::branch::alt;
28use nom::bytes::complete::tag;
29use nom::character::complete::multispace0;
30use nom::combinator::map;
31use nom::multi::separated_list0;
32use nom::sequence::{delimited, preceded};
33use nom::{IResult, Parser};
34
35use error::ParsingError;
36pub use error::{Error, MoreDataNeeded, ParsingErrorKind};
37use function::Function;
38
39type ParsingResult<'a, T> = IResult<&'a str, T, ParsingError<&'a str>>;
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
42#[non_exhaustive]
43pub enum GameType {
44    Oblivion,
45    Skyrim,
46    SkyrimSE,
47    SkyrimVR,
48    Fallout3,
49    FalloutNV,
50    Fallout4,
51    Fallout4VR,
52    Morrowind,
53    Starfield,
54    OpenMW,
55}
56
57impl GameType {
58    fn supports_light_plugins(self) -> bool {
59        matches!(
60            self,
61            GameType::SkyrimSE
62                | GameType::SkyrimVR
63                | GameType::Fallout4
64                | GameType::Fallout4VR
65                | GameType::Starfield
66        )
67    }
68
69    fn allows_ghosted_plugins(self) -> bool {
70        self != GameType::OpenMW
71    }
72}
73
74#[derive(Debug)]
75pub struct State {
76    game_type: GameType,
77    /// Game Data folder path.
78    data_path: PathBuf,
79    /// Other directories that may contain plugins and other game files, used before data_path and
80    /// in the order they're listed.
81    additional_data_paths: Vec<PathBuf>,
82    /// Lowercased plugin filenames.
83    active_plugins: HashSet<String>,
84    /// Lowercased paths.
85    crc_cache: RwLock<HashMap<String, u32>>,
86    /// Lowercased plugin filenames and their versions as found in description fields.
87    plugin_versions: HashMap<String, String>,
88    /// Conditions that have already been evaluated, and their results.
89    condition_cache: RwLock<HashMap<Function, bool>>,
90}
91
92impl State {
93    pub fn new(game_type: GameType, data_path: PathBuf) -> Self {
94        State {
95            game_type,
96            data_path,
97            additional_data_paths: Vec::default(),
98            active_plugins: HashSet::default(),
99            crc_cache: RwLock::default(),
100            plugin_versions: HashMap::default(),
101            condition_cache: RwLock::default(),
102        }
103    }
104
105    #[must_use]
106    pub fn with_plugin_versions<T: AsRef<str>, V: ToString>(
107        mut self,
108        plugin_versions: &[(T, V)],
109    ) -> Self {
110        self.set_plugin_versions(plugin_versions);
111        self
112    }
113
114    #[must_use]
115    pub fn with_active_plugins<T: AsRef<str>>(mut self, active_plugins: &[T]) -> Self {
116        self.set_active_plugins(active_plugins);
117        self
118    }
119
120    pub fn set_active_plugins<T: AsRef<str>>(&mut self, active_plugins: &[T]) {
121        self.active_plugins = active_plugins
122            .iter()
123            .map(|s| s.as_ref().to_lowercase())
124            .collect();
125    }
126
127    pub fn set_plugin_versions<T: AsRef<str>, V: ToString>(&mut self, plugin_versions: &[(T, V)]) {
128        self.plugin_versions = plugin_versions
129            .iter()
130            .map(|(p, v)| (p.as_ref().to_lowercase(), v.to_string()))
131            .collect();
132    }
133
134    pub fn set_cached_crcs<T: AsRef<str>>(
135        &mut self,
136        plugin_crcs: &[(T, u32)],
137    ) -> Result<(), PoisonError<RwLockWriteGuard<'_, HashMap<String, u32>>>> {
138        let mut writer = self.crc_cache.write().unwrap_or_else(|mut e| {
139            **e.get_mut() = HashMap::new();
140            self.crc_cache.clear_poison();
141            e.into_inner()
142        });
143
144        writer.deref_mut().clear();
145        writer.deref_mut().extend(
146            plugin_crcs
147                .iter()
148                .map(|(p, v)| (p.as_ref().to_lowercase(), *v)),
149        );
150
151        Ok(())
152    }
153
154    pub fn clear_condition_cache(
155        &mut self,
156    ) -> Result<(), PoisonError<RwLockWriteGuard<'_, HashMap<Function, bool>>>> {
157        let mut writer = self.condition_cache.write().unwrap_or_else(|mut e| {
158            **e.get_mut() = HashMap::new();
159            self.crc_cache.clear_poison();
160            e.into_inner()
161        });
162        writer.clear();
163        Ok(())
164    }
165
166    pub fn set_additional_data_paths(&mut self, additional_data_paths: Vec<PathBuf>) {
167        self.additional_data_paths = additional_data_paths;
168    }
169}
170
171/// Compound conditions joined by 'or'
172#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
173pub struct Expression(Vec<CompoundCondition>);
174
175impl Expression {
176    pub fn eval(&self, state: &State) -> Result<bool, Error> {
177        for compound_condition in &self.0 {
178            if compound_condition.eval(state)? {
179                return Ok(true);
180            }
181        }
182        Ok(false)
183    }
184}
185
186impl str::FromStr for Expression {
187    type Err = Error;
188
189    fn from_str(s: &str) -> Result<Self, Self::Err> {
190        parse_expression(s)
191            .map_err(Error::from)
192            .and_then(|(remaining_input, expression)| {
193                if remaining_input.is_empty() {
194                    Ok(expression)
195                } else {
196                    Err(Error::UnconsumedInput(remaining_input.to_owned()))
197                }
198            })
199    }
200}
201
202fn parse_expression(input: &str) -> ParsingResult<'_, Expression> {
203    map(
204        separated_list0(map_err(whitespace(tag("or"))), CompoundCondition::parse),
205        Expression,
206    )
207    .parse(input)
208}
209
210impl fmt::Display for Expression {
211    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
212        let strings: Vec<String> = self.0.iter().map(CompoundCondition::to_string).collect();
213        write!(f, "{}", strings.join(" or "))
214    }
215}
216
217/// Conditions joined by 'and'
218#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
219struct CompoundCondition(Vec<Condition>);
220
221impl CompoundCondition {
222    fn eval(&self, state: &State) -> Result<bool, Error> {
223        for condition in &self.0 {
224            if !condition.eval(state)? {
225                return Ok(false);
226            }
227        }
228        Ok(true)
229    }
230
231    fn parse(input: &str) -> ParsingResult<'_, CompoundCondition> {
232        map(
233            separated_list0(map_err(whitespace(tag("and"))), Condition::parse),
234            CompoundCondition,
235        )
236        .parse(input)
237    }
238}
239
240impl fmt::Display for CompoundCondition {
241    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
242        let strings: Vec<String> = self.0.iter().map(Condition::to_string).collect();
243        write!(f, "{}", strings.join(" and "))
244    }
245}
246
247#[derive(Clone, Debug, PartialEq, Eq, Hash)]
248enum Condition {
249    Function(Function),
250    InvertedFunction(Function),
251    Expression(Expression),
252    InvertedExpression(Expression),
253}
254
255impl Condition {
256    fn eval(&self, state: &State) -> Result<bool, Error> {
257        match self {
258            Condition::Function(f) => f.eval(state),
259            Condition::InvertedFunction(f) => f.eval(state).map(|r| !r),
260            Condition::Expression(e) => e.eval(state),
261            Condition::InvertedExpression(e) => e.eval(state).map(|r| !r),
262        }
263    }
264
265    fn parse(input: &str) -> ParsingResult<'_, Condition> {
266        alt((
267            map(Function::parse, Condition::Function),
268            map(
269                preceded(map_err(whitespace(tag("not"))), Function::parse),
270                Condition::InvertedFunction,
271            ),
272            map(
273                delimited(
274                    map_err(whitespace(tag("("))),
275                    parse_expression,
276                    map_err(whitespace(tag(")"))),
277                ),
278                Condition::Expression,
279            ),
280            map(
281                delimited(
282                    map_err(preceded(whitespace(tag("not")), whitespace(tag("(")))),
283                    parse_expression,
284                    map_err(whitespace(tag(")"))),
285                ),
286                Condition::InvertedExpression,
287            ),
288        ))
289        .parse(input)
290    }
291}
292
293impl fmt::Display for Condition {
294    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
295        match self {
296            Self::Function(function) => write!(f, "{function}"),
297            Self::InvertedFunction(function) => write!(f, "not {function}"),
298            Self::Expression(e) => write!(f, "({e})"),
299            Self::InvertedExpression(e) => write!(f, "not ({e})"),
300        }
301    }
302}
303
304fn map_err<'a, O>(
305    mut parser: impl Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>>,
306) -> impl FnMut(&'a str) -> ParsingResult<'a, O> {
307    move |i| parser.parse(i).map_err(nom::Err::convert)
308}
309
310fn whitespace<'a, O>(
311    parser: impl Fn(&'a str) -> IResult<&'a str, O>,
312) -> impl Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>> {
313    delimited(multispace0, parser, multispace0)
314}
315
316#[cfg(test)]
317mod tests {
318    use crate::function::ComparisonOperator;
319
320    use super::*;
321
322    use std::fs::create_dir_all;
323    use std::str::FromStr;
324
325    fn state<T: Into<PathBuf>>(data_path: T) -> State {
326        let data_path = data_path.into();
327        if !data_path.exists() {
328            create_dir_all(&data_path).unwrap();
329        }
330
331        State {
332            game_type: GameType::Oblivion,
333            data_path,
334            additional_data_paths: Vec::default(),
335            active_plugins: HashSet::new(),
336            crc_cache: RwLock::default(),
337            plugin_versions: HashMap::default(),
338            condition_cache: RwLock::default(),
339        }
340    }
341
342    #[test]
343    fn game_type_supports_light_plugins_should_be_true_for_tes5se_tes5vr_fo4_fo4vr_and_starfield() {
344        assert!(GameType::SkyrimSE.supports_light_plugins());
345        assert!(GameType::SkyrimVR.supports_light_plugins());
346        assert!(GameType::Fallout4.supports_light_plugins());
347        assert!(GameType::Fallout4VR.supports_light_plugins());
348        assert!(GameType::Starfield.supports_light_plugins());
349    }
350
351    #[test]
352    fn game_type_supports_light_master_should_be_false_for_tes3_to_5_fo3_and_fonv() {
353        assert!(!GameType::OpenMW.supports_light_plugins());
354        assert!(!GameType::Morrowind.supports_light_plugins());
355        assert!(!GameType::Oblivion.supports_light_plugins());
356        assert!(!GameType::Skyrim.supports_light_plugins());
357        assert!(!GameType::Fallout3.supports_light_plugins());
358        assert!(!GameType::FalloutNV.supports_light_plugins());
359    }
360
361    #[test]
362    fn expression_from_str_should_error_with_input_on_incomplete_input() {
363        let error = Expression::from_str("file(\"Carg").unwrap_err();
364
365        assert_eq!(
366            "The parser did not consume the following input: \"file(\"Carg\"",
367            error.to_string()
368        );
369    }
370
371    #[test]
372    fn expression_from_str_should_error_with_input_on_invalid_regex() {
373        let error = Expression::from_str("file(\"Carg\\.*(\")").unwrap_err();
374
375        assert_eq!(
376            "An error was encountered while parsing the expression \"Carg\\.*(\": regex parse error:\n    ^Carg\\.*($\n            ^\nerror: unclosed group",
377            error.to_string()
378        );
379    }
380
381    #[test]
382    fn expression_from_str_should_error_with_input_on_invalid_crc() {
383        let error = Expression::from_str("checksum(\"Cargo.toml\", DEADBEEFDEAD)").unwrap_err();
384
385        assert_eq!(
386            "An error was encountered while parsing the expression \"DEADBEEFDEAD\": number too large to fit in target type",
387            error.to_string()
388        );
389    }
390
391    #[test]
392    fn expression_from_str_should_error_with_input_on_directory_regex() {
393        let error = Expression::from_str("file(\"targ.*et/\")").unwrap_err();
394
395        assert_eq!(
396            "An error was encountered while parsing the expression \"targ.*et/\\\")\": \"targ.*et/\" ends in a directory separator",
397            error.to_string()
398        );
399    }
400
401    #[test]
402    fn expression_parse_should_handle_a_single_compound_condition() {
403        let result = Expression::from_str("file(\"Cargo.toml\")").unwrap();
404
405        match result.0.as_slice() {
406            [CompoundCondition(_)] => {}
407            _ => panic!("Expected an expression with one compound condition"),
408        }
409    }
410
411    #[test]
412    fn expression_parse_should_handle_multiple_compound_conditions() {
413        let result = Expression::from_str("file(\"Cargo.toml\") or file(\"Cargo.toml\")").unwrap();
414
415        match result.0.as_slice() {
416            [CompoundCondition(_), CompoundCondition(_)] => {}
417            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
418        }
419    }
420
421    #[test]
422    fn expression_parse_should_error_if_it_does_not_consume_the_whole_input() {
423        let error = Expression::from_str("file(\"Cargo.toml\") foobar").unwrap_err();
424
425        assert_eq!(
426            "The parser did not consume the following input: \" foobar\"",
427            error.to_string()
428        );
429    }
430
431    #[test]
432    fn expression_parsing_should_ignore_whitespace_between_function_arguments() {
433        let is_ok = |s: &str| Expression::from_str(s).is_ok();
434
435        assert!(is_ok("version(\"Cargo.toml\", \"1.2\", ==)"));
436        assert!(is_ok(
437            "version(\"Unofficial Oblivion Patch.esp\",\"3.4.0\",>=)"
438        ));
439        assert!(is_ok(
440            "version(\"Unofficial Skyrim Patch.esp\", \"2.0\", >=)"
441        ));
442        assert!(is_ok("version(\"..\\TESV.exe\", \"1.8\", >) and not checksum(\"EternalShineArmorAndWeapons.esp\",3E85A943)"));
443        assert!(is_ok("version(\"..\\TESV.exe\",\"1.8\",>) and not checksum(\"EternalShineArmorAndWeapons.esp\",3E85A943)"));
444        assert!(is_ok("checksum(\"HM_HotkeyMod.esp\",374C564C)"));
445        assert!(is_ok("checksum(\"HM_HotkeyMod.esp\",CF00AFFD)"));
446        assert!(is_ok(
447            "checksum(\"HM_HotkeyMod.esp\",374C564C) or checksum(\"HM_HotkeyMod.esp\",CF00AFFD)"
448        ));
449        assert!(is_ok("( checksum(\"HM_HotkeyMod.esp\",374C564C) or checksum(\"HM_HotkeyMod.esp\",CF00AFFD) )"));
450        assert!(is_ok("file(\"UFO - Ultimate Follower Overhaul.esp\")"));
451        assert!(is_ok("( checksum(\"HM_HotkeyMod.esp\",374C564C) or checksum(\"HM_HotkeyMod.esp\",CF00AFFD) ) and file(\"UFO - Ultimate Follower Overhaul.esp\")"));
452        assert!(is_ok(
453            "many(\"Deeper Thoughts (\\(Curie\\)|- (Expressive )?Curie)\\.esp\")"
454        ));
455    }
456
457    #[test]
458    fn expression_parsing_should_ignore_line_breaks_when_ignoring_whitespace() {
459        let result = Expression::from_str("file(\"Cargo.toml\")\r\nor\nversion(\"Cargo.toml\",\n\"1.2\",\r\n==)\nand\r\nfile(\"Cargo.toml\")").unwrap();
460
461        match result.0.as_slice() {
462            [CompoundCondition(c1), CompoundCondition(c2)] => {
463                match (c1.as_slice(), c2.as_slice()) {
464                    (
465                        [Condition::Function(_)],
466                        [Condition::Function(_), Condition::Function(_)],
467                    ) => {}
468                    v => panic!("Expected an expression with two compound conditions, got {v:?}",),
469                }
470            }
471            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
472        }
473    }
474
475    #[test]
476    fn compound_condition_parse_should_handle_a_single_condition() {
477        let result = CompoundCondition::parse("file(\"Cargo.toml\")").unwrap().1;
478
479        match result.0.as_slice() {
480            [Condition::Function(Function::FilePath(f))] => {
481                assert_eq!(&PathBuf::from("Cargo.toml"), f);
482            }
483            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
484        }
485    }
486
487    #[test]
488    fn compound_condition_parse_should_handle_multiple_conditions() {
489        let result = CompoundCondition::parse("file(\"Cargo.toml\") and file(\"README.md\")")
490            .unwrap()
491            .1;
492
493        match result.0.as_slice() {
494            [Condition::Function(Function::FilePath(f1)), Condition::Function(Function::FilePath(f2))] =>
495            {
496                assert_eq!(&PathBuf::from("Cargo.toml"), f1);
497                assert_eq!(&PathBuf::from("README.md"), f2);
498            }
499            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
500        }
501    }
502
503    #[test]
504    fn condition_parse_should_handle_a_function() {
505        let result = Condition::parse("file(\"Cargo.toml\")").unwrap().1;
506
507        match result {
508            Condition::Function(Function::FilePath(f)) => {
509                assert_eq!(PathBuf::from("Cargo.toml"), f);
510            }
511            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
512        }
513    }
514
515    #[test]
516    fn condition_parse_should_handle_an_inverted_function() {
517        let result = Condition::parse("not file(\"Cargo.toml\")").unwrap().1;
518
519        match result {
520            Condition::InvertedFunction(Function::FilePath(f)) => {
521                assert_eq!(PathBuf::from("Cargo.toml"), f);
522            }
523            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
524        }
525    }
526
527    #[test]
528    fn condition_parse_should_handle_an_expression_in_parentheses() {
529        let result = Condition::parse("(not file(\"Cargo.toml\"))").unwrap().1;
530
531        match result {
532            Condition::Expression(_) => {}
533            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
534        }
535    }
536
537    #[test]
538    fn condition_parse_should_handle_an_expression_in_parentheses_with_whitespace() {
539        let result = Condition::parse("( not file(\"Cargo.toml\") )").unwrap().1;
540
541        match result {
542            Condition::Expression(_) => {}
543            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
544        }
545    }
546
547    #[test]
548    fn condition_parse_should_handle_an_inverted_expression_in_parentheses() {
549        let result = Condition::parse("not(not file(\"Cargo.toml\"))").unwrap().1;
550
551        match result {
552            Condition::InvertedExpression(_) => {}
553            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
554        }
555    }
556
557    #[test]
558    fn condition_parse_should_handle_an_inverted_expression_in_parentheses_with_whitespace() {
559        let result = Condition::parse("not ( not file(\"Cargo.toml\") )")
560            .unwrap()
561            .1;
562
563        match result {
564            Condition::InvertedExpression(_) => {}
565            v => panic!("Expected an expression with two compound conditions, got {v:?}",),
566        }
567    }
568
569    #[test]
570    fn condition_eval_should_return_function_eval_for_a_function_condition() {
571        let state = state(".");
572
573        let condition = Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml")));
574
575        assert!(condition.eval(&state).unwrap());
576
577        let condition = Condition::Function(Function::FilePath(PathBuf::from("missing")));
578
579        assert!(!condition.eval(&state).unwrap());
580    }
581
582    #[test]
583    fn condition_eval_should_return_expression_eval_for_an_expression_condition() {
584        let state = state(".");
585
586        let condition = Condition::Expression(Expression(vec![CompoundCondition(vec![
587            Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml"))),
588        ])]));
589
590        assert!(condition.eval(&state).unwrap());
591    }
592
593    #[test]
594    fn condition_eval_should_return_inverse_of_function_eval_for_a_not_function_condition() {
595        let state = state(".");
596
597        let condition =
598            Condition::InvertedFunction(Function::FilePath(PathBuf::from("Cargo.toml")));
599
600        assert!(!condition.eval(&state).unwrap());
601
602        let condition = Condition::InvertedFunction(Function::FilePath(PathBuf::from("missing")));
603
604        assert!(condition.eval(&state).unwrap());
605    }
606
607    #[test]
608    fn condition_eval_should_return_inverse_of_expression_eval_for_a_not_expression_condition() {
609        let state = state(".");
610
611        let condition = Condition::InvertedExpression(Expression(vec![CompoundCondition(vec![
612            Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml"))),
613        ])]));
614
615        assert!(!condition.eval(&state).unwrap());
616    }
617
618    #[test]
619    fn condition_fmt_should_format_function_correctly() {
620        let condition = Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml")));
621
622        assert_eq!("file(\"Cargo.toml\")", &format!("{condition}"));
623    }
624
625    #[test]
626    fn condition_fmt_should_format_inverted_function_correctly() {
627        let condition =
628            Condition::InvertedFunction(Function::FilePath(PathBuf::from("Cargo.toml")));
629
630        assert_eq!("not file(\"Cargo.toml\")", &format!("{condition}"));
631    }
632
633    #[test]
634    fn condition_fmt_should_format_expression_correctly() {
635        let condition = Condition::Expression(Expression(vec![CompoundCondition(vec![
636            Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml"))),
637        ])]));
638
639        assert_eq!("(file(\"Cargo.toml\"))", &format!("{condition}"));
640    }
641
642    #[test]
643    fn condition_fmt_should_format_inverted_expression_correctly() {
644        let condition = Condition::InvertedExpression(Expression(vec![CompoundCondition(vec![
645            Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml"))),
646        ])]));
647
648        assert_eq!("not (file(\"Cargo.toml\"))", &format!("{condition}"));
649    }
650
651    #[test]
652    fn compound_condition_eval_should_be_true_if_all_conditions_are_true() {
653        let state = state(".");
654
655        let compound_condition = CompoundCondition(vec![
656            Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml"))),
657            Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml"))),
658        ]);
659
660        assert!(compound_condition.eval(&state).unwrap());
661    }
662
663    #[test]
664    fn compound_condition_eval_should_be_false_if_any_condition_is_false() {
665        let state = state(".");
666
667        let compound_condition = CompoundCondition(vec![
668            Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml"))),
669            Condition::Function(Function::FilePath(PathBuf::from("missing"))),
670        ]);
671
672        assert!(!compound_condition.eval(&state).unwrap());
673    }
674
675    #[test]
676    fn compound_condition_eval_should_return_false_on_first_false_condition() {
677        let state = state(".");
678        let path = "Cargo.toml";
679
680        // If the second function is evaluated, it will result in an error.
681        let compound_condition = CompoundCondition(vec![
682            Condition::InvertedFunction(Function::Readable(PathBuf::from(path))),
683            Condition::Function(Function::ProductVersion(
684                PathBuf::from(path),
685                "1.0.0".into(),
686                ComparisonOperator::Equal,
687            )),
688        ]);
689
690        assert!(!compound_condition.eval(&state).unwrap());
691    }
692
693    #[test]
694    fn compound_condition_fmt_should_format_correctly() {
695        let compound_condition = CompoundCondition(vec![
696            Condition::Function(Function::FilePath(PathBuf::from("Cargo.toml"))),
697            Condition::Function(Function::FilePath(PathBuf::from("missing"))),
698        ]);
699
700        assert_eq!(
701            "file(\"Cargo.toml\") and file(\"missing\")",
702            &format!("{compound_condition}")
703        );
704
705        let compound_condition = CompoundCondition(vec![Condition::Function(Function::FilePath(
706            PathBuf::from("Cargo.toml"),
707        ))]);
708
709        assert_eq!("file(\"Cargo.toml\")", &format!("{compound_condition}"));
710    }
711
712    #[test]
713    fn expression_eval_should_be_true_if_any_compound_condition_is_true() {
714        let state = state(".");
715
716        let expression = Expression(vec![
717            CompoundCondition(vec![Condition::Function(Function::FilePath(
718                PathBuf::from("Cargo.toml"),
719            ))]),
720            CompoundCondition(vec![Condition::Function(Function::FilePath(
721                PathBuf::from("missing"),
722            ))]),
723        ]);
724        assert!(expression.eval(&state).unwrap());
725    }
726
727    #[test]
728    fn expression_eval_should_be_false_if_all_compound_conditions_are_false() {
729        let state = state(".");
730
731        let expression = Expression(vec![
732            CompoundCondition(vec![Condition::Function(Function::FilePath(
733                PathBuf::from("missing"),
734            ))]),
735            CompoundCondition(vec![Condition::Function(Function::FilePath(
736                PathBuf::from("missing"),
737            ))]),
738        ]);
739        assert!(!expression.eval(&state).unwrap());
740    }
741
742    #[test]
743    fn expression_fmt_should_format_correctly() {
744        let expression = Expression(vec![
745            CompoundCondition(vec![Condition::Function(Function::FilePath(
746                PathBuf::from("Cargo.toml"),
747            ))]),
748            CompoundCondition(vec![Condition::Function(Function::FilePath(
749                PathBuf::from("missing"),
750            ))]),
751        ]);
752
753        assert_eq!(
754            "file(\"Cargo.toml\") or file(\"missing\")",
755            &format!("{expression}")
756        );
757
758        let expression = Expression(vec![CompoundCondition(vec![Condition::Function(
759            Function::FilePath(PathBuf::from("Cargo.toml")),
760        )])]);
761
762        assert_eq!("file(\"Cargo.toml\")", &format!("{expression}"));
763    }
764}