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 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 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 #[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 path.strip_prefix(&path2).unwrap().to_path_buf()
154 } else {
155 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
186fn 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
210fn 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 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#[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 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
445fn 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}