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