Skip to main content

determinator/
rules.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Custom rules for the target determinator.
5//!
6//! By default, the target determinator follows a simple set of rules:
7//! * Every changed path is matched to its nearest package, and that package is marked changed.
8//! * Cargo builds are simulated against the old and new package graphs, and any packages with
9//!   different results are marked changed.
10//! * The affected set is found through observing simulated Cargo builds and doing a reverse map.
11//!
12//! However, there is often a need to customize these rules, for example to:
13//! * ignore certain files
14//! * build everything if certain files or packages have changed
15//! * add *virtual dependencies* that Cargo may not know about: if a package changes, also consider
16//!   certain other packages as changed.
17//!
18//! These custom behaviors can be specified through *determinator rules*.
19//!
20//! There are two sorts of determinator rules:
21//! * **Path rules** match on changed paths, and are applied **in order**, before regular matches.
22//! * **Package rules** match based on changed packages, and are applied as required until
23//!   exhausted (i.e. a fixpoint is reached).
24//!
25//! Determinator rules are a configuration file format and can be read from a TOML file.
26//!
27//! # Default path rules
28//!
29//! The determinator ships with a set of default path rules for common files such as `.gitignore`
30//! and `Cargo.lock`. These rules are applied *after* custom rules, so custom rules matching the
31//! same paths can override them.
32//!
33//! The default rules can be [viewed here](DeterminatorRules::DEFAULT_RULES_TOML).
34//!
35//! To disable default rules entirely, set at the top level:
36//!
37//! ```toml
38//! use-default-rules = false
39//! ```
40//!
41//! # Examples for path rules
42//!
43//! To ignore all files named `README.md` and `README.tpl`, and skip all further processing:
44//!
45//! ```toml
46//! [[path-rule]]
47//! # Globs are implemented using globset: https://docs.rs/globset/0.4
48//! globs = ["**/README.md", "**/README.tpl"]
49//! mark-changed = []
50//! # "skip" is the default for post-rule, so it can be omitted.
51//! post-rule = "skip"
52//! ```
53//!
54//! To mark a package changed if a file in a different directory changes, but also continue to
55//! use the standard algorithm to match paths to their nearest package:
56//!
57//! ```toml
58//! [[path-rule]]
59//! # Note that globs are relative to the root of the workspace.
60//! globs = ["cargo-guppy/src/lib.rs"]
61//! # Workspace packages are specified through their names.
62//! mark-changed = ["cargo-compare"]
63//! # "skip-rules" means that cargo-guppy/src/lib.rs will also match cargo-guppy.
64//! post-rule = "skip-rules"
65//! ```
66//!
67//! To build everything if a special file changes:
68//!
69//! ```toml
70//! [[path-rule]]
71//! name = "rust-toolchain"
72//! mark-changed = "all"
73//! ```
74//!
75//! To apply multiple rules to a file, say `CODE_OF_CONDUCT.md`:
76//!
77//! ```toml
78//! [[path-rule]]
79//! globs = ["CODE_OF_CONDUCT.md", "CONTRIBUTING.md"]
80//! mark-changed = ["cargo-guppy"]
81//! # "fallthrough" means further rules are applied as well.
82//! post-rule = "fallthrough"
83//!
84//! [[path-rule]]
85//! globs = ["CODE_OF_CONDUCT.md"]
86//! mark-changed = ["guppy"]
87//! ```
88//!
89//! # Examples for package rules
90//!
91//! To add a "virtual dependency" that Cargo may not know about:
92//!
93//! ```toml
94//! [[package-rule]]
95//! on-affected = ["fixtures"]
96//! mark-changed = ["guppy-cmdlib"]
97//! ```
98//!
99//! To build everything if a package changes.
100//!
101//! ```toml
102//! [[package-rule]]
103//! on-affected = ["guppy-benchmarks"]
104//! mark-changed = "all"
105//! ```
106
107use crate::errors::RulesError;
108use globset::{Glob, GlobSet, GlobSetBuilder};
109use guppy::graph::{PackageGraph, PackageMetadata, PackageSet, Workspace};
110use serde::{Deserialize, Serialize};
111use std::{fmt, sync::LazyLock};
112
113/// Rules for the target determinator.
114///
115/// This forms a configuration file format that can be read from a TOML file.
116///
117/// For more about determinator rules, see [the module-level documentation](index.html).
118#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
119#[serde(deny_unknown_fields)]
120pub struct DeterminatorRules {
121    /// Whether to use the default rules, as specified by `DEFAULT_RULES_TOML` and `default_rules`.
122    ///
123    /// This is true by default.
124    #[serde(default = "default_true", rename = "use-default-rules")]
125    use_default_rules: bool,
126
127    /// A list of rules that each changed file path is matched against.
128    #[serde(default, rename = "path-rule")]
129    pub path_rules: Vec<PathRule>,
130
131    /// A list of rules that each affected package is matched against.
132    ///
133    /// Sometimes, dependencies between workspace packages aren't expressed in Cargo.tomls. The
134    /// packages here act as "virtual dependencies" for the determinator.
135    #[serde(default, rename = "package-rule")]
136    pub package_rules: Vec<PackageRule>,
137}
138
139/// The `Default` impl is the set of custom rules used by the determinator if
140/// [`set_rules`](crate::Determinator::set_rules) isn't called. It is an empty set of determinator
141/// rules, with `use_default_rules` set to true. This means that if `set_rules` isn't
142/// called, the only rules in effect are the default ones.
143impl Default for DeterminatorRules {
144    fn default() -> Self {
145        Self {
146            use_default_rules: true,
147            path_rules: vec![],
148            package_rules: vec![],
149        }
150    }
151}
152
153#[inline]
154fn default_true() -> bool {
155    true
156}
157
158/// A hack that lets the contents of default-rules.toml be included.
159macro_rules! doc_comment {
160    ($doc:expr, $($t:tt)*) => (
161        #[doc = $doc]
162        $($t)*
163    );
164}
165
166impl DeterminatorRules {
167    /// Deserializes determinator rules from the given TOML string.
168    pub fn parse(s: &str) -> Result<Self, toml::de::Error> {
169        toml::from_str(s)
170    }
171
172    doc_comment! {
173        concat!("\
174Contains the default rules in a TOML file format.
175
176The default rules included with this copy of the determinator are:
177
178```toml
179", include_str!("../default-rules.toml"), "\
180```
181
182The latest version of the default rules is available
183[on GitHub](https://github.com/guppy-rs/guppy/blob/main/tools/determinator/default-rules.toml).
184"),
185        pub const DEFAULT_RULES_TOML: &'static str = include_str!("../default-rules.toml");
186    }
187
188    /// Returns the default rules.
189    ///
190    /// These rules are applied *after* any custom rules, so they can be overridden by custom rules.
191    pub fn default_rules() -> &'static DeterminatorRules {
192        static DEFAULT_RULES: LazyLock<DeterminatorRules> = LazyLock::new(|| {
193            DeterminatorRules::parse(DeterminatorRules::DEFAULT_RULES_TOML)
194                .expect("default rules should parse")
195        });
196
197        &DEFAULT_RULES
198    }
199}
200
201/// Path-based rules for the determinator.
202///
203/// These rules customize the behavior of the determinator based on changed paths.
204///
205/// # Examples
206///
207/// ```toml
208/// [[path-rule]]
209/// globs = ["**/README.md", "**/README.tpl"]
210/// mark-changed = ["guppy"]
211/// ```
212///
213/// For more examples, see [the module-level documentation](index.html).
214#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
215#[serde(deny_unknown_fields, rename_all = "kebab-case")]
216pub struct PathRule {
217    /// The globs to match against.
218    ///
219    /// A changed path matches a rule if it matches any of the globs on this list.
220    ///
221    /// # Examples
222    ///
223    /// In TOML format, this is specified as [`globset`](https://docs.rs/globset/0.4) globs:
224    ///
225    /// ```toml
226    /// globs = ["foo", "**/bar/*.rs"]
227    /// ```
228    pub globs: Vec<String>,
229
230    /// The set of packages to mark as changed.
231    ///
232    /// # Examples
233    ///
234    /// In TOML format, this may be the string `"all"` to cause all packages to be marked changed:
235    ///
236    /// ```toml
237    /// mark-changed = "all"
238    /// ```
239    ///
240    /// Alternatively, `mark-changed` may be an array of workspace package names:
241    ///
242    /// ```toml
243    /// mark-changed = ["guppy", "determinator"]
244    /// ```
245    #[serde(with = "mark_changed_impl")]
246    pub mark_changed: DeterminatorMarkChanged,
247
248    /// The operation to perform after applying the rule. Set to "skip" by default.
249    #[serde(default)]
250    pub post_rule: DeterminatorPostRule,
251}
252
253/// The operation to perform after applying the rule.
254#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
255#[serde(rename_all = "kebab-case")]
256#[non_exhaustive]
257#[derive(Default)]
258pub enum DeterminatorPostRule {
259    /// Skip all further processing of this path.
260    ///
261    /// This is the default.
262    ///
263    /// # Examples
264    ///
265    /// In TOML format, specified as the string `"skip"`:
266    ///
267    /// ```toml
268    /// post-rule = "skip"
269    /// ```
270    #[default]
271    Skip,
272
273    /// Skip rule processing but continue attempting to match the changed path to the nearest
274    /// package name.
275    ///
276    /// # Examples
277    ///
278    /// In TOML format, specified as the string `"skip-rules"`:
279    ///
280    /// ```toml
281    /// post-rule = "skip-rules"
282    /// ```
283    SkipRules,
284
285    /// Continue to apply further rules.
286    ///
287    /// # Examples
288    ///
289    /// In TOML format, specified as the string `"fallthrough"`:
290    ///
291    /// ```toml
292    /// post-rule = "fallthrough"
293    /// ```
294    Fallthrough,
295}
296
297/// Package-based rules for the determinator.
298///
299/// These rules customize the behavior of the determinator based on affected packages, and can be
300/// used to insert "virtual dependencies" that Cargo may not be aware of.
301///
302/// # Examples
303///
304/// ```toml
305/// [[package-rules]]
306/// on-affected = ["determinator"]
307/// mark-changed = ["guppy"]
308/// ```
309///
310/// For more examples, see [the module-level documentation](index.html).
311#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
312#[serde(deny_unknown_fields, rename_all = "kebab-case")]
313pub struct PackageRule {
314    /// The package names to match against.
315    ///
316    /// If any of the packages in this list is affected, the given packages will be marked changed.
317    ///
318    /// # Examples
319    ///
320    /// In TOML format, specified as an array of workspace package names:
321    ///
322    /// ```toml
323    /// on-affected = ["target-spec", "guppy"]
324    /// ```
325    pub on_affected: Vec<String>,
326
327    /// The set of packages to mark as changed.
328    ///
329    /// # Examples
330    ///
331    /// In TOML format, this may be the string `"all"`:
332    ///
333    /// ```toml
334    /// mark-changed = "all"
335    /// ```
336    ///
337    /// or an array of workspace package names:
338    ///
339    /// ```toml
340    /// mark-changed = ["guppy", "determinator"]
341    /// ```
342    #[serde(with = "mark_changed_impl")]
343    pub mark_changed: DeterminatorMarkChanged,
344}
345
346/// The set of packages to mark as changed.
347///
348/// # Examples
349///
350/// In TOML format, this may be the string `"all"` to cause all packages to be marked changed:
351///
352/// ```toml
353/// mark-changed = "all"
354/// ```
355///
356/// Alternatively, `mark-changed` may be an array of workspace package names:
357///
358/// ```toml
359/// mark-changed = ["guppy", "determinator"]
360/// ```
361///
362/// For more examples, see [the module-level documentation](index.html).
363#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
364#[serde(rename_all = "kebab-case", untagged)]
365pub enum DeterminatorMarkChanged {
366    /// Mark the workspace packages with the given names as changed.
367    ///
368    /// This may be empty:
369    ///
370    /// ```toml
371    /// mark-changed = []
372    /// ```
373    Packages(Vec<String>),
374
375    /// Mark the entire tree as changed. Skip over all further processing and return the entire
376    /// workspace as affected.
377    ///
378    /// This is most useful for global files that affect the environment.
379    All,
380}
381
382/// The result of matching a file path against a determinator.
383///
384/// Returned by `Determinator::match_path`.
385#[derive(Copy, Clone, Debug, Eq, PartialEq)]
386pub enum PathMatch {
387    /// The path matched a rule, causing everything to be rebuilt.
388    RuleMatchedAll,
389    /// The path matched a rule and ancestor-based matching was not followed.
390    ///
391    /// This will not be returned if the matched rule caused ancestor-based matching to happen.
392    RuleMatched(RuleIndex),
393    /// The path was matched to a package through inspecting the parent directories of each path.
394    AncestorMatched,
395    /// The path wasn't matched to a rule or a nearby package, causing everything to be rebuilt.
396    NoMatches,
397}
398
399/// The index of a rule.
400///
401/// Used in `PathMatch` and while returning errors.
402#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
403pub enum RuleIndex {
404    /// The custom path rule at this index.
405    CustomPath(usize),
406    /// The default path rule at this index.
407    DefaultPath(usize),
408    /// The package rule at this index.
409    ///
410    /// All package rules are custom: there are no default package rules.
411    Package(usize),
412}
413
414impl fmt::Display for RuleIndex {
415    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
416        match self {
417            RuleIndex::CustomPath(index) => write!(f, "custom path rule {index}"),
418            RuleIndex::DefaultPath(index) => write!(f, "default path rule {index}"),
419            RuleIndex::Package(index) => write!(f, "package rule {index}"),
420        }
421    }
422}
423
424// ---
425// Private types
426// ---
427
428/// Internal version of determinator rules.
429#[derive(Clone, Debug)]
430pub(crate) struct RulesImpl<'g> {
431    pub(crate) path_rules: Vec<PathRuleImpl<'g>>,
432    pub(crate) package_rules: Vec<PackageRuleImpl<'g>>,
433}
434
435impl<'g> RulesImpl<'g> {
436    pub(crate) fn new(
437        graph: &'g PackageGraph,
438        options: &DeterminatorRules,
439    ) -> Result<Self, RulesError> {
440        let workspace = graph.workspace();
441
442        let custom_path_rules = options
443            .path_rules
444            .iter()
445            .enumerate()
446            .map(|(idx, rule)| (RuleIndex::CustomPath(idx), rule));
447
448        let default_path_rules = if options.use_default_rules {
449            let default_rules = DeterminatorRules::default_rules();
450            default_rules.path_rules.as_slice()
451        } else {
452            &[]
453        };
454
455        let default_path_rules = default_path_rules
456            .iter()
457            .enumerate()
458            .map(|(idx, rule)| (RuleIndex::DefaultPath(idx), rule));
459
460        // Default rules come after custom ones.
461        let path_rules = custom_path_rules
462            .chain(default_path_rules)
463            .map(
464                |(
465                    rule_index,
466                    PathRule {
467                        globs,
468                        mark_changed,
469                        post_rule,
470                    },
471                )| {
472                    // Convert the globs to a globset.
473                    let mut builder = GlobSetBuilder::new();
474                    for glob in globs {
475                        let glob = Glob::new(glob)
476                            .map_err(|err| RulesError::glob_parse(rule_index, err))?;
477                        builder.add(glob);
478                    }
479
480                    let glob_set = builder
481                        .build()
482                        .map_err(|err| RulesError::glob_parse(rule_index, err))?;
483
484                    // Convert workspace paths to packages.
485                    let mark_changed = MarkChangedImpl::new(&workspace, mark_changed)
486                        .map_err(|err| RulesError::resolve_ref(rule_index, err))?;
487
488                    Ok(PathRuleImpl {
489                        rule_index,
490                        glob_set,
491                        mark_changed,
492                        post_rule: *post_rule,
493                    })
494                },
495            )
496            .collect::<Result<Vec<_>, _>>()?;
497
498        let package_rules = options
499            .package_rules
500            .iter()
501            .enumerate()
502            .map(
503                |(
504                    rule_index,
505                    PackageRule {
506                        on_affected,
507                        mark_changed,
508                    },
509                )| {
510                    let rule_index = RuleIndex::Package(rule_index);
511                    let on_affected = graph
512                        .resolve_workspace_names(on_affected)
513                        .map_err(|err| RulesError::resolve_ref(rule_index, err))?;
514                    let mark_changed = MarkChangedImpl::new(&workspace, mark_changed)
515                        .map_err(|err| RulesError::resolve_ref(rule_index, err))?;
516                    Ok(PackageRuleImpl {
517                        on_affected,
518                        mark_changed,
519                    })
520                },
521            )
522            .collect::<Result<Vec<_>, _>>()?;
523
524        Ok(Self {
525            path_rules,
526            package_rules,
527        })
528    }
529}
530
531#[derive(Clone, Debug)]
532pub(crate) struct PathRuleImpl<'g> {
533    pub(crate) rule_index: RuleIndex,
534    pub(crate) glob_set: GlobSet,
535    pub(crate) mark_changed: MarkChangedImpl<'g>,
536    pub(crate) post_rule: DeterminatorPostRule,
537}
538
539#[derive(Clone, Debug)]
540pub(crate) struct PackageRuleImpl<'g> {
541    pub(crate) on_affected: PackageSet<'g>,
542    pub(crate) mark_changed: MarkChangedImpl<'g>,
543}
544
545#[derive(Clone, Debug)]
546pub(crate) enum MarkChangedImpl<'g> {
547    All,
548    Packages(Vec<PackageMetadata<'g>>),
549}
550
551impl<'g> MarkChangedImpl<'g> {
552    fn new(
553        workspace: &Workspace<'g>,
554        mark_changed: &DeterminatorMarkChanged,
555    ) -> Result<Self, guppy::Error> {
556        match mark_changed {
557            DeterminatorMarkChanged::Packages(names) => Ok(MarkChangedImpl::Packages(
558                workspace.members_by_names(names)?,
559            )),
560            DeterminatorMarkChanged::All => Ok(MarkChangedImpl::All),
561        }
562    }
563}
564
565mod mark_changed_impl {
566    use super::*;
567    use serde::{Deserializer, Serializer, de::Error};
568
569    pub fn serialize<S>(
570        mark_changed: &DeterminatorMarkChanged,
571        serializer: S,
572    ) -> Result<S::Ok, S::Error>
573    where
574        S: Serializer,
575    {
576        match mark_changed {
577            DeterminatorMarkChanged::Packages(names) => names.serialize(serializer),
578            DeterminatorMarkChanged::All => "all".serialize(serializer),
579        }
580    }
581
582    pub fn deserialize<'de, D>(deserializer: D) -> Result<DeterminatorMarkChanged, D::Error>
583    where
584        D: Deserializer<'de>,
585    {
586        let d = MarkChangedDeserialized::deserialize(deserializer)?;
587        match d {
588            MarkChangedDeserialized::String(s) => match s.as_str() {
589                "all" => Ok(DeterminatorMarkChanged::All),
590                other => Err(D::Error::custom(format!(
591                    "unknown string for mark-changed: {other}",
592                ))),
593            },
594            MarkChangedDeserialized::VecString(strings) => {
595                Ok(DeterminatorMarkChanged::Packages(strings))
596            }
597        }
598    }
599
600    #[derive(Deserialize)]
601    #[serde(untagged)]
602    enum MarkChangedDeserialized {
603        String(String),
604        VecString(Vec<String>),
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn parse() {
614        let s = r#"[[path-rule]]
615        globs = ["all/*"]
616        mark-changed = "all"
617        post-rule = "fallthrough"
618
619        [[path-rule]]
620        globs = ["all/1/2/*"]
621        mark-changed = ["c"]
622        post-rule = "skip-rules"
623
624        [[path-rule]]
625        globs = ["none/**/test", "foo/bar"]
626        mark-changed = []
627
628        [[package-rule]]
629        on-affected = ["foo"]
630        mark-changed = ["wat"]
631
632        [[package-rule]]
633        on-affected = ["test1"]
634        mark-changed = "all"
635        "#;
636
637        let expected = DeterminatorRules {
638            use_default_rules: true,
639            path_rules: vec![
640                PathRule {
641                    globs: vec!["all/*".to_owned()],
642                    mark_changed: DeterminatorMarkChanged::All,
643                    post_rule: DeterminatorPostRule::Fallthrough,
644                },
645                PathRule {
646                    globs: vec!["all/1/2/*".to_owned()],
647                    mark_changed: DeterminatorMarkChanged::Packages(vec!["c".to_owned()]),
648                    post_rule: DeterminatorPostRule::SkipRules,
649                },
650                PathRule {
651                    globs: vec!["none/**/test".to_owned(), "foo/bar".to_owned()],
652                    mark_changed: DeterminatorMarkChanged::Packages(vec![]),
653                    post_rule: DeterminatorPostRule::Skip,
654                },
655            ],
656            package_rules: vec![
657                PackageRule {
658                    on_affected: vec!["foo".to_string()],
659                    mark_changed: DeterminatorMarkChanged::Packages(vec!["wat".to_string()]),
660                },
661                PackageRule {
662                    on_affected: vec!["test1".to_string()],
663                    mark_changed: DeterminatorMarkChanged::All,
664                },
665            ],
666        };
667
668        assert_eq!(
669            DeterminatorRules::parse(s),
670            Ok(expected),
671            "parse() result matches"
672        );
673    }
674
675    #[test]
676    fn parse_empty() {
677        let expected = DeterminatorRules::default();
678
679        assert_eq!(
680            DeterminatorRules::parse(""),
681            Ok(expected),
682            "parse_empty() returns default"
683        );
684    }
685
686    #[test]
687    fn parse_bad() {
688        let bads = &[
689            // **************
690            // General errors
691            // **************
692
693            // unrecognized section
694            r#"[[foo]]
695            bar = "baz"
696            "#,
697            // unrecognized section
698            r#"[foo]
699            bar = "baz"
700            "#,
701            //
702            // **********
703            // Path rules
704            // **********
705            //
706            // unrecognized key
707            r#"[[path-rule]]
708            globs = ["a/b"]
709            mark-changed = []
710            foo = "bar"
711            "#,
712            // globs is not a list
713            r#"[[path-rule]]
714            globs = "x"
715            mark-changed = []
716            "#,
717            // glob list doesn't have a string
718            r#"[[path-rule]]
719            globs = [123, "a/b"]
720            mark-changed = []
721            "#,
722            // rule totally missing
723            r#"[[path-rule]]
724            "#,
725            // globs missing
726            r#"[[path-rule]]
727            mark-changed = "all"
728            "#,
729            // mark-changed missing
730            r#"[[path-rule]]
731            globs = ["a/b"]
732            "#,
733            // mark-changed is an invalid string
734            r#"[[path-rule]]
735            globs = ["a/b"]
736            mark-changed = "foo"
737            "#,
738            // mark-changed is not a string or list
739            r#"[[path-rule]]
740            globs = ["a/b"]
741            mark-changed = 123
742            "#,
743            // mark-changed is not a list of strings
744            r#"[[path-rule]]
745            globs = ["a/b"]
746            mark-changed = [123, "abc"]
747            "#,
748            // post-rule is invalid
749            r#"[[path-rule]]
750            globs = ["a/b"]
751            mark-changed = []
752            post-rule = "abc"
753            "#,
754            // post-rule is not a string
755            r#"[[path-rule]]
756            globs = ["a/b"]
757            mark-changed = "all"
758            post-rule = []
759            "#,
760            //
761            // *************
762            // Package rules
763            // *************
764            //
765            // unrecognized key
766            r#"[[package-rule]]
767            on-affected = ["foo"]
768            mark-changed = []
769            foo = "bar"
770            "#,
771            // on-affected is not a list
772            r#"[[package-rule]]
773            on-affected = "foo"
774            mark-changed = []
775            "#,
776            // on-affected doesn't contain strings
777            r#"[[package-rule]]
778            on-affected = ["foo", 123]
779            mark-changed = []
780            "#,
781            // mark-changed is not a string or list
782            r#"[[package-rule]]
783            on-affected = ["foo"]
784            mark-changed = 123
785            "#,
786            // mark-changed is not a list of strings
787            r#"[[package-rule]]
788            on-affected = ["foo", 123]
789            mark-changed = ["bar", 456]
790            "#,
791            // mark-changed is an invalid string
792            r#"[[package-rule]]
793            on-affected = ["foo"]
794            mark-changed = "bar"
795            "#,
796            // on-affected is missing
797            r#"[[package-rule]]
798            mark-changed = "all"
799            "#,
800            // mark-changed is missing
801            r#"[[package-rule]]
802            on-affected = ["foo"]
803            "#,
804        ];
805
806        for &bad in bads {
807            let res = DeterminatorRules::parse(bad);
808            if res.is_ok() {
809                panic!(
810                    "parsing should have failed but succeeded:\n\
811                     input = {bad}\n\
812                     output: {res:?}\n"
813                );
814            }
815        }
816    }
817}