Skip to content

Commit 5887f3b

Browse files
committed
✨ Add --format option to diff subcommand
jsonl emits one JSON Lines record per difference: {"path", "kind", "target"?}.
1 parent d4e2cd6 commit 5887f3b

3 files changed

Lines changed: 486 additions & 21 deletions

File tree

cli/src/command/diff.rs

Lines changed: 89 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::{
77
utils::{BsdGlobMatcher, io::streams_equal},
88
};
99

10-
use clap::Parser;
10+
use clap::{Parser, ValueEnum};
1111
#[cfg(unix)]
1212
use pna::prelude::MetadataTimeExt;
1313
use pna::{DataKind, EntryContent, NormalEntry, ReadOptions};
@@ -31,12 +31,41 @@ pub(crate) struct DiffCommand {
3131
help = "Compare directory mtime and ownership (by default, only mode is compared for directories)"
3232
)]
3333
full_compare: bool,
34+
#[arg(
35+
long,
36+
default_value = "plain",
37+
help = "Output format [unstable: jsonl]",
38+
long_help = "Output format. plain: tar-style text. jsonl: one JSON Lines record per difference with fields `path`, `kind` (one of: missing, size, content, mode, mtime, uid, gid, type, symlink, hardlink) and, for kind=hardlink only, `target` (the stored link target). mode/mtime/uid/gid comparisons only occur on Unix."
39+
)]
40+
format: Format,
41+
}
42+
43+
#[derive(Copy, Clone, Debug, ValueEnum)]
44+
#[value(rename_all = "lower")]
45+
enum Format {
46+
Plain,
47+
JsonL,
48+
}
49+
50+
impl Format {
51+
/// Returns true if this format is unstable and requires --unstable flag
52+
#[inline]
53+
const fn is_unstable(self) -> bool {
54+
matches!(self, Self::JsonL)
55+
}
56+
}
57+
58+
impl fmt::Display for Format {
59+
#[inline]
60+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61+
f.write_str(self.to_possible_value().unwrap().get_name())
62+
}
3463
}
3564

