1use 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#[derive(clap::Parser, Command, Debug)]
24pub(crate) struct DiffCmd {
25 #[clap(value_name = "SNAPSHOT1[:PATH1]")]
27 snap1: String,
28
29 #[clap(value_name = "SNAPSHOT2[:PATH2]|PATH2", value_hint = ValueHint::AnyPath)]
31 snap2: String,
32
33 #[clap(long)]
35 metadata: bool,
36
37 #[clap(long)]
39 no_content: bool,
40
41 #[clap(long, conflicts_with = "no_content")]
43 only_identical: bool,
44
45 #[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 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 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 path.strip_prefix(&path2).unwrap().to_path_buf()
112 } else {
113 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
144fn 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
168fn 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#[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 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 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 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
303fn 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 tpe if tpe != &node2.node_type && !are_both_symlink => {
363 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 _ => {} }
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}