1use 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#[derive(clap::Parser, Command, Debug)]
29pub(crate) struct DiffCmd {
30 #[clap(value_name = "SNAPSHOT1[:PATH1]")]
34 snap1: Option<String>,
35
36 #[clap(value_name = "SNAPSHOT2[:PATH2]|PATH2", value_hint = ValueHint::AnyPath)]
40 snap2: Option<String>,
41
42 #[clap(long)]
44 metadata: bool,
45
46 #[clap(long)]
48 no_content: bool,
49
50 #[clap(long, conflicts_with = "no_content")]
52 only_identical: bool,
53
54 #[cfg(feature = "tui")]
55 #[clap(long, short)]
57 pub interactive: bool,
58
59 #[clap(flatten, next_help_heading = "Exclude options")]
61 excludes: Excludes,
62
63 #[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 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 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 #[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 path.strip_prefix(&path2).unwrap().to_path_buf()
192 } else {
193 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
224pub 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
249fn 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 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#[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 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
484fn 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}