Skip to main content

ephemeris_forge/
collection.rs

1use std::collections::BTreeMap;
2
3use crate::{Change, FieldDiff, Mode, Report};
4
5/// A keyed collection of forge items, such as labels or webhooks.
6#[derive(Clone, Copy, Debug)]
7pub struct Collection<'a> {
8    /// Names one item in the report, as in `label bug`.
9    pub noun: &'a str,
10    /// Whether live items that the configuration does not declare are deleted.
11    pub mode: Mode,
12}
13
14impl Collection<'_> {
15    /// Plan the operations that make `live` match `declared`.
16    ///
17    /// `matches` tells whether a live item has a declared key. `diff` compares
18    /// one declared item with its live item, if any, and may note limitations
19    /// in the report. It returns the field
20    /// comparison, which is recorded under `{noun} {key}`, and the operations
21    /// for it. `retire` names an undeclared live item and returns its delete
22    /// operation. It returns `None` to leave the item in place.
23    ///
24    /// The operations for declared items come first, in key order. In owned
25    /// mode the deletes of undeclared live items follow, in live order.
26    pub fn plan<K, D, L, O, M, F, R>(
27        self,
28        declared: &BTreeMap<K, D>,
29        live: &[L],
30        report: &mut Report,
31        matches: M,
32        mut diff: F,
33        mut retire: R,
34    ) -> Vec<O>
35    where
36        K: AsRef<str>,
37        M: Fn(&L, &str) -> bool,
38        F: FnMut(&str, &D, Option<&L>, &mut Report) -> (FieldDiff, Vec<O>),
39        R: FnMut(&L, &mut Report) -> Option<(String, O)>,
40    {
41        let mut operations = Vec::new();
42        for (key, desired) in declared {
43            let key = key.as_ref();
44            let actual = live.iter().find(|item| matches(item, key));
45            let (fields, changes) = diff(key, desired, actual, report);
46            let resource = format!("{} {key}", self.noun);
47            if actual.is_none() && fields.has_changes() {
48                report.changes.insert(resource, Change::Create);
49            } else {
50                fields.record(&resource, report);
51            }
52            operations.extend(changes);
53        }
54        if self.mode == Mode::Owned {
55            for item in live {
56                if declared.keys().any(|key| matches(item, key.as_ref())) {
57                    continue;
58                }
59                if let Some((key, operation)) = retire(item, report) {
60                    report.record_delete(format!("{} {key}", self.noun));
61                    operations.push(operation);
62                }
63            }
64        }
65        operations
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[derive(Debug, Eq, PartialEq)]
74    enum Operation {
75        Create(String),
76        Patch(String),
77        Delete(String),
78    }
79
80    fn run(mode: Mode, declared: &[(&str, u32)], live: &[(&str, u32)]) -> (Vec<Operation>, Report) {
81        let declared: BTreeMap<String, u32> = declared
82            .iter()
83            .map(|(key, value)| ((*key).to_owned(), *value))
84            .collect();
85        let live: Vec<(String, u32)> = live
86            .iter()
87            .map(|(key, value)| ((*key).to_owned(), *value))
88            .collect();
89        let mut report = Report::default();
90        let operations = Collection { noun: "item", mode }.plan(
91            &declared,
92            &live,
93            &mut report,
94            |item, key| item.0 == key,
95            |key, desired, actual, _| {
96                let mut fields = FieldDiff::default();
97                let changed = fields.check(
98                    "value",
99                    actual.is_some_and(|item| item.1 == *desired),
100                    &actual.map(|item| item.1),
101                    desired,
102                );
103                let operations = match actual {
104                    None => vec![Operation::Create(key.to_owned())],
105                    Some(_) if changed => vec![Operation::Patch(key.to_owned())],
106                    Some(_) => Vec::new(),
107                };
108                (fields, operations)
109            },
110            |item, _| Some((item.0.clone(), Operation::Delete(item.0.clone()))),
111        );
112        (operations, report)
113    }
114
115    #[test]
116    fn matching_item_yields_nothing() {
117        let (operations, report) = run(Mode::Owned, &[("a", 1)], &[("a", 1)]);
118        assert_eq!(operations, []);
119        assert!(report.changes.is_empty());
120        assert_eq!(report.unchanged["item a"], ["value"]);
121    }
122
123    #[test]
124    fn differing_item_yields_the_diff_operations() {
125        let (operations, report) = run(Mode::Additive, &[("a", 2)], &[("a", 1)]);
126        assert_eq!(operations, [Operation::Patch("a".to_owned())]);
127        assert_eq!(report.changes["item a"].fields(), ["value"]);
128    }
129
130    #[test]
131    fn missing_item_is_created() {
132        let (operations, report) = run(Mode::Additive, &[("a", 1)], &[]);
133        assert_eq!(operations, [Operation::Create("a".to_owned())]);
134        assert_eq!(report.changes["item a"], Change::Create);
135    }
136
137    #[test]
138    fn additive_mode_keeps_undeclared_items() {
139        let (operations, report) = run(Mode::Additive, &[("a", 1)], &[("a", 1), ("b", 1)]);
140        assert_eq!(operations, []);
141        assert!(!report.changes.contains_key("item b"));
142    }
143
144    #[test]
145    fn owned_mode_deletes_undeclared_items_after_declared_ones() {
146        let (operations, report) = run(
147            Mode::Owned,
148            &[("a", 2), ("c", 1)],
149            &[("z", 1), ("a", 1), ("b", 1)],
150        );
151        assert_eq!(
152            operations,
153            [
154                Operation::Patch("a".to_owned()),
155                Operation::Create("c".to_owned()),
156                Operation::Delete("z".to_owned()),
157                Operation::Delete("b".to_owned()),
158            ]
159        );
160        assert_eq!(report.changes["item z"], Change::Delete);
161        assert_eq!(report.changes["item b"], Change::Delete);
162    }
163}