Skip to main content

rustic_core/repofile/snapshotfile/
grouping.rs

1use std::{
2    cmp::Ordering,
3    fmt::{self, Display},
4    str::FromStr,
5};
6
7use derive_setters::Setters;
8use itertools::Itertools;
9use serde::{Deserialize, Serialize};
10use serde_with::skip_serializing_none;
11
12use crate::{
13    ForgetSnapshot, StringList,
14    repofile::{
15        SnapshotFile,
16        snapshotfile::{SnapshotFileErrorKind, SnapshotFileResult},
17    },
18};
19
20/// [`SnapshotGroupCriterion`] determines how to group snapshots.
21///
22/// `Default` grouping is by hostname, label and paths.
23#[allow(clippy::struct_excessive_bools)]
24#[derive(Clone, Debug, Copy, Setters, Deserialize, Serialize)]
25#[setters(into)]
26#[non_exhaustive]
27pub struct SnapshotGroupCriterion {
28    /// Whether to group by hostnames
29    pub hostname: bool,
30
31    /// Whether to group by labels
32    pub label: bool,
33
34    /// Whether to group by paths
35    pub paths: bool,
36
37    /// Whether to group by tags
38    pub tags: bool,
39}
40
41impl SnapshotGroupCriterion {
42    /// Create a new empty `SnapshotGroupCriterion`
43    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            hostname: false,
47            label: false,
48            paths: false,
49            tags: false,
50        }
51    }
52
53    /// Create a `SnapshotGroupCriterion` from a `SnapshotGroup`
54    #[must_use]
55    pub fn from_group(group: &SnapshotGroup) -> Self {
56        Self {
57            hostname: group.hostname.is_some(),
58            label: group.label.is_some(),
59            paths: group.paths.is_some(),
60            tags: group.tags.is_some(),
61        }
62    }
63}
64
65impl Default for SnapshotGroupCriterion {
66    fn default() -> Self {
67        Self {
68            hostname: true,
69            label: true,
70            paths: true,
71            tags: false,
72        }
73    }
74}
75
76impl FromStr for SnapshotGroupCriterion {
77    type Err = SnapshotFileErrorKind;
78    fn from_str(s: &str) -> SnapshotFileResult<Self> {
79        let mut crit = Self::new();
80        for val in s.split(',') {
81            match val {
82                "host" => crit.hostname = true,
83                "label" => crit.label = true,
84                "paths" => crit.paths = true,
85                "tags" => crit.tags = true,
86                "" => {}
87                v => return Err(SnapshotFileErrorKind::ValueNotAllowed(v.into())),
88            }
89        }
90        Ok(crit)
91    }
92}
93
94impl Display for SnapshotGroupCriterion {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        let mut display = Vec::new();
97        if self.hostname {
98            display.push("host");
99        }
100        if self.label {
101            display.push("label");
102        }
103        if self.paths {
104            display.push("paths");
105        }
106        if self.tags {
107            display.push("tags");
108        }
109        write!(f, "{}", display.join(","))?;
110        Ok(())
111    }
112}
113
114#[skip_serializing_none]
115#[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
116#[non_exhaustive]
117/// [`SnapshotGroup`] specifies the group after a grouping using [`SnapshotGroupCriterion`].
118pub struct SnapshotGroup {
119    /// Group hostname, if grouped by hostname
120    pub hostname: Option<String>,
121
122    /// Group label, if grouped by label
123    pub label: Option<String>,
124
125    /// Group paths, if grouped by paths
126    pub paths: Option<StringList>,
127
128    /// Group tags, if grouped by tags
129    pub tags: Option<StringList>,
130}
131
132impl PartialOrd for SnapshotGroup {
133    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
134        Some(self.cmp(other))
135    }
136}
137
138impl Ord for SnapshotGroup {
139    fn cmp(&self, other: &Self) -> Ordering {
140        self.hostname
141            .cmp(&other.hostname)
142            .then(self.label.cmp(&other.label))
143            .then(self.paths.cmp(&other.paths))
144            .then(self.tags.cmp(&other.tags))
145    }
146}
147
148impl Display for SnapshotGroup {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        let mut out = Vec::new();
151
152        if let Some(host) = &self.hostname {
153            out.push(format!("host [{host}]"));
154        }
155        if let Some(label) = &self.label {
156            out.push(format!("label [{label}]"));
157        }
158        if let Some(paths) = &self.paths {
159            out.push(format!("paths [{paths}]"));
160        }
161        if let Some(tags) = &self.tags {
162            out.push(format!("tags [{tags}]"));
163        }
164
165        write!(f, "({})", out.join(", "))?;
166        Ok(())
167    }
168}
169
170impl SnapshotGroup {
171    /// Extracts the suitable [`SnapshotGroup`] from a [`SnapshotFile`] using a given [`SnapshotGroupCriterion`].
172    ///
173    /// # Arguments
174    ///
175    /// * `sn` - The [`SnapshotFile`] to extract the [`SnapshotGroup`] from
176    /// * `crit` - The [`SnapshotGroupCriterion`] to use
177    #[must_use]
178    pub fn from_snapshot(sn: &SnapshotFile, crit: SnapshotGroupCriterion) -> Self {
179        Self {
180            hostname: crit.hostname.then(|| sn.hostname.clone()),
181            label: crit.label.then(|| sn.label.clone()),
182            paths: crit.paths.then(|| sn.paths.clone()),
183            tags: crit.tags.then(|| sn.tags.clone()),
184        }
185    }
186
187    /// Check if the [`SnapshotFile`] is in the [`SnapshotGroup`].
188    ///
189    /// # Arguments
190    ///
191    /// * `group` - The [`SnapshotGroup`] to check
192    #[must_use]
193    pub fn matches(&self, snapshot: &SnapshotFile) -> bool {
194        self.hostname
195            .as_ref()
196            .is_none_or(|val| val == &snapshot.hostname)
197            && self.label.as_ref().is_none_or(|val| val == &snapshot.label)
198            && self.paths.as_ref().is_none_or(|val| val == &snapshot.paths)
199            && self.tags.as_ref().is_none_or(|val| val == &snapshot.tags)
200    }
201
202    /// Returns whether this is an empty group, i.e. no grouping information is contained.
203    #[must_use]
204    pub fn is_empty(&self) -> bool {
205        self == &Self::default()
206    }
207}
208
209pub trait Grouping {
210    type GroupKey: PartialEq + Ord + fmt::Debug;
211    type Criterion: Copy;
212    fn get_group(&self, c: Self::Criterion) -> Self::GroupKey;
213}
214
215impl Grouping for SnapshotFile {
216    type GroupKey = SnapshotGroup;
217    type Criterion = SnapshotGroupCriterion;
218    fn get_group(&self, c: Self::Criterion) -> Self::GroupKey {
219        SnapshotGroup::from_snapshot(self, c)
220    }
221}
222
223impl Grouping for ForgetSnapshot {
224    type GroupKey = SnapshotGroup;
225    type Criterion = SnapshotGroupCriterion;
226    fn get_group(&self, c: Self::Criterion) -> Self::GroupKey {
227        SnapshotGroup::from_snapshot(&self.snapshot, c)
228    }
229}
230
231#[derive(Debug, Clone, Deserialize, Serialize)]
232/// A group is a `Vec` of items with identical `group_key`
233pub struct Group<T: Grouping> {
234    /// The key for this group
235    pub group_key: T::GroupKey,
236    /// The items of the group
237    pub items: Vec<T>,
238}
239
240impl<T: Grouping> Group<T>
241where
242    T::GroupKey: Default,
243{
244    /// A group where `group_key` is the default for this group key
245    #[must_use]
246    pub fn default_group(items: Vec<T>) -> Self {
247        Self {
248            group_key: T::GroupKey::default(),
249            items,
250        }
251    }
252}
253
254#[derive(Debug)]
255/// A grouped list of items
256pub struct Grouped<T: Grouping> {
257    /// The criterion used for groupung
258    pub criterion: T::Criterion,
259    /// The groups
260    pub groups: Vec<Group<T>>,
261}
262
263impl<T: Grouping> Grouped<T> {
264    /// Create a new empty group of snapshots
265    #[must_use]
266    pub fn new(criterion: T::Criterion) -> Self {
267        Self {
268            criterion,
269            groups: Vec::new(),
270        }
271    }
272
273    /// Crate a group of items by grouping them with `criterion`
274    #[must_use]
275    pub fn from_items(mut items: Vec<T>, criterion: T::Criterion) -> Self {
276        items.sort_unstable_by_key(|item| item.get_group(criterion));
277        let mut groups = Vec::new();
278        for (group, snaps) in &items.into_iter().chunk_by(|item| item.get_group(criterion)) {
279            groups.push(Group {
280                group_key: group,
281                items: snaps.collect(),
282            });
283        }
284        Self { criterion, groups }
285    }
286
287    /// Update the group using `update` on the `Vec` of items
288    ///
289    /// # Errors
290    ///
291    /// * If `update` returns an error
292    pub fn try_update_with<E>(
293        self,
294        update: impl FnOnce(Vec<T>) -> Result<Vec<T>, E>,
295    ) -> Result<Self, E> {
296        let crit = self.criterion;
297        let items = update(self.into())?;
298        Ok(Self::from_items(items, crit))
299    }
300}
301
302impl<T: Grouping> From<Grouped<T>> for Vec<T> {
303    fn from(value: Grouped<T>) -> Self {
304        value
305            .groups
306            .into_iter()
307            .flat_map(|group| group.items)
308            .collect()
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use rstest::rstest;
316
317    #[rstest]
318    #[case(
319        "host,label,paths",
320        true,
321        true,
322        true,
323        false,
324        "host,label,paths",
325        "(host [myhost], label [mylabel], paths [/path])"
326    )]
327    #[case("host", true, false, false, false, "host", "(host [myhost])")]
328    #[case(
329        "label,host",
330        true,
331        true,
332        false,
333        false,
334        "host,label",
335        "(host [myhost], label [mylabel])"
336    )]
337    #[case("tags", false, false, false, true, "tags", "(tags [tag1,tag2])")]
338    #[case(
339        "paths,label",
340        false,
341        true,
342        true,
343        false,
344        "label,paths",
345        "(label [mylabel], paths [/path])"
346    )]
347    fn fromstr_display(
348        #[case] input: String,
349        #[case] is_host: bool,
350        #[case] is_label: bool,
351        #[case] is_path: bool,
352        #[case] is_tags: bool,
353        #[case] display: String,
354        #[case] group_display: String,
355    ) {
356        let crit: SnapshotGroupCriterion = input.parse().unwrap();
357        assert_eq!(crit.hostname, is_host);
358        assert_eq!(crit.label, is_label);
359        assert_eq!(crit.paths, is_path);
360        assert_eq!(crit.tags, is_tags);
361
362        assert_eq!(crit.to_string(), display);
363
364        let sn = SnapshotFile {
365            hostname: "myhost".to_string(),
366            label: "mylabel".to_string(),
367            paths: "/path".parse().unwrap(),
368            tags: "tag1,tag2".parse().unwrap(),
369            ..Default::default()
370        };
371
372        let group = SnapshotGroup::from_snapshot(&sn, crit);
373        assert_eq!(group.to_string(), group_display);
374    }
375}