1use 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#[derive(clap::Parser, Command, Debug)]
29pub(crate) struct DiffCmd {
30 #[clap(value_name = "SNAPSHOT1[:PATH1]")]
32 snap1: String,
33
34 #[clap(value_name = "SNAPSHOT2[:PATH2]|PATH2", value_hint = ValueHint::AnyPath)]
36 snap2: Option<String>,
37
38 #[clap(long)]
40 metadata: bool,
41
42 #[clap(long)]
44 no_content: bool,
45
46 #[clap(long, conflicts_with = "no_content")]
48 only_identical: bool,
49
50 #[clap(flatten)]
52 ignore_opts: LocalSourceFilterOptions,
53
54 #[cfg(feature = "tui")]
55 #[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 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 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 #[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 path.strip_prefix(&path2).unwrap().to_path_buf()
148 } else {
149 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
180fn 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
204fn 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 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#[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 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
439fn 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}