rustic_rs/commands/
diff.rs

1//! `diff` subcommand
2
3use crate::{repository::CliIndexedRepo, status_err, Application, RUSTIC_APP};
4
5use abscissa_core::{Command, Runnable, Shutdown};
6use clap::ValueHint;
7use log::debug;
8
9use std::{
10    fmt::Display,
11    path::{Path, PathBuf},
12};
13
14use anyhow::{bail, Context, Result};
15
16use rustic_core::{
17    repofile::{Node, NodeType},
18    IndexedFull, LocalDestination, LocalSource, LocalSourceFilterOptions, LocalSourceSaveOptions,
19    LsOptions, ReadSource, ReadSourceEntry, Repository, RusticResult,
20};
21
22/// `diff` subcommand
23#[derive(clap::Parser, Command, Debug)]
24pub(crate) struct DiffCmd {
25    /// Reference snapshot/path
26    #[clap(value_name = "SNAPSHOT1[:PATH1]")]
27    snap1: String,
28
29    /// New snapshot/path or local path [default for PATH2: PATH1]
30    #[clap(value_name = "SNAPSHOT2[:PATH2]|PATH2", value_hint = ValueHint::AnyPath)]
31    snap2: String,
32
33    /// show differences in metadata
34    #[clap(long)]
35    metadata: bool,
36
37    /// don't check for different file contents
38    #[clap(long)]
39    no_content: bool,
40
41    /// only show differences for identical files, this can be used for a bitrot test on the local path
42    #[clap(long, conflicts_with = "no_content")]
43    only_identical: bool,
44
45    /// Ignore options
46    #[clap(flatten)]
47    ignore_opts: LocalSourceFilterOptions,
48}
49
50impl Runnable for DiffCmd {
51    fn run(&self) {
52        if let Err(err) = RUSTIC_APP
53            .config()
54            .repository
55            .run_indexed(|repo| self.inner_run(repo))
56        {
57            status_err!("{}", err);
58            RUSTIC_APP.shutdown(Shutdown::Crash);
59        };
60    }
61}
62
63impl DiffCmd {
64    fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
65        let config = RUSTIC_APP.config();
66
67        let (id1, path1) = arg_to_snap_path(&self.snap1, "");
68        let (id2, path2) = arg_to_snap_path(&self.snap2, path1);
69
70        match (id1, id2) {
71            (Some(id1), Some(id2)) => {
72                // diff between two snapshots
73                let snaps = repo.get_snapshots(&[id1, id2])?;
74
75                let snap1 = &snaps[0];
76                let snap2 = &snaps[1];
77
78                let node1 = repo.node_from_snapshot_and_path(snap1, path1)?;
79                let node2 = repo.node_from_snapshot_and_path(snap2, path2)?;
80
81                diff(
82                    repo.ls(&node1, &LsOptions::default())?,
83                    repo.ls(&node2, &LsOptions::default())?,
84                    self.no_content,
85                    |_path, node1, node2| Ok(node1.content == node2.content),
86                    self.metadata,
87                )?;
88            }
89            (Some(id1), None) => {
90                // diff between snapshot and local path
91                let snap1 =
92                    repo.get_snapshot_from_str(id1, |sn| config.snapshot_filter.matches(sn))?;
93
94                let node1 = repo.node_from_snapshot_and_path(&snap1, path1)?;
95                let local = LocalDestination::new(path2, false, !node1.is_dir())?;
96                let path2 = PathBuf::from(path2);
97                let is_dir = path2
98                    .metadata()
99                    .with_context(|| format!("Error accessing {path2:?}"))?
100                    .is_dir();
101                let src = LocalSource::new(
102                    LocalSourceSaveOptions::default(),
103                    &self.ignore_opts,
104                    &[&path2],
105                )?
106                .entries()
107                .map(|item| -> RusticResult<_> {
108                    let ReadSourceEntry { path, node, .. } = item?;
109                    let path = if is_dir {
110                        // remove given path prefix for dirs as local path
111                        path.strip_prefix(&path2).unwrap().to_path_buf()
112                    } else {
113                        // ensure that we really get the filename if local path is a file
114                        path2.file_name().unwrap().into()
115                    };
116                    Ok((path, node))
117                });
118
119                if self.only_identical {
120                    diff_identical(
121                        repo.ls(&node1, &LsOptions::default())?,
122                        src,
123                        |path, node1, _node2| identical_content_local(&local, &repo, path, node1),
124                    )?;
125                } else {
126                    diff(
127                        repo.ls(&node1, &LsOptions::default())?,
128                        src,
129                        self.no_content,
130                        |path, node1, _node2| identical_content_local(&local, &repo, path, node1),
131                        self.metadata,
132                    )?;
133                }
134            }
135            (None, _) => {
136                bail!("cannot use local path as first argument");
137            }
138        };
139
140        Ok(())
141    }
142}
143
144/// Split argument into snapshot id and path
145///
146/// # Arguments
147///
148/// * `arg` - argument to split
149/// * `default_path` - default path if no path is given
150///
151/// # Returns
152///
153/// A tuple of the snapshot id and the path
154fn arg_to_snap_path<'a>(arg: &'a str, default_path: &'a str) -> (Option<&'a str>, &'a str) {
155    match arg.split_once(':') {
156        Some(("local", path)) => (None, path),
157        Some((id, path)) => (Some(id), path),
158        None => {
159            if arg.contains('/') {
160                (None, arg)
161            } else {
162                (Some(arg), default_path)
163            }
164        }
165    }
166}
167
168/// Check if the content of a file in a snapshot is identical to the content of a local file
169///
170/// # Arguments
171///
172/// * `local` - local destination
173/// * `repo` - repository
174/// * `path` - path of the file in the snapshot
175/// * `node` - node of the file in the snapshot
176///
177/// # Errors
178///
179/// * [`RepositoryErrorKind::IdNotFound`] - If the id of a blob is not found in the repository
180///
181/// # Returns
182///
183/// `true` if the content of the file in the snapshot is identical to the content of the local file,
184/// `false` otherwise
185///
186/// [`RepositoryErrorKind::IdNotFound`]: rustic_core::error::RepositoryErrorKind::IdNotFound
187fn identical_content_local<P, S: IndexedFull>(
188    local: &LocalDestination,
189    repo: &Repository<P, S>,
190    path: &Path,
191    node: &Node,
192) -> Result<bool> {
193    let Some(mut open_file) = local.get_matching_file(path, node.meta.size) else {
194        return Ok(false);
195    };
196
197    for id in node.content.iter().flatten() {
198        let ie = repo.get_index_entry(id)?;
199        let length = ie.data_length();
200        if !id.blob_matches_reader(length as usize, &mut open_file) {
201            return Ok(false);
202        }
203    }
204    Ok(true)
205}
206
207/// Statistics about the differences listed with the [`DiffCmd`] command
208#[derive(Default)]
209struct DiffStatistics {
210    files_added: usize,
211    files_removed: usize,
212    files_changed: usize,
213    directories_added: usize,
214    directories_removed: usize,
215    others_added: usize,
216    others_removed: usize,
217    node_type_changed: usize,
218    metadata_changed: usize,
219    symlink_added: usize,
220    symlink_removed: usize,
221    symlink_changed: usize,
222}
223
224impl DiffStatistics {
225    fn removed_node(&mut self, node_type: &NodeType) {
226        match node_type {
227            NodeType::File => self.files_removed += 1,
228            NodeType::Dir => self.directories_removed += 1,
229            NodeType::Symlink { .. } => self.symlink_removed += 1,
230            _ => self.others_removed += 1,
231        }
232    }
233
234    fn added_node(&mut self, node_type: &NodeType) {
235        match node_type {
236            NodeType::File => self.files_added += 1,
237            NodeType::Dir => self.directories_added += 1,
238            NodeType::Symlink { .. } => self.symlink_added += 1,
239            _ => self.others_added += 1,
240        }
241    }
242
243    fn changed_file(&mut self) {
244        self.files_changed += 1;
245    }
246
247    fn changed_node_type(&mut self) {
248        self.node_type_changed += 1;
249    }
250
251    fn changed_metadata(&mut self) {
252        self.metadata_changed += 1;
253    }
254
255    fn changed_symlink(&mut self) {
256        self.symlink_changed += 1;
257    }
258}
259
260impl Display for DiffStatistics {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        f.write_fmt(format_args!(
263            "Files   :\t{} new,\t{} removed,\t{} changed\n",
264            self.files_added, self.files_removed, self.files_changed
265        ))?;
266        // symlink
267        if self.symlink_added != 0 || self.symlink_removed != 0 || self.symlink_changed != 0 {
268            f.write_fmt(format_args!(
269                "Symlinks:\t{} new,\t{} removed,\t{} changed\n",
270                self.symlink_added, self.symlink_removed, self.symlink_changed
271            ))?;
272        }
273        f.write_fmt(format_args!(
274            "Dirs    :\t{} new,\t{} removed\n",
275            self.directories_added, self.directories_removed
276        ))?;
277        if self.others_added != 0 || self.others_removed != 0 {
278            f.write_fmt(format_args!(
279                "Others  :\t{} new,\t{} removed\n",
280                self.others_added, self.others_removed
281            ))?;
282        }
283
284        // node type
285        if self.node_type_changed != 0 {
286            f.write_fmt(format_args!(
287                "NodeType:\t{} changed\n",
288                self.node_type_changed
289            ))?;
290        }
291
292        // metadata
293        if self.metadata_changed != 0 {
294            f.write_fmt(format_args!(
295                "Metadata:\t{} changed\n",
296                self.metadata_changed
297            ))?;
298        }
299        Ok(())
300    }
301}
302
303/// Compare two streams of nodes and print the differences
304///
305/// # Arguments
306///
307/// * `tree_streamer1` - first stream of nodes
308/// * `tree_streamer2` - second stream of nodes
309/// * `no_content` - don't check for different file contents
310/// * `file_identical` - function to check if the content of two files is identical
311/// * `metadata` - show differences in metadata
312///
313/// # Errors
314///
315// TODO!: add errors!
316fn diff(
317    mut tree_streamer1: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
318    mut tree_streamer2: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
319    no_content: bool,
320    file_identical: impl Fn(&Path, &Node, &Node) -> Result<bool>,
321    metadata: bool,
322) -> Result<()> {
323    let mut item1 = tree_streamer1.next().transpose()?;
324    let mut item2 = tree_streamer2.next().transpose()?;
325
326    let mut diff_statistics = DiffStatistics::default();
327
328    loop {
329        match (&item1, &item2) {
330            (None, None) => break,
331            (Some(i1), None) => {
332                println!("-    {:?}", i1.0);
333                diff_statistics.removed_node(&i1.1.node_type);
334                item1 = tree_streamer1.next().transpose()?;
335            }
336            (None, Some(i2)) => {
337                println!("+    {:?}", i2.0);
338                diff_statistics.added_node(&i2.1.node_type);
339                item2 = tree_streamer2.next().transpose()?;
340            }
341            (Some(i1), Some(i2)) if i1.0 < i2.0 => {
342                println!("-    {:?}", i1.0);
343                diff_statistics.removed_node(&i1.1.node_type);
344                item1 = tree_streamer1.next().transpose()?;
345            }
346            (Some(i1), Some(i2)) if i1.0 > i2.0 => {
347                println!("+    {:?}", i2.0);
348                diff_statistics.added_node(&i2.1.node_type);
349                item2 = tree_streamer2.next().transpose()?;
350            }
351            (Some(i1), Some(i2)) => {
352                let path = &i1.0;
353                let node1 = &i1.1;
354                let node2 = &i2.1;
355
356                let are_both_symlink = matches!(&node1.node_type, NodeType::Symlink { .. })
357                    && matches!(&node2.node_type, NodeType::Symlink { .. });
358                match &node1.node_type {
359                    // if node1.node_type != node2.node_type, they could be different symlinks,
360                    // for this reason we check:
361                    // that their type is different AND that they are not both symlinks
362                    tpe if tpe != &node2.node_type && !are_both_symlink => {
363                        // type was changed
364                        println!("T    {path:?}");
365                        diff_statistics.changed_node_type();
366                    }
367                    NodeType::File if !no_content && !file_identical(path, node1, node2)? => {
368                        println!("M    {path:?}");
369                        diff_statistics.changed_file();
370                    }
371                    NodeType::File if metadata && node1.meta != node2.meta => {
372                        println!("U    {path:?}");
373                        diff_statistics.changed_metadata();
374                    }
375                    NodeType::Symlink { .. } => {
376                        if node1.node_type.to_link() != node2.node_type.to_link() {
377                            println!("U    {path:?}");
378                            diff_statistics.changed_symlink();
379                        }
380                    }
381                    _ => {} // no difference to show
382                }
383                item1 = tree_streamer1.next().transpose()?;
384                item2 = tree_streamer2.next().transpose()?;
385            }
386        }
387    }
388    println!("{diff_statistics}");
389    Ok(())
390}
391
392fn diff_identical(
393    mut tree_streamer1: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
394    mut tree_streamer2: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
395    file_identical: impl Fn(&Path, &Node, &Node) -> Result<bool>,
396) -> Result<()> {
397    let mut item1 = tree_streamer1.next().transpose()?;
398    let mut item2 = tree_streamer2.next().transpose()?;
399
400    let mut checked: usize = 0;
401
402    loop {
403        match (&item1, &item2) {
404            (None, None) => break,
405            (Some(i1), None) => {
406                let path = &i1.0;
407                debug!("not checking {}: not present in target", path.display());
408                item1 = tree_streamer1.next().transpose()?;
409            }
410            (None, Some(i2)) => {
411                let path = &i2.0;
412                debug!("not checking {}: not present in source", path.display());
413                item2 = tree_streamer2.next().transpose()?;
414            }
415            (Some(i1), Some(i2)) if i1.0 < i2.0 => {
416                let path = &i1.0;
417                debug!("not checking {}: not present in target", path.display());
418                item1 = tree_streamer1.next().transpose()?;
419            }
420            (Some(i1), Some(i2)) if i1.0 > i2.0 => {
421                let path = &i2.0;
422                debug!("not checking {}: not present in source", path.display());
423                item2 = tree_streamer2.next().transpose()?;
424            }
425            (Some(i1), Some(i2)) => {
426                let path = &i1.0;
427                let node1 = &i1.1;
428                let node2 = &i2.1;
429
430                if matches!(&node1.node_type, NodeType::File)
431                    && matches!(&node2.node_type, NodeType::File)
432                    && node1.meta == node2.meta
433                {
434                    debug!("checking {}", path.display());
435                    checked += 1;
436                    if !file_identical(path, node1, node2)? {
437                        println!("M    {path:?}");
438                    }
439                } else {
440                    debug!("not checking {}: metadata changed", path.display());
441                }
442                item1 = tree_streamer1.next().transpose()?;
443                item2 = tree_streamer2.next().transpose()?;
444            }
445        }
446    }
447    println!("checked {checked} files.");
448    Ok(())
449}