rustic_rs/commands/
diff.rs

1//! `diff` subcommand
2
3use crate::{Application, RUSTIC_APP, repository::CliIndexedRepo, status_err};
4
5use abscissa_core::{Command, Runnable, Shutdown};
6use clap::ValueHint;
7use itertools::{EitherOrBoth, Itertools};
8use log::debug;
9
10use std::{
11    cmp::Ordering,
12    fmt::{Display, Write},
13    path::{Path, PathBuf},
14};
15
16use anyhow::{Context, Result, bail};
17
18use rustic_core::{
19    IndexedFull, LocalDestination, LocalSource, LocalSourceFilterOptions, LocalSourceSaveOptions,
20    LsOptions, Progress, ProgressBars, ReadSource, ReadSourceEntry, Repository, RusticResult,
21    repofile::{Node, NodeType},
22};
23
24#[cfg(feature = "tui")]
25use crate::commands::tui;
26
27/// `diff` subcommand
28#[derive(clap::Parser, Command, Debug)]
29pub(crate) struct DiffCmd {
30    /// Reference snapshot/path
31    #[clap(value_name = "SNAPSHOT1[:PATH1]")]
32    snap1: String,
33
34    /// New snapshot/path (uses PATH2 = PATH1, if not given; uses local path if no snapshot is given)
35    #[clap(value_name = "SNAPSHOT2[:PATH2]|PATH2", value_hint = ValueHint::AnyPath)]
36    snap2: Option<String>,
37
38    /// show differences in metadata
39    #[clap(long)]
40    metadata: bool,
41
42    /// don't check for different file contents
43    #[clap(long)]
44    no_content: bool,
45
46    /// only show differences for identical files, this can be used for a bitrot test on the local path
47    #[clap(long, conflicts_with = "no_content")]
48    only_identical: bool,
49
50    /// Ignore options
51    #[clap(flatten)]
52    ignore_opts: LocalSourceFilterOptions,
53
54    #[cfg(feature = "tui")]
55    /// Run in interactive UI mode
56    #[clap(long, short)]
57    pub interactive: bool,
58}
59
60impl Runnable for DiffCmd {
61    fn run(&self) {
62        if let Err(err) = RUSTIC_APP
63            .config()
64            .repository
65            .run_indexed(|repo| self.inner_run(repo))
66        {
67            status_err!("{}", err);
68            RUSTIC_APP.shutdown(Shutdown::Crash);
69        };
70    }
71}
72
73impl DiffCmd {
74    fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
75        let config = RUSTIC_APP.config();
76
77        let (id1, path1) = arg_to_snap_path(&self.snap1, "");
78        let (id2, path2) = self
79            .snap2
80            .as_ref()
81            .map_or((None, path1), |snap2| arg_to_snap_path(snap2, path1));
82
83        match (id1, id2) {
84            (Some(id1), Some(id2)) => {
85                // diff between two snapshots
86                let snaps = repo.get_snapshots(&[id1, id2])?;
87
88                let snap1 = &snaps[0];
89                let snap2 = &snaps[1];
90
91                #[cfg(feature = "tui")]
92                if self.interactive {
93                    use tui::summary::SummaryMap;
94                    return tui::run(|progress| {
95                        let p = progress.progress_spinner("starting rustic in interactive mode...");
96                        p.finish();
97                        // create app and run it
98                        let diff = tui::Diff::new(
99                            &repo,
100                            snap1.clone(),
101                            snap2.clone(),
102                            path1,
103                            path2,
104                            SummaryMap::default(),
105                        )?;
106                        tui::run_app(progress.terminal, diff)
107                    });
108                }
109
110                let node1 = repo.node_from_snapshot_and_path(snap1, path1)?;
111                let node2 = repo.node_from_snapshot_and_path(snap2, path2)?;
112
113                diff(
114                    repo.ls(&node1, &LsOptions::default())?,
115                    repo.ls(&node2, &LsOptions::default())?,
116                    self.no_content,
117                    |_path, node1, node2| Ok(node1.content == node2.content),
118                    self.metadata,
119                )?;
120            }
121            (Some(id1), None) => {
122                // diff between snapshot and local path
123                #[cfg(feature = "tui")]
124                if self.interactive {
125                    bail!("interactive diff with local path is not yet implemented!");
126                }
127                let snap1 =
128                    repo.get_snapshot_from_str(id1, |sn| config.snapshot_filter.matches(sn))?;
129
130                let node1 = repo.node_from_snapshot_and_path(&snap1, path1)?;
131                let local = LocalDestination::new(path2, false, !node1.is_dir())?;
132                let path2 = PathBuf::from(path2);
133                let is_dir = path2
134                    .metadata()
135                    .with_context(|| format!("Error accessing {path2:?}"))?
136                    .is_dir();
137                let src = LocalSource::new(
138                    LocalSourceSaveOptions::default(),
139                    &self.ignore_opts,
140                    &[&path2],
141                )?
142                .entries()
143                .map(|item| -> RusticResult<_> {
144                    let ReadSourceEntry { path, node, .. } = item?;
145                    let path = if is_dir {
146                        // remove given path prefix for dirs as local path
147                        path.strip_prefix(&path2).unwrap().to_path_buf()
148                    } else {
149                        // ensure that we really get the filename if local path is a file
150                        path2.file_name().unwrap().into()
151                    };
152                    Ok((path, node))
153                });
154
155                if self.only_identical {
156                    diff_identical(
157                        repo.ls(&node1, &LsOptions::default())?,
158                        src,
159                        |path, node1, _node2| identical_content_local(&local, &repo, path, node1),
160                    )?;
161                } else {
162                    diff(
163                        repo.ls(&node1, &LsOptions::default())?,
164                        src,
165                        self.no_content,
166                        |path, node1, _node2| identical_content_local(&local, &repo, path, node1),
167                        self.metadata,
168                    )?;
169                }
170            }
171            (None, _) => {
172                bail!("cannot use local path as first argument");
173            }
174        };
175
176        Ok(())
177    }
178}
179
180/// Split argument into snapshot id and path
181///
182/// # Arguments
183///
184/// * `arg` - argument to split
185/// * `default_path` - default path if no path is given
186///
187/// # Returns
188///
189/// A tuple of the snapshot id and the path
190fn arg_to_snap_path<'a>(arg: &'a str, default_path: &'a str) -> (Option<&'a str>, &'a str) {
191    match arg.split_once(':') {
192        Some(("local", path)) => (None, path),
193        Some((id, path)) => (Some(id), path),
194        None => {
195            if arg.contains('/') {
196                (None, arg)
197            } else {
198                (Some(arg), default_path)
199            }
200        }
201    }
202}
203
204/// Check if the content of a file in a snapshot is identical to the content of a local file
205///
206/// # Arguments
207///
208/// * `local` - local destination
209/// * `repo` - repository
210/// * `path` - path of the file in the snapshot
211/// * `node` - node of the file in the snapshot
212///
213/// # Errors
214///
215/// * [`RepositoryErrorKind::IdNotFound`] - If the id of a blob is not found in the repository
216///
217/// # Returns
218///
219/// `true` if the content of the file in the snapshot is identical to the content of the local file,
220/// `false` otherwise
221///
222/// [`RepositoryErrorKind::IdNotFound`]: rustic_core::error::RepositoryErrorKind::IdNotFound
223fn identical_content_local<P, S: IndexedFull>(
224    local: &LocalDestination,
225    repo: &Repository<P, S>,
226    path: &Path,
227    node: &Node,
228) -> Result<bool> {
229    let Some(mut open_file) = local.get_matching_file(path, node.meta.size) else {
230        return Ok(false);
231    };
232
233    for id in node.content.iter().flatten() {
234        let ie = repo.get_index_entry(id)?;
235        let length: u64 = ie.data_length().into();
236        if !id.blob_matches_reader(length, &mut open_file) {
237            return Ok(false);
238        }
239    }
240    Ok(true)
241}
242
243#[derive(Clone, Copy)]
244pub enum NodeTypeDiff {
245    Identical,
246    Added,
247    Removed,
248    Changed,
249    MetaDataChanged,
250}
251
252#[derive(Clone, Copy)]
253pub enum NodeDiff {
254    File(NodeTypeDiff),
255    Dir(NodeTypeDiff),
256    Symlink(NodeTypeDiff),
257    Other(NodeTypeDiff),
258    TypeChanged,
259}
260use NodeTypeDiff::*;
261
262impl NodeDiff {
263    pub fn from_node_type(t: &NodeType, diff: NodeTypeDiff) -> Self {
264        match t {
265            NodeType::File => Self::File(diff),
266            NodeType::Dir => Self::Dir(diff),
267            NodeType::Symlink { .. } => Self::Symlink(diff),
268            _ => Self::Other(diff),
269        }
270    }
271
272    pub fn from(
273        node1: Option<&Node>,
274        node2: Option<&Node>,
275        equal_content: impl Fn(&Node, &Node) -> bool,
276    ) -> Self {
277        Self::try_from(node1, node2, |node1, node2| Ok(equal_content(node1, node2))).unwrap()
278    }
279
280    pub fn try_from(
281        node1: Option<&Node>,
282        node2: Option<&Node>,
283        equal_content: impl Fn(&Node, &Node) -> Result<bool>,
284    ) -> Result<Self> {
285        let result = match (node1, node2) {
286            (None, Some(node2)) => Self::from_node_type(&node2.node_type, Added),
287            (Some(node1), None) => Self::from_node_type(&node1.node_type, Removed),
288            (Some(node1), Some(node2)) => {
289                let are_both_symlink = matches!(&node1.node_type, NodeType::Symlink { .. })
290                    && matches!(&node2.node_type, NodeType::Symlink { .. });
291                match &node1.node_type {
292                    // if node1.node_type != node2.node_type, they could be different symlinks,
293                    // for this reason we check:
294                    // that their type is different AND that they are not both symlinks
295                    tpe if tpe != &node2.node_type && !are_both_symlink => Self::TypeChanged,
296                    NodeType::Symlink { .. }
297                        if node1.node_type.to_link() != node2.node_type.to_link() =>
298                    {
299                        Self::Symlink(Changed)
300                    }
301                    t => {
302                        if !equal_content(node1, node2)? {
303                            Self::from_node_type(t, Changed)
304                        } else if node1.meta != node2.meta {
305                            Self::from_node_type(t, MetaDataChanged)
306                        } else {
307                            Self::from_node_type(t, Identical)
308                        }
309                    }
310                }
311            }
312            (None, None) => bail!("nothing to compare!"),
313        };
314        Ok(result)
315    }
316
317    pub fn is_identical(self) -> bool {
318        match self {
319            Self::File(diff) | Self::Dir(diff) | Self::Symlink(diff) | Self::Other(diff) => {
320                matches!(diff, Identical)
321            }
322            Self::TypeChanged => false,
323        }
324    }
325
326    pub fn ignore_metadata(self) -> Self {
327        match self {
328            Self::File(MetaDataChanged) => Self::File(Identical),
329            Self::Dir(MetaDataChanged) => Self::Dir(Identical),
330            Self::Symlink(MetaDataChanged) => Self::Symlink(Identical),
331            Self::Other(MetaDataChanged) => Self::Other(Identical),
332            d => d,
333        }
334    }
335}
336
337impl Display for NodeDiff {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        let c = match self {
340            Self::File(diff) | Self::Dir(diff) | Self::Symlink(diff) | Self::Other(diff) => {
341                match diff {
342                    Identical => '=',
343                    Added => '+',
344                    Removed => '-',
345                    Changed => 'M',
346                    MetaDataChanged => 'U',
347                }
348            }
349            Self::TypeChanged => 'T',
350        };
351        f.write_char(c)
352    }
353}
354
355#[derive(Default)]
356pub struct DiffTypeStatistic {
357    pub identical: usize,
358    pub added: usize,
359    pub removed: usize,
360    pub changed: usize,
361    pub metadata_changed: usize,
362}
363
364impl DiffTypeStatistic {
365    pub fn apply(&mut self, diff: NodeTypeDiff) {
366        match diff {
367            Identical => self.identical += 1,
368            Added => self.added += 1,
369            Removed => self.removed += 1,
370            Changed => self.changed += 1,
371            MetaDataChanged => self.metadata_changed += 1,
372        }
373    }
374
375    pub fn is_empty(&self) -> bool {
376        self.identical + self.added + self.removed + self.changed + self.metadata_changed == 0
377    }
378}
379
380impl Display for DiffTypeStatistic {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        f.write_fmt(format_args!(
383            "{} =, {} +, {} -, {} M, {} U",
384            self.identical, self.added, self.removed, self.changed, self.metadata_changed
385        ))?;
386        Ok(())
387    }
388}
389
390/// Statistics about the differences listed with the [`DiffCmd`] command
391#[derive(Default)]
392pub struct DiffStatistics {
393    pub files: DiffTypeStatistic,
394    pub dirs: DiffTypeStatistic,
395    pub symlinks: DiffTypeStatistic,
396    pub others: DiffTypeStatistic,
397    pub node_type_changed: usize,
398}
399
400impl DiffStatistics {
401    pub fn apply(&mut self, diff: NodeDiff) {
402        match diff {
403            NodeDiff::File(t) => self.files.apply(t),
404            NodeDiff::Dir(t) => self.dirs.apply(t),
405            NodeDiff::Symlink(t) => self.symlinks.apply(t),
406            NodeDiff::Other(t) => self.others.apply(t),
407            NodeDiff::TypeChanged => self.node_type_changed += 1,
408        };
409    }
410}
411
412impl Display for DiffStatistics {
413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        if !self.files.is_empty() {
415            f.write_fmt(format_args!("Files   : {}\n", self.files))?;
416        }
417        if !self.dirs.is_empty() {
418            f.write_fmt(format_args!("Dirs    : {}\n", self.dirs))?;
419        }
420        if !self.symlinks.is_empty() {
421            f.write_fmt(format_args!("Symlinks: {}\n", self.symlinks))?;
422        }
423        if !self.others.is_empty() {
424            f.write_fmt(format_args!("Others  : {}\n", self.others))?;
425        }
426
427        // node type change
428        if self.node_type_changed != 0 {
429            f.write_fmt(format_args!(
430                "NodeType:\t{} changed\n",
431                self.node_type_changed
432            ))?;
433        }
434
435        Ok(())
436    }
437}
438
439/// Compare two streams of nodes and print the differences
440///
441/// # Arguments
442///
443/// * `tree_streamer1` - first stream of nodes
444/// * `tree_streamer2` - second stream of nodes
445/// * `no_content` - don't check for different file contents
446/// * `file_identical` - function to check if the content of two files is identical
447/// * `metadata` - show differences in metadata
448///
449/// # Errors
450///
451// TODO!: add errors!
452fn diff(
453    tree_streamer1: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
454    tree_streamer2: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
455    no_content: bool,
456    file_identical: impl Fn(&Path, &Node, &Node) -> Result<bool>,
457    metadata: bool,
458) -> Result<()> {
459    let compare_streamer = tree_streamer1.merge_join_by(tree_streamer2, |left, right| {
460        let Ok(left) = left else {
461            return Ordering::Less;
462        };
463        let Ok(right) = right else {
464            return Ordering::Greater;
465        };
466        left.0.cmp(&right.0)
467    });
468
469    let mut diff_statistics = DiffStatistics::default();
470
471    for item in compare_streamer {
472        let (path, node1, node2) = match item {
473            EitherOrBoth::Left(l) => {
474                let l = l?;
475                (l.0, Some(l.1), None)
476            }
477            EitherOrBoth::Right(r) => {
478                let r = r?;
479                (r.0, None, Some(r.1))
480            }
481            EitherOrBoth::Both(l, r) => {
482                let (r, l) = (r?, l?);
483                (l.0, Some(l.1), Some(r.1))
484            }
485        };
486
487        let mut diff = NodeDiff::try_from(node1.as_ref(), node2.as_ref(), |n1, n2| {
488            Ok(match n1.node_type {
489                NodeType::File => no_content || file_identical(&path, n1, n2)?,
490                NodeType::Dir => true,
491                _ => false,
492            })
493        })?;
494        if !metadata {
495            diff = diff.ignore_metadata();
496        }
497
498        if !diff.is_identical() {
499            println!("{diff}    {path:?}");
500        }
501        diff_statistics.apply(diff);
502    }
503
504    println!("{diff_statistics}");
505    Ok(())
506}
507
508fn diff_identical(
509    mut tree_streamer1: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
510    mut tree_streamer2: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
511    file_identical: impl Fn(&Path, &Node, &Node) -> Result<bool>,
512) -> Result<()> {
513    let mut item1 = tree_streamer1.next().transpose()?;
514    let mut item2 = tree_streamer2.next().transpose()?;
515
516    let mut checked: usize = 0;
517
518    loop {
519        match (&item1, &item2) {
520            (None, None) => break,
521            (Some(i1), None) => {
522                let path = &i1.0;
523                debug!("not checking {}: not present in target", path.display());
524                item1 = tree_streamer1.next().transpose()?;
525            }
526            (None, Some(i2)) => {
527                let path = &i2.0;
528                debug!("not checking {}: not present in source", path.display());
529                item2 = tree_streamer2.next().transpose()?;
530            }
531            (Some(i1), Some(i2)) if i1.0 < i2.0 => {
532                let path = &i1.0;
533                debug!("not checking {}: not present in target", path.display());
534                item1 = tree_streamer1.next().transpose()?;
535            }
536            (Some(i1), Some(i2)) if i1.0 > i2.0 => {
537                let path = &i2.0;
538                debug!("not checking {}: not present in source", path.display());
539                item2 = tree_streamer2.next().transpose()?;
540            }
541            (Some(i1), Some(i2)) => {
542                let path = &i1.0;
543                let node1 = &i1.1;
544                let node2 = &i2.1;
545
546                if matches!(&node1.node_type, NodeType::File)
547                    && matches!(&node2.node_type, NodeType::File)
548                    && node1.meta == node2.meta
549                {
550                    debug!("checking {}", path.display());
551                    checked += 1;
552                    if !file_identical(path, node1, node2)? {
553                        println!("M    {path:?}");
554                    }
555                } else {
556                    debug!("not checking {}: metadata changed", path.display());
557                }
558                item1 = tree_streamer1.next().transpose()?;
559                item2 = tree_streamer2.next().transpose()?;
560            }
561        }
562    }
563    println!("checked {checked} files.");
564    Ok(())
565}