diff options
author | Stefan Kreutz <mail@skreutz.com> | 2024-03-24 15:04:09 +0100 |
---|---|---|
committer | Stefan Kreutz <mail@skreutz.com> | 2024-03-24 15:04:09 +0100 |
commit | c1fa48e9bd617d70e823efef5d6dcea41b1d2087 (patch) | |
tree | 421e69c512ac54bf65495ef23fd7d9ec5a5e67d5 /src | |
download | brck-c1fa48e9bd617d70e823efef5d6dcea41b1d2087.tar |
Add initial implementationbrck-0.1.0
Diffstat (limited to 'src')
-rw-r--r-- | src/core.rs | 306 | ||||
-rw-r--r-- | src/fs.rs | 144 | ||||
-rw-r--r-- | src/main.rs | 343 |
3 files changed, 793 insertions, 0 deletions
diff --git a/src/core.rs b/src/core.rs new file mode 100644 index 0000000..6ac3ba5 --- /dev/null +++ b/src/core.rs @@ -0,0 +1,306 @@ +//! Core functionality. +//! +//! This module contains Brck's core types and functions. + +use std::{ + cmp::Ordering, + fs::OpenOptions, + io::{BufReader, Read}, + path::{Path, PathBuf}, + sync::{atomic::AtomicBool, Arc}, +}; + +use chrono::{DateTime, Utc}; +use either::Either; +use flate2::bufread::GzDecoder; +use itertools::{EitherOrBoth, Itertools}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; + +/// Produces an iterator over the regular files below the given path. +/// +/// The files are sorted by their full path. +/// Symlinks are not followed, except for the original `path`. +pub fn find_files<P: AsRef<Path>>( + path: P, +) -> impl Iterator<Item = Result<PathBuf, walkdir::Error>> { + WalkDir::new(path.as_ref()) + .sort_by_path() + .into_iter() + .filter_ok(|entry| entry.file_type().is_file()) + .map_ok(|entry| entry.into_path()) +} + +/// Extension for [WalkDir]. +trait WalkDirExt { + /// Sort directory entries by full path. + /// + /// Yiedls "foo bar/buzz" before "foo/buzz". + fn sort_by_path(self) -> WalkDir; +} + +impl WalkDirExt for WalkDir { + fn sort_by_path(self) -> WalkDir { + self.sort_by_key(|a| { + if a.file_type().is_dir() { + let mut name = a.file_name().to_os_string(); + name.push(std::path::MAIN_SEPARATOR.to_string()); + name + } else { + a.file_name().to_os_string() + } + }) + } +} + +/// Returns an iterator over the records of the given database. +pub fn read_db<P: AsRef<Path>>( + path: P, +) -> Result<impl Iterator<Item = Result<Record, serde_json::Error>>, std::io::Error> { + let file = OpenOptions::new().read(true).open(path)?; + let buf = BufReader::new(file); + let dec = GzDecoder::new(buf); + let iter = serde_json::Deserializer::from_reader(dec).into_iter::<Record>(); + Ok(iter) +} + +/// A record of a file's modification time and hash sum. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct Record { + pub path: PathBuf, + pub modified: DateTime<Utc>, + #[serde(with = "hex::serde")] + pub sha256: Vec<u8>, +} + +impl Record { + /// Constructs a `Record` of the given file. + /// + /// This function reads the given file in chunks of at most `chunk_size` bytes. It checks the + /// `terminate` flag before reading each chunk, returning the `RecordError::Interrupt` error + /// when set. + pub fn from_path<P: AsRef<Path>>( + path: P, + chunk_size: u64, + terminate: Arc<AtomicBool>, + ) -> Result<Record, RecordError> { + let modified = std::fs::symlink_metadata(&path)?.modified()?; + let mut file = std::fs::File::open(&path)?; + let mut hasher = Sha256::new(); + loop { + if terminate.load(std::sync::atomic::Ordering::SeqCst) { + return Err(RecordError::Interrupt); + } + let mut reader = std::io::Read::by_ref(&mut file).take(chunk_size); + if std::io::copy(&mut reader, &mut hasher)? == 0 { + break; + } + } + let hash = hasher.finalize(); + Ok(Record { + path: path.as_ref().into(), + modified: modified.into(), + sha256: hash.as_slice().into(), + }) + } +} + +/// The possible errors of [Record::from_path]. +#[derive(Debug)] +pub enum RecordError { + IO(std::io::Error), + Interrupt, +} + +impl std::fmt::Display for RecordError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RecordError::IO(err) => write!(f, "{}", err), + RecordError::Interrupt => write!(f, "Received interrupt signal"), + } + } +} + +impl std::error::Error for RecordError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + RecordError::IO(err) => Some(err), + RecordError::Interrupt => None, + } + } +} + +impl From<std::io::Error> for RecordError { + fn from(value: std::io::Error) -> Self { + RecordError::IO(value) + } +} + +/// Returns an iterator over the difference of the given files. +pub fn diff<I, J, E, F>(cached: I, found: J) -> impl Iterator<Item = Result<Diff, Either<E, F>>> +where + I: Iterator<Item = Result<Record, E>>, + J: Iterator<Item = Result<Record, F>>, +{ + cached + .merge_join_by(found, |cached, found| match (cached, found) { + (Err(_), _) => Ordering::Less, + (Ok(_), Err(_)) => Ordering::Greater, + (Ok(cached), Ok(found)) => cached.path.cmp(&found.path), + }) + .map(|item| match item { + EitherOrBoth::Left(Err(err)) => Err(Either::Left(err)), + EitherOrBoth::Right(Err(err)) => Err(Either::Right(err)), + EitherOrBoth::Left(Ok(old)) => Ok(Diff::Removed { old }), + EitherOrBoth::Right(Ok(new)) => Ok(Diff::Added { new }), + EitherOrBoth::Both(Ok(old), Ok(new)) => { + if old.modified == new.modified { + if old.sha256 == new.sha256 { + Ok(Diff::Unchanged { old, new }) + } else { + Ok(Diff::Corrupted { old, new }) + } + } else if old.sha256 == new.sha256 { + Ok(Diff::Touched { old, new }) + } else { + Ok(Diff::Changed { old, new }) + } + } + _ => unreachable!(), + }) +} + +/// The list of possible types of differences. +#[derive(Debug, Clone, Hash, Eq, PartialEq, clap::ValueEnum)] +pub enum DiffKind { + Added, + Touched, + Changed, + Unchanged, + Removed, + Corrupted, +} + +/// The difference between two records of a file. +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Diff { + // Modeled variants as structs to improve their JSON representation. + Added { new: Record }, + Touched { old: Record, new: Record }, + Changed { old: Record, new: Record }, + Unchanged { old: Record, new: Record }, + Removed { old: Record }, + Corrupted { old: Record, new: Record }, +} + +impl Diff { + pub fn kind(&self) -> DiffKind { + match self { + Diff::Added { .. } => DiffKind::Added, + Diff::Touched { .. } => DiffKind::Touched, + Diff::Changed { .. } => DiffKind::Changed, + Diff::Unchanged { .. } => DiffKind::Unchanged, + Diff::Removed { .. } => DiffKind::Removed, + Diff::Corrupted { .. } => DiffKind::Corrupted, + } + } +} + +impl std::fmt::Display for Diff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Diff::Added { new, .. } => write!(f, "added: {}", new.path.display()), + Diff::Touched { old, .. } => write!(f, "touched: {}", old.path.display()), + Diff::Changed { old, .. } => write!(f, "changed: {}", old.path.display()), + Diff::Unchanged { old, .. } => write!(f, "unchanged: {}", old.path.display()), + Diff::Removed { old, .. } => write!(f, "removed: {}", old.path.display()), + Diff::Corrupted { old, .. } => write!(f, "corrupted: {}", old.path.display()), + } + } +} + +#[cfg(test)] +mod tests { + use testresult::TestResult; + + use super::*; + + fn record(path: &str, modified: &str, sha256: &str) -> Record { + Record { + path: path.into(), + modified: DateTime::parse_from_rfc3339(modified).unwrap().into(), + sha256: sha256.into(), + } + } + + #[test] + fn diff_works() -> TestResult { + let a = record("a", "2024-03-22T00:00:00Z", "apple"); + let b = record("b", "2024-03-22T00:00:00Z", "banana"); + let b_touched = record("b", "2024-03-22T01:00:00Z", "banana"); + let c = record("c", "2024-03-22T00:00:00Z", "cherry"); + let c_changed = record("c", "2024-03-22T01:00:00Z", "cashew"); + let d = record("d", "2024-03-22T00:00:00Z", "date"); + let e = record("e", "2024-03-22T00:00:00Z", "elderberry"); + let f = record("f", "2024-03-22T00:00:00Z", "fig"); + let f_corrupted = record("f", "2024-03-22T00:00:00Z", "feijoa"); + + let db = [ + Err("foo"), + Ok(b.clone()), + Ok(c.clone()), + Ok(d.clone()), + Ok(e.clone()), + Ok(f.clone()), + ]; + + let fs = [ + Ok(a.clone()), + Ok(b_touched.clone()), + Err("bar"), + Ok(c_changed.clone()), + Ok(d.clone()), + Ok(f_corrupted.clone()), + ]; + + let mut result = diff(db.into_iter(), fs.into_iter()); + + assert_eq!(result.next(), Some(Err(Either::Left("foo")))); + assert_eq!(result.next(), Some(Ok(Diff::Added { new: a }))); + assert_eq!( + result.next(), + Some(Ok(Diff::Touched { + old: b, + new: b_touched + })) + ); + assert_eq!(result.next(), Some(Err(Either::Right("bar")))); + assert_eq!( + result.next(), + Some(Ok(Diff::Changed { + old: c, + new: c_changed + })) + ); + assert_eq!( + result.next(), + Some(Ok(Diff::Unchanged { + old: d.clone(), + new: d + })) + ); + assert_eq!(result.next(), Some(Ok(Diff::Removed { old: e }))); + assert_eq!( + result.next(), + Some(Ok(Diff::Corrupted { + old: f, + new: f_corrupted + })) + ); + assert_eq!(result.next(), None); + + Ok(()) + } +} diff --git a/src/fs.rs b/src/fs.rs new file mode 100644 index 0000000..f962e53 --- /dev/null +++ b/src/fs.rs @@ -0,0 +1,144 @@ +//! Filesytem functionality. +//! +//! This module contains several extensions to [std::fs]. + +use std::{ + fs::{File, OpenOptions}, + path::{Path, PathBuf}, +}; + +/// A std::fs::File wrapper that removes the file when dropped. +#[derive(Debug)] +pub struct TmpFile { + path: PathBuf, + file: File, +} + +impl TmpFile { + /// Opens a new temporary file at the given path. + pub fn open<P: AsRef<Path>>(path: P) -> Result<TmpFile, std::io::Error> { + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path)?; + Ok(TmpFile { + path: path.as_ref().into(), + file, + }) + } + + pub fn file(&self) -> &File { + &self.file + } +} + +impl Drop for TmpFile { + fn drop(&mut self) { + if let Err(err) = remove_file_if_exists(&self.path) { + eprintln!( + "Failed to remove temporary file {}: {}", + &self.path.display(), + err + ); + } + } +} + +/// Removes a file from the filesystem, if it exists. +pub fn remove_file_if_exists<P: AsRef<Path>>(path: P) -> Result<(), std::io::Error> { + std::fs::remove_file(path).or_else(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(err) + } + }) +} + +/// Returns the path without it's final component, if there is one. +/// +/// In contrast to [Path::parent], this function returns the [CurDir][std::path::Component::CurDir] +/// instead of the empty string for the current directory. +pub fn parent_dir<P: AsRef<Path>>(path: &P) -> Option<&Path> { + path.as_ref().parent().map(|p| { + if p.as_os_str().is_empty() { + Path::new(std::path::Component::CurDir.as_os_str()) + } else { + p + } + }) +} + +/// Rename a file. +/// +/// This function should be atomic on POSIX-conform systems. +/// +/// Both paths must be located on the same filesystem. +pub fn rename_file<P, Q>(from: P, to: Q) -> Result<(), std::io::Error> +where + P: AsRef<Path>, + Q: AsRef<Path>, +{ + File::open(&from)?.sync_all()?; + std::fs::rename(&from, &to)?; + + let dest_dir = parent_dir(&to).ok_or_else(|| std::io::Error::other("is root directory"))?; + File::open(dest_dir)?.sync_all()?; + + let src_dir = parent_dir(&from).ok_or_else(|| std::io::Error::other("is root directory"))?; + if src_dir != dest_dir { + File::open(src_dir)?.sync_all()?; + } + + Ok(()) +} + +/// Copy a file. +/// +/// This functions hard links `from` to `tmp`, and then renames the latter to `to`. The renaming +/// should be atomic on POSIX-conform systems. +/// +/// All three paths must be located on the same filesystem. +// Didn't use https://crates.io/crates/atomic-write-file or https://crates.io/crates/atomicwrites +// because they create randomly named temporary files, which I wanted to unveil(2) on OpenBSD, and +// remove when receiving a termination signal. +pub fn copy_file<P, Q, R>(from: P, tmp: Q, to: R) -> Result<(), std::io::Error> +where + P: AsRef<Path>, + Q: AsRef<Path>, + R: AsRef<Path>, +{ + fn inner<P, Q, R>(from: P, tmp: Q, to: R) -> Result<(), std::io::Error> + where + P: AsRef<Path>, + Q: AsRef<Path>, + R: AsRef<Path>, + { + let tmp_dir = + parent_dir(&from).ok_or_else(|| std::io::Error::other("is root directory"))?; + File::open(tmp_dir)?.sync_all()?; + + std::fs::rename(&tmp, &to)?; + + let to_dir = parent_dir(&from).ok_or_else(|| std::io::Error::other("is root directory"))?; + File::open(to_dir)?.sync_all()?; + + Ok(()) + } + + File::open(&from)?.sync_all()?; + std::fs::hard_link(&from, &tmp)?; + inner(&from, &tmp, &to).inspect_err(|_| { + if let Err(err) = remove_file_if_exists(&tmp) { + eprintln!( + "Failed to remove temporary file {}: {}", + tmp.as_ref().display(), + err + ); + } + }) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2d26eed --- /dev/null +++ b/src/main.rs @@ -0,0 +1,343 @@ +#![forbid(unsafe_code)] + +use std::{ + collections::HashMap, + num::{NonZeroU64, NonZeroUsize}, + path::PathBuf, + sync::{atomic::AtomicBool, Arc}, +}; + +use anyhow::{anyhow, Context}; +use clap::Parser; +use either::Either; +use flate2::{write::GzEncoder, Compression}; +use itertools::Itertools; +use parseq::ParallelIterator; + +mod core; +use core::*; + +mod fs; +use fs::*; + +// std::process::ExitCode::exit_process is unstable +// Exit codes should fit u8. +const EXIT_SUCCESS: i32 = 0; +const EXIT_FAILURE: i32 = 1; +const EXIT_INTERRUPT: i32 = 130; + +/// A simple bit rot checker for legacy file systems. +/// +/// On the first invocation, Brck records the modification time and a cryptographic hash sum of each regular file in the current working directory to a .brck file. +/// On subsequent invocations, Brck compares the recorded files against the current file system. +/// +/// A file can be added, touched, changed, unchanged, removed, or corrupted. +/// A file is corrupted if it's content changed but it's modification did not. +/// +/// Without any options, Brck denies corrupted files. +/// If Brck finds one or more denied differences, it prints them to standard output and exits >0. +/// Otherwise, Brck updates the .brck file. +#[derive(Debug, Parser)] +#[command(about, version, author)] +struct Args { + /// Increase verbosity. + /// + /// Specify once to print all changed files. + /// Specify twice to print unchanged files, too. + /// By default, only denied differences are printed. + #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)] + verbosity: u8, + + /// Do not actually modify the database. + #[arg(short = 'n', long)] + dry_run: bool, + + /// Specify the number of worker threads. + /// + /// Defaults to the available parallelism, typically the number of CPUs. + #[arg(short, long)] + jobs: Option<NonZeroUsize>, + + /// Deny select differences only. + /// + /// This option accepts a comma-separated list of differences to deny. + /// Without this option, only corrupted files are denied. + /// With this option, only the specified differences are denied. + #[arg(value_enum, short, long, default_values_t = vec![DiffKind::Corrupted], value_delimiter = ',', action = clap::ArgAction::Append)] + deny: Vec<DiffKind>, + + /// Enable structured output. + #[arg(short = 'J', long)] + json: bool, + + /// Print summary to standard error. + #[arg(short, long)] + summary: bool, + + /// Specify the maximum number of bytes a worker thread may read without checking for an interrupt signal. + /// + /// Defaults to 1 GiB. + #[arg(long)] + chunk_size: Option<NonZeroU64>, + + /// Specify the maximum number of files to read ahead. + /// + /// This should be greater than the number of worker threads. + /// + /// Defaults to 1024 files per worker thread. + #[arg(long)] + queue_size: Option<NonZeroUsize>, +} + +fn main() { + std::process::exit(match run() { + Ok(_) => EXIT_SUCCESS, + Err(err) if err.is::<clap::Error>() => { + // Use clap's own error formatter + eprintln!("{err}"); + EXIT_FAILURE + } + Err(err) => { + eprintln!("Error: {:?}", err); + match err.downcast_ref::<RecordError>() { + Some(RecordError::Interrupt) => EXIT_INTERRUPT, + _ => EXIT_FAILURE, + } + } + }) +} + +fn run() -> Result<(), anyhow::Error> { + let terminate = Arc::new(AtomicBool::new(false)); + let args = Args::try_parse()?; + + // Paths must begin with ./ for correct filtering. + let current_dir: PathBuf = std::path::Component::CurDir.as_os_str().into(); + let db_path = current_dir.join(".brck"); + let backup_path = current_dir.join(".brck.bak"); + + // Creating temporary files in the same directory instead of std::env_temp_dir() to enable atomic rename(2) on POSIX-conform systems. + // The drawback is that the operating system won't remove these files automatically. + let tmp_db_path = current_dir.join(".brck.tmp"); + let tmp_backup_path = current_dir.join(".brck.bak.tmp"); + + #[cfg(target_os = "openbsd")] + { + use std::{os::unix::ffi::OsStrExt, path::Path}; + + pledge::pledge![Stdio Rpath Wpath Cpath Unveil, Stdio].context("Failed to pledge(2)")?; + + fn unveil<P: AsRef<Path>>(path: P, permissions: &str) -> Result<(), anyhow::Error> { + unveil::unveil(path.as_ref().as_os_str().as_bytes(), permissions).with_context(|| { + format!( + "Failed to unveil(2) {} with permissions {}", + path.as_ref().display(), + permissions + ) + }) + } + + unveil(".", "r")?; + unveil(&db_path, "rwc")?; + unveil(&tmp_db_path, "rwc")?; + unveil(&backup_path, "rwc")?; + unveil(&tmp_backup_path, "rwc")?; + unveil::unveil("", "").context("Failed to disable unveil(2)")?; + } + + let t = terminate.clone(); + ctrlc::try_set_handler(move || { + if t.load(std::sync::atomic::Ordering::SeqCst) { + eprintln!("Received second interrupt signal"); + std::process::exit(EXIT_INTERRUPT); + } + eprintln!("Received interrupt signal"); + t.store(true, std::sync::atomic::Ordering::SeqCst); + }) + .context("Failed to register interrupt signal handler")?; + + let (db, first_run) = match read_db(&db_path) { + Ok(db) => Ok((Either::Left(db), false)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok((Either::Right(std::iter::empty()), true)) + } + Err(err) => Err(err), + } + .with_context(|| format!("Failed to read database {}", db_path.display()))?; + let db = db.map(|item| { + item.map_err(|err| { + anyhow::Error::new(err).context(format!( + "Failed to decode record from database {}", + &db_path.display() + )) + }) + }); + + let tmp = TmpFile::open(&tmp_db_path).with_context(|| { + format!( + "Failed to create temporary database {}", + tmp_db_path.display() + ) + })?; + let mut tmp = GzEncoder::new(tmp.file(), Compression::default()); + + let jobs = args + .jobs + .or_else(|| std::thread::available_parallelism().ok()) + .map(NonZeroUsize::get) + .unwrap_or(1); + let queue_size = args + .queue_size + .map(NonZeroUsize::get) + .unwrap_or_else(|| jobs.saturating_mul(1024)); + let chunk_size = args + .chunk_size + .map(NonZeroU64::get) + .unwrap_or(1024 * 1024 * 1024); + + let fs = find_files(¤t_dir) + .filter_ok(|path| path != &db_path && path != &tmp_db_path && path != &backup_path) + .map(|item| { + item.map_err(|err| anyhow::Error::new(err).context("Failed to walk filesystem")) + }) + .map_parallel_limit(jobs, queue_size, move |item| { + item.and_then(|path| { + Record::from_path(&path, chunk_size, terminate.clone()) + .with_context(|| format!("Failed to read file {}", path.display())) + }) + }); + + let mut counter = HashMap::new(); + let mut denied = 0; + + diff(db, fs).try_for_each(|item| match item { + Err(Either::Left(err)) => Err(err), + Err(Either::Right(err)) => Err(err), + Ok(diff) => { + *counter.entry(diff.kind()).or_insert(0) += 1; + record(&diff, &mut tmp).with_context(|| { + format!( + "Failed to write to temporary database {}", + tmp_db_path.display() + ) + })?; + if args.deny.contains(&diff.kind()) { + if args.json { + println!("{}", serde_json::to_string(&diff).unwrap()); + } else { + println!("{diff}"); + } + denied += 1; + } else if args.verbosity > 1 + || (diff.kind() != DiffKind::Unchanged && args.verbosity > 0) + { + if args.json { + println!("{}", serde_json::to_string(&diff).unwrap()); + } else { + println!("{diff}"); + } + } + Ok(()) + } + })?; + + if args.summary { + let total: usize = counter.values().sum(); + let width = usize::try_from(total.checked_ilog10().unwrap_or(0) + 1).unwrap_or(0); + eprintln!( + "Removed: {:>width$}", + counter.get(&DiffKind::Removed).unwrap_or(&0) + ); + eprintln!( + "Added: {:>width$}", + counter.get(&DiffKind::Added).unwrap_or(&0) + ); + eprintln!( + "Unchanged: {:>width$}", + counter.get(&DiffKind::Unchanged).unwrap_or(&0) + ); + eprintln!( + "Corrupted: {:>width$}", + counter.get(&DiffKind::Corrupted).unwrap_or(&0) + ); + eprintln!( + "Touched: {:>width$}", + counter.get(&DiffKind::Touched).unwrap_or(&0) + ); + eprintln!( + "Changed: {:>width$}", + counter.get(&DiffKind::Changed).unwrap_or(&0) + ); + eprintln!("Total: {:>width$}", total); + } + + if denied > 0 { + return Err(anyhow!( + "Found {} denied {}", + denied, + if denied == 1 { + "difference" + } else { + "differences" + } + )); + } + + if args.dry_run { + eprintln!("Exiting due to dry run"); + return Ok(()); + } + + tmp.finish().with_context(|| { + format!( + "Failed to synchronize temporary database {}", + tmp_db_path.display() + ) + })?; + if !first_run { + copy_file(&db_path, tmp_backup_path, &backup_path).with_context(|| { + format!( + "Failed to backup database {} to {}", + db_path.display(), + backup_path.display() + ) + })?; + } + rename_file(&tmp_db_path, &db_path).with_context(|| { + format!( + "Failed to persist temporary database {} to {}", + tmp_db_path.display(), + db_path.display() + ) + })?; + + Ok(()) +} + +fn record<T: std::io::Write>(diff: &Diff, mut writer: T) -> Result<(), std::io::Error> { + let file = match diff { + Diff::Removed { .. } => None, + Diff::Added { new, .. } => Some(new), + Diff::Unchanged { new, .. } => Some(new), + Diff::Corrupted { new, .. } => Some(new), + Diff::Touched { new, .. } => Some(new), + Diff::Changed { new, .. } => Some(new), + }; + if let Some(file) = file { + let s = serde_json::to_string(file).unwrap(); + writeln!(writer, "{}", s) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_clap_app() { + use clap::CommandFactory; + Args::command().debug_assert() + } +} |