Skip to main content

rustic_rs/commands/
diff.rs

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