rustic_rs/
filtering.rs

1#[cfg(feature = "rhai")]
2use crate::error::RhaiErrorKinds;
3
4#[cfg(feature = "rhai")]
5use std::error::Error;
6use std::{
7    fmt::{Debug, Display},
8    str::FromStr,
9};
10
11#[cfg(feature = "jq")]
12use anyhow::{anyhow, bail};
13use bytesize::ByteSize;
14use derive_more::derive::Display;
15use log::warn;
16use rustic_core::{repofile::SnapshotFile, StringList};
17
18use cached::proc_macro::cached;
19use chrono::{DateTime, Local, NaiveTime};
20use conflate::Merge;
21
22#[cfg(feature = "jq")]
23use jaq_core::{
24    load::{Arena, File, Loader},
25    Compiler, Ctx, Filter, Native, RcIter,
26};
27#[cfg(feature = "jq")]
28use jaq_json::Val;
29#[cfg(feature = "rhai")]
30use rhai::{serde::to_dynamic, Dynamic, Engine, FnPtr, AST};
31use serde::{Deserialize, Serialize};
32#[cfg(feature = "jq")]
33use serde_json::Value;
34use serde_with::{serde_as, DisplayFromStr};
35
36/// A function to filter snapshots
37///
38/// The function is called with a [`SnapshotFile`] and must return a boolean.
39#[cfg(feature = "rhai")]
40#[derive(Clone, Debug)]
41pub(crate) struct SnapshotFn(FnPtr, AST);
42
43#[cfg(feature = "rhai")]
44impl FromStr for SnapshotFn {
45    type Err = RhaiErrorKinds;
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        let engine = Engine::new();
48        let ast = engine.compile(s)?;
49        let func = engine.eval_ast::<FnPtr>(&ast)?;
50        Ok(Self(func, ast))
51    }
52}
53
54#[cfg(feature = "rhai")]
55impl SnapshotFn {
56    /// Call the function with a [`SnapshotFile`]
57    ///
58    /// The function must return a boolean.
59    ///
60    /// # Errors
61    ///
62    // TODO!: add errors!
63    fn call<T: Clone + Send + Sync + 'static>(
64        &self,
65        sn: &SnapshotFile,
66    ) -> Result<T, Box<dyn Error>> {
67        let engine = Engine::new();
68        let sn: Dynamic = to_dynamic(sn)?;
69        Ok(self.0.call::<T>(&engine, &self.1, (sn,))?)
70    }
71}
72
73#[cfg(feature = "rhai")]
74#[cached(key = "String", convert = r#"{ s.to_string() }"#, size = 1)]
75fn string_to_fn(s: &str) -> Option<SnapshotFn> {
76    match SnapshotFn::from_str(s) {
77        Ok(filter_fn) => Some(filter_fn),
78        Err(err) => {
79            warn!("Error evaluating filter-fn {s}: {err}",);
80            None
81        }
82    }
83}
84
85#[cfg(feature = "jq")]
86#[derive(Clone)]
87pub(crate) struct SnapshotJq(Filter<Native<Val>>);
88
89#[cfg(feature = "jq")]
90impl FromStr for SnapshotJq {
91    type Err = anyhow::Error;
92    fn from_str(s: &str) -> Result<Self, Self::Err> {
93        let programm = File { code: s, path: () };
94        let loader = Loader::new(jaq_std::defs().chain(jaq_json::defs()));
95        let arena = Arena::default();
96        let modules = loader
97            .load(&arena, programm)
98            .map_err(|errs| anyhow!("errors loading modules in jq: {errs:?}"))?;
99        let filter = Compiler::<_, Native<_>>::default()
100            .with_funs(jaq_std::funs().chain(jaq_json::funs()))
101            .compile(modules)
102            .map_err(|errs| anyhow!("errors during compiling filters in jq: {errs:?}"))?;
103
104        Ok(Self(filter))
105    }
106}
107
108#[cfg(feature = "jq")]
109impl SnapshotJq {
110    fn call(&self, snap: &SnapshotFile) -> Result<bool, anyhow::Error> {
111        let input = serde_json::to_value(snap)?;
112
113        let inputs = RcIter::new(core::iter::empty());
114        let res = self.0.run((Ctx::new([], &inputs), Val::from(input))).next();
115
116        match res {
117            Some(Ok(val)) => {
118                let val: Value = val.into();
119                match val.as_bool() {
120                    Some(true) => Ok(true),
121                    Some(false) => Ok(false),
122                    None => bail!("expression does not return bool"),
123                }
124            }
125            _ => bail!("expression does not return bool"),
126        }
127    }
128}
129
130#[cfg(feature = "jq")]
131#[cached(key = "String", convert = r#"{ s.to_string() }"#, size = 1)]
132fn string_to_jq(s: &str) -> Option<SnapshotJq> {
133    match SnapshotJq::from_str(s) {
134        Ok(filter_jq) => Some(filter_jq),
135        Err(err) => {
136            warn!("Error evaluating filter-fn {s}: {err}",);
137            None
138        }
139    }
140}
141
142#[serde_as]
143#[derive(Clone, Default, Debug, Serialize, Deserialize, Merge, clap::Parser)]
144#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
145pub struct SnapshotFilter {
146    /// Hostname to filter (can be specified multiple times)
147    #[clap(long = "filter-host", global = true, value_name = "HOSTNAME")]
148    #[merge(strategy=conflate::vec::overwrite_empty)]
149    filter_hosts: Vec<String>,
150
151    /// Label to filter (can be specified multiple times)
152    #[clap(long = "filter-label", global = true, value_name = "LABEL")]
153    #[merge(strategy=conflate::vec::overwrite_empty)]
154    filter_labels: Vec<String>,
155
156    /// Path list to filter (can be specified multiple times)
157    #[clap(long, global = true, value_name = "PATH[,PATH,..]")]
158    #[serde_as(as = "Vec<DisplayFromStr>")]
159    #[merge(strategy=conflate::vec::overwrite_empty)]
160    filter_paths: Vec<StringList>,
161
162    /// Path list to filter exactly (no superset) as given (can be specified multiple times)
163    #[clap(long, global = true, value_name = "PATH[,PATH,..]")]
164    #[serde_as(as = "Vec<DisplayFromStr>")]
165    #[merge(strategy=conflate::vec::overwrite_empty)]
166    filter_paths_exact: Vec<StringList>,
167
168    /// Tag list to filter (can be specified multiple times)
169    #[clap(long, global = true, value_name = "TAG[,TAG,..]")]
170    #[serde_as(as = "Vec<DisplayFromStr>")]
171    #[merge(strategy=conflate::vec::overwrite_empty)]
172    filter_tags: Vec<StringList>,
173
174    /// Tag list to filter exactly (no superset) as given (can be specified multiple times)
175    #[clap(long, global = true, value_name = "TAG[,TAG,..]")]
176    #[serde_as(as = "Vec<DisplayFromStr>")]
177    #[merge(strategy=conflate::vec::overwrite_empty)]
178    filter_tags_exact: Vec<StringList>,
179
180    /// Only use snapshots which are taken after the given given date/time
181    #[serde_as(as = "Option<DisplayFromStr>")]
182    #[clap(long, global = true, value_name = "DATE(TIME)")]
183    #[merge(strategy=conflate::option::overwrite_none)]
184    filter_after: Option<AfterDate>,
185
186    /// Only use snapshots which are taken before the given given date/time
187    #[serde_as(as = "Option<DisplayFromStr>")]
188    #[clap(long, global = true, value_name = "DATE(TIME)")]
189    #[merge(strategy=conflate::option::overwrite_none)]
190    filter_before: Option<BeforeDate>,
191
192    /// Only use snapshots with total size in given range
193    #[serde_as(as = "Option<DisplayFromStr>")]
194    #[clap(long, global = true, value_name = "SIZE")]
195    #[merge(strategy=conflate::option::overwrite_none)]
196    filter_size: Option<SizeRange>,
197
198    /// Only use snapshots with size added to the repo in given range
199    #[serde_as(as = "Option<DisplayFromStr>")]
200    #[clap(long, global = true, value_name = "SIZE")]
201    #[merge(strategy=conflate::option::overwrite_none)]
202    filter_size_added: Option<SizeRange>,
203
204    /// Function to filter snapshots
205    #[cfg(feature = "rhai")]
206    #[clap(long, global = true, value_name = "FUNC")]
207    #[serde_as(as = "Option<DisplayFromStr>")]
208    #[merge(strategy=conflate::option::overwrite_none)]
209    filter_fn: Option<String>,
210
211    /// jq to filter snapshots
212    #[cfg(feature = "jq")]
213    #[clap(long, global = true, value_name = "JQ")]
214    #[serde_as(as = "Option<DisplayFromStr>")]
215    #[merge(strategy=conflate::option::overwrite_none)]
216    filter_jq: Option<String>,
217}
218
219impl SnapshotFilter {
220    /// Check if a [`SnapshotFile`] matches the filter
221    ///
222    /// # Arguments
223    ///
224    /// * `snapshot` - The snapshot to check
225    ///
226    /// # Returns
227    ///
228    /// `true` if the snapshot matches the filter, `false` otherwise
229    #[must_use]
230    pub fn matches(&self, snapshot: &SnapshotFile) -> bool {
231        #[cfg(feature = "rhai")]
232        if let Some(filter_fn) = &self.filter_fn {
233            if let Some(func) = string_to_fn(filter_fn) {
234                match func.call::<bool>(snapshot) {
235                    Ok(result) => {
236                        if !result {
237                            return false;
238                        }
239                    }
240                    Err(err) => {
241                        warn!(
242                            "Error evaluating filter-fn for snapshot {}: {err}",
243                            snapshot.id
244                        );
245                        return false;
246                    }
247                }
248            }
249        }
250        #[cfg(feature = "jq")]
251        if let Some(filter_jq) = &self.filter_jq {
252            if let Some(jq) = string_to_jq(filter_jq) {
253                match jq.call(snapshot) {
254                    Ok(result) => {
255                        if !result {
256                            return false;
257                        }
258                    }
259                    Err(err) => {
260                        warn!(
261                            "Error evaluating filter-jq for snapshot {}: {err}",
262                            snapshot.id
263                        );
264                        return false;
265                    }
266                }
267            }
268        }
269
270        // For the `Option`s we check if the option is set and the condition is not matched. In this case we can early return false.
271        if matches!(&self.filter_after, Some(after) if !after.matches(snapshot.time))
272            || matches!(&self.filter_before, Some(before) if !before.matches(snapshot.time))
273            || matches!((&self.filter_size,&snapshot.summary), (Some(size),Some(summary)) if !size.matches(summary.total_bytes_processed))
274            || matches!((&self.filter_size_added,&snapshot.summary), (Some(size),Some(summary)) if !size.matches(summary.data_added))
275        {
276            return false;
277        }
278
279        // For the the `Vec`s we have two possibilities:
280        // - There exists a suitable matches method on the snapshot item
281        //   (this automatically handles empty filter correctly):
282        snapshot.paths.matches(&self.filter_paths)
283            && snapshot.tags.matches(&self.filter_tags)
284        //  - manually check if the snapshot item is contained in the `Vec`
285        //    but only if the `Vec` is not empty.
286        //    If it is empty, no condition is given.
287            && (self.filter_paths_exact.is_empty()
288                || self.filter_paths_exact.contains(&snapshot.paths))
289            && (self.filter_tags_exact.is_empty()
290                || self.filter_tags_exact.contains(&snapshot.tags))
291            && (self.filter_hosts.is_empty() || self.filter_hosts.contains(&snapshot.hostname))
292            && (self.filter_labels.is_empty() || self.filter_labels.contains(&snapshot.label))
293    }
294}
295
296#[derive(Debug, Clone, Display)]
297struct AfterDate(DateTime<Local>);
298
299impl AfterDate {
300    fn matches(&self, datetime: DateTime<Local>) -> bool {
301        self.0 < datetime
302    }
303}
304
305impl FromStr for AfterDate {
306    type Err = anyhow::Error;
307    fn from_str(s: &str) -> Result<Self, Self::Err> {
308        let before_midnight = NaiveTime::from_hms_nano_opt(23, 59, 59, 999_999_999).unwrap();
309        let datetime = dateparser::parse_with(s, &Local, before_midnight)?;
310        Ok(Self(datetime.into()))
311    }
312}
313
314#[derive(Debug, Clone, Display)]
315struct BeforeDate(DateTime<Local>);
316
317impl BeforeDate {
318    fn matches(&self, datetime: DateTime<Local>) -> bool {
319        datetime < self.0
320    }
321}
322
323impl FromStr for BeforeDate {
324    type Err = anyhow::Error;
325    fn from_str(s: &str) -> Result<Self, Self::Err> {
326        let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
327        let datetime = dateparser::parse_with(s, &Local, midnight)?;
328        Ok(Self(datetime.into()))
329    }
330}
331
332#[derive(Debug, Clone)]
333struct SizeRange {
334    from: Option<ByteSize>,
335    to: Option<ByteSize>,
336}
337
338impl SizeRange {
339    fn matches(&self, size: u64) -> bool {
340        // The matches-expression is only true if the `Option` is `Some` and the size is smaller than from.
341        // Hence, !matches is true either if `self.from` is `None` or if the size >= the values
342        !matches!(self.from, Some(from) if size < from.0)
343        // same logic here, but smaller and greater swapped.
344            && !matches!(self.to, Some(to) if size > to.0)
345    }
346}
347
348fn parse_size(s: &str) -> Result<Option<ByteSize>, String> {
349    let s = s.trim();
350    if s.is_empty() {
351        return Ok(None);
352    }
353    Ok(Some(s.parse()?))
354}
355
356impl FromStr for SizeRange {
357    type Err = String;
358    fn from_str(s: &str) -> Result<Self, Self::Err> {
359        let (from, to) = match s.split_once("..") {
360            Some((s1, s2)) => (parse_size(s1)?, parse_size(s2)?),
361            None => (parse_size(s)?, None),
362        };
363        Ok(Self { from, to })
364    }
365}
366
367impl Display for SizeRange {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        if let Some(from) = self.from {
370            f.write_str(&from.to_string_as(true))?;
371        }
372        f.write_str("..")?;
373        if let Some(to) = self.to {
374            f.write_str(&to.to_string_as(true))?;
375        }
376
377        Ok(())
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use rstest::rstest;
385
386    #[rstest]
387    #[case("..", None, None)]
388    #[case("10", Some(10), None)]
389    #[case("..10k", None, Some(10_000))]
390    #[case("1MB..", Some(1_000_000), None)]
391    #[case("1 MB .. 1 GiB", Some(1_000_000), Some(1_073_741_824))]
392    #[case("10 .. 20 ", Some(10), Some(20))]
393    #[case(" 2G ", Some(2_000_000_000), None)]
394    fn size_range_from_str(
395        #[case] input: SizeRange,
396        #[case] from: Option<u64>,
397        #[case] to: Option<u64>,
398    ) {
399        assert_eq!(input.from.map(|v| v.0), from);
400        assert_eq!(input.to.map(|v| v.0), to);
401    }
402}