3665
impl Command for DiffCommand {
3766
#[inline]
38-
fn execute(self, _ctx: &crate::cli::GlobalContext) -> anyhow::Result<()> {
39-
match diff_archive(self) {
67+
fn execute(self, ctx: &crate::cli::GlobalContext) -> anyhow::Result<()> {
68+
match diff_archive(ctx, self) {
4069
Ok(0) => Ok(()),
4170
Ok(_) => Err(ExitCodeError::silent(1).into()),
4271
Err(err) => Err(ExitCodeError::with_source(2, err).into()),
@@ -45,11 +74,18 @@ impl Command for DiffCommand {
4574
}
4675

4776
#[hooq::hooq(anyhow)]
48-
fn diff_archive(args: DiffCommand) -> anyhow::Result<usize> {
77+
fn diff_archive(ctx: &crate::cli::GlobalContext, args: DiffCommand) -> anyhow::Result<usize> {
78+
if args.format.is_unstable() && !ctx.unstable() {
79+
anyhow::bail!(
80+
"The '--format {}' option is unstable and requires --unstable flag",
81+
args.format
82+
);
83+
}
4984
let password = ask_password(args.password)?;
5085
let archives = collect_split_archives(&args.file.archive)?;
5186
let options = CompareOptions {
5287
full_compare: args.full_compare,
88+
format: args.format,
5389
};
5490

5591
let mut globs = BsdGlobMatcher::new(args.file.files.iter().map(|s| s.as_str()));
@@ -81,32 +117,43 @@ fn diff_archive(args: DiffCommand) -> anyhow::Result<usize> {
81117

82118
/// Difference types detected during archive-filesystem comparison.
83119
/// Message format follows tar --diff for compatibility.
84-
#[derive(Clone, Debug, PartialEq, Eq)]
120+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
121+
#[serde(tag = "kind")]
85122
enum DiffKind {
86123
/// File/directory does not exist on filesystem
124+
#[serde(rename = "missing")]
87125
Missing,
88126
/// File size differs
127+
#[serde(rename = "size")]
89128
SizeDiffers,
90129
/// File contents differ (same size)
130+
#[serde(rename = "content")]
91131
ContentsDiffer,
92132
/// Permission mode differs
93133
#[cfg(unix)]
134+
#[serde(rename = "mode")]
94135
ModeDiffers,
95136
/// Modification time differs
96137
#[cfg(unix)]
138+
#[serde(rename = "mtime")]
97139
MtimeDiffers,
98140
/// User ID differs
99141
#[cfg(unix)]
142+
#[serde(rename = "uid")]
100143
UidDiffers,
101144
/// Group ID differs
102145
#[cfg(unix)]
146+
#[serde(rename = "gid")]
103147
GidDiffers,
104148
/// File type differs (e.g., file vs directory)
149+
#[serde(rename = "type")]
105150
TypeMismatch,
106151
/// Symbolic link target differs
152+
#[serde(rename = "symlink")]
107153
SymlinkDiffers,
108154
/// Hardlink relationship broken
109-
NotLinked(String),
155+
#[serde(rename = "hardlink")]
156+
NotLinked { target: String },
110157
}
111158

112159
impl DiffKind {
@@ -116,6 +163,23 @@ impl DiffKind {
116163
}
117164
}
118165

166+
#[derive(serde::Serialize)]
167+
struct DiffRecord<'a> {
168+
path: &'a str,
169+
#[serde(flatten)]
170+
kind: &'a DiffKind,
171+
}
172+
173+
fn report(kind: &DiffKind, path: &str, format: Format) {
174+
match format {
175+
Format::Plain => println!("{}", kind.display(path)),
176+
Format::JsonL => println!(
177+
"{}",
178+
serde_json::to_string(&DiffRecord { path, kind }).unwrap()
179+
),
180+
}
181+
}
182+
119183
/// A tar-compatible difference message that implements `Display`.
120184
struct DiffMessage<'a> {
121185
kind: &'a DiffKind,
@@ -144,17 +208,18 @@ impl fmt::Display for DiffMessage<'_> {
144208
DiffKind::GidDiffers => write!(f, "{}: Gid differs", self.path),
145209
DiffKind::TypeMismatch => write!(f, "{}: File type differs", self.path),
146210
DiffKind::SymlinkDiffers => write!(f, "{}: Symlink differs", self.path),
147-
DiffKind::NotLinked(target) => write!(f, "{}: Not linked to {target}", self.path),
211+
DiffKind::NotLinked { target } => write!(f, "{}: Not linked to {target}", self.path),
148212
}
149213
}
150214
}
151215

152216
/// Options controlling what aspects to compare.
153-
#[derive(Clone, Debug, Default)]
217+
#[derive(Clone, Debug)]
154218
struct CompareOptions {
155219
/// Compare directory mtime and ownership (not just mode)
156220
#[cfg_attr(not(unix), allow(dead_code))]
157221
full_compare: bool,
222+
format: Format,
158223
}
159224

160225
/// Compare two SystemTime values with 1-second tolerance for filesystem precision.
@@ -281,7 +346,7 @@ fn compare_entry<T: AsRef<[u8]>>(
281346
let meta = match fs::symlink_metadata(path) {
282347
Ok(meta) => meta,
283348
Err(e) if e.kind() == io::ErrorKind::NotFound => {
284-
println!("{}", DiffKind::Missing.display(path_str));
349+
report(&DiffKind::Missing, path_str, options.format);
285350
return Ok(1);
286351
}
287352
Err(e) => return Err(e),
@@ -292,30 +357,30 @@ fn compare_entry<T: AsRef<[u8]>>(
292357
// Compare metadata first
293358
let meta_diffs = compare_file_metadata(&entry, &meta, options);
294359
diff_count += meta_diffs.len();
295-
for diff in meta_diffs {
296-
println!("{}", diff.display(path_str));
360+
for diff in &meta_diffs {
361+
report(diff, path_str, options.format);
297362
}
298363

299364
// Compare size first, then content
300365
let fs_size = meta.len();
301366
let archive_size = entry.metadata().raw_file_size();
302367
if archive_size.is_some_and(|s| s != fs_size as u128) {
303-
println!("{}", DiffKind::SizeDiffers.display(path_str));
368+
report(&DiffKind::SizeDiffers, path_str, options.format);
304369
diff_count += 1;
305370
} else {
306371
let fs_file = fs::File::open(path)?;
307372
let archive_reader = entry.reader(read_options)?;
308373
if !streams_equal(fs_file, archive_reader)? {
309-
println!("{}", DiffKind::ContentsDiffer.display(path_str));
374+
report(&DiffKind::ContentsDiffer, path_str, options.format);
310375
diff_count += 1;
311376
}
312377
}
313378
}
314379
DataKind::DIRECTORY if meta.is_dir() => {
315380
let diffs = compare_directory_metadata(&entry, &meta, options);
316381
diff_count += diffs.len();
317-
for diff in diffs {
318-
println!("{}", diff.display(path_str));
382+
for diff in &diffs {
383+
report(diff, path_str, options.format);
319384
}
320385
}
321386
DataKind::SYMBOLIC_LINK if meta.is_symlink() => {
@@ -324,7 +389,7 @@ fn compare_entry<T: AsRef<[u8]>>(
324389
unreachable!("data_kind() returned SymbolicLink");
325390
};
326391
if link.as_path() != Path::new(stored.as_str()) {
327-
println!("{}", DiffKind::SymlinkDiffers.display(path_str));
392+
report(&DiffKind::SymlinkDiffers, path_str, options.format);
328393
diff_count += 1;
329394
}
330395
}
@@ -335,21 +400,24 @@ fn compare_entry<T: AsRef<[u8]>>(
335400
match is_same_file(path, stored.as_str()) {
336401
Ok(true) => (),
337402
Ok(false) => {
338-
println!(
339-
"{}",
340-
DiffKind::NotLinked(stored.to_string()).display(path_str)
403+
report(
404+
&DiffKind::NotLinked {
405+
target: stored.to_string(),
406+
},
407+
path_str,
408+
options.format,
341409
);
342410
diff_count += 1;
343411
}
344412
Err(e) if e.kind() == io::ErrorKind::NotFound => {
345-
println!("{}", DiffKind::Missing.display(path_str));
413+
report(&DiffKind::Missing, path_str, options.format);
346414
diff_count += 1;
347415
}
348416
Err(e) => return Err(e),
349417
}
350418
}
351419
_ => {
352-
println!("{}", DiffKind::TypeMismatch.display(path_str));
420+
report(&DiffKind::TypeMismatch, path_str, options.format);
353421
diff_count += 1;
354422
}
355423
}

cli/tests/cli/diff.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod metadata;
66
mod missing;
77
mod multipart;
88
mod no_diff;
9+
mod option_format;
910
mod path_filter;
1011
mod solid_archive;
1112
mod symlink;

0 commit comments

Comments
 (0)