@@ -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) ]
1212use pna:: prelude:: MetadataTimeExt ;
1313use 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
3665impl 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" ) ]
85122enum 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
112159impl 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`.
120184struct 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 ) ]
154218struct 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 }
0 commit comments