Skip to main content

ephemeris_files/
path_policy.rs

1//! Priority-resolved policy for repository paths.
2
3use facet::Facet;
4
5use crate::FilesError;
6use crate::path::{PathSelector, RepositoryPath};
7
8/// One path rule decoded from the evaluated configuration.
9///
10/// `source` is a Nix-side source-membership concern (see
11/// `nix/core/paths/source.nix`). This crate decodes it only because it shares
12/// a wire schema with `managed`, `ignore` and `format`.
13#[derive(Debug, Clone, Facet)]
14#[facet(deny_unknown_fields)]
15pub(crate) struct PathRuleState {
16    /// Which repository-relative paths this rule applies to.
17    pub(crate) selector: PathSelector,
18    /// Nix-side source-membership. Decoded but not resolved here.
19    pub(crate) source: Option<PolicyValue>,
20    /// Whether the generated ignore files list paths here. Nix-side only.
21    pub(crate) ignore: Option<PolicyValue>,
22    /// Whether tracked files here must be generated or marked as generated.
23    pub(crate) managed: Option<PolicyValue>,
24    /// Whether formatters process files here. Nix-side only.
25    pub(crate) format: Option<PolicyValue>,
26}
27
28/// A flag contributed at a given priority. Lower priorities win.
29#[derive(Debug, Clone, Facet)]
30#[facet(deny_unknown_fields)]
31pub(crate) struct PolicyValue {
32    /// The contributed value.
33    pub(crate) value: bool,
34    /// Lower priorities override higher ones. Equal priorities must agree.
35    pub(crate) priority: u32,
36}
37
38pub(crate) struct PathPolicy<'a> {
39    rules: &'a [PathRuleState],
40}
41
42impl<'a> PathPolicy<'a> {
43    /// Build a resolver over normalized path rules.
44    ///
45    /// # Errors
46    /// Returns an error when a rule's selector is invalid.
47    pub(crate) fn new(rules: &'a [PathRuleState]) -> Result<Self, FilesError> {
48        if let Some(rule) = rules.iter().find(|rule| !rule.selector.valid()) {
49            return Err(FilesError::InvalidSelector {
50                selector: rule.selector.clone(),
51            });
52        }
53        Ok(Self { rules })
54    }
55
56    /// Whether tracked files at a repository-relative path must be generated
57    /// or marked as generated.
58    ///
59    /// A path no rule marks is not managed.
60    ///
61    /// # Errors
62    /// Returns an error when two rules disagree on `managed` at the same
63    /// priority.
64    pub(crate) fn managed(&self, path: &RepositoryPath) -> Result<bool, FilesError> {
65        let mut best: Option<(u32, bool, bool)> = None;
66        for value in self
67            .rules
68            .iter()
69            .filter(|rule| rule.selector.matches(path))
70            .filter_map(|rule| rule.managed.as_ref())
71        {
72            match &mut best {
73                Some((priority, first, conflict)) if value.priority == *priority => {
74                    *conflict |= value.value != *first;
75                }
76                Some((priority, _, _)) if value.priority > *priority => {}
77                _ => best = Some((value.priority, value.value, false)),
78            }
79        }
80        match best {
81            None => Ok(false),
82            Some((priority, _, true)) => Err(FilesError::ManagedConflict {
83                path: path.clone(),
84                priority,
85            }),
86            Some((_, value, false)) => Ok(value),
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use facet::Facet;
94
95    use super::{PathPolicy, PathRuleState, PolicyValue};
96    use crate::FilesError;
97    use crate::path::{PathSegment, PathSelector, RepositoryPath};
98
99    #[derive(Facet)]
100    struct Fixture {
101        rules: Vec<PathRuleState>,
102        cases: Vec<Case>,
103    }
104
105    #[derive(Facet)]
106    struct Case {
107        path: String,
108        managed: bool,
109    }
110
111    fn repo(path: &str) -> RepositoryPath {
112        RepositoryPath::try_from(path).expect("valid repository path")
113    }
114
115    fn exact(value: &str) -> PathSelector {
116        PathSelector::Exact {
117            segments: vec![PathSegment::Literal {
118                value: value.into(),
119            }],
120        }
121    }
122
123    fn managed_rule(selector: PathSelector, value: bool, priority: u32) -> PathRuleState {
124        PathRuleState {
125            selector,
126            source: None,
127            ignore: None,
128            format: None,
129            managed: Some(PolicyValue { value, priority }),
130        }
131    }
132
133    #[test]
134    fn shared_nix_fixture_resolves_the_same_managed_state() {
135        // `source`, `ignore` and `format` are Nix-side concerns, so only
136        // `managed` is checked against the shared fixture.
137        let fixture: Fixture =
138            facet_json::from_str(include_str!("../tests/data/path-policy-cases.json"))
139                .expect("shared path-policy fixture decodes");
140        let policy = PathPolicy::new(&fixture.rules).expect("fixture selectors are valid");
141
142        for case in fixture.cases {
143            assert_eq!(
144                policy.managed(&repo(&case.path)).unwrap(),
145                case.managed,
146                "{}",
147                case.path
148            );
149        }
150    }
151
152    #[test]
153    fn a_path_without_a_rule_is_not_managed() {
154        let rules = [managed_rule(exact("other"), true, 1000)];
155        assert!(
156            !PathPolicy::new(&rules)
157                .unwrap()
158                .managed(&repo("file"))
159                .unwrap()
160        );
161    }
162
163    #[test]
164    fn the_lower_priority_wins() {
165        let rules = [
166            managed_rule(exact("file"), true, 10),
167            managed_rule(exact("file"), false, 20),
168        ];
169        assert!(
170            PathPolicy::new(&rules)
171                .unwrap()
172                .managed(&repo("file"))
173                .unwrap()
174        );
175    }
176
177    #[test]
178    fn conflicting_values_at_equal_priority_are_an_error() {
179        let rules = [
180            managed_rule(exact("file"), true, 5),
181            managed_rule(exact("file"), false, 5),
182        ];
183        assert!(matches!(
184            PathPolicy::new(&rules).unwrap().managed(&repo("file")),
185            Err(FilesError::ManagedConflict {
186                priority: 5,
187                path: _
188            })
189        ));
190    }
191}