@@ -249,7 +249,11 @@ enum SyncCommands {
249249 include : Option < String > ,
250250 } ,
251251 /// List configured sync entries and their live state.
252- Ls ,
252+ Ls {
253+ /// Emit a stable JSON array for scripts.
254+ #[ arg( long) ]
255+ json : bool ,
256+ } ,
253257 /// Remove a sync entry by name or folder and reload the daemon.
254258 Rm { name_or_folder : String } ,
255259 /// Re-read syncs.toml into the running daemon (like reload-peers).
@@ -690,16 +694,42 @@ async fn run_sync(home: &FabricHome, command: SyncCommands) -> Result<()> {
690694 let _ = send_control ( home, ControlRequest :: SyncReload ) . await ;
691695 println ! ( "sync {name:?} written to {}" , home. syncs_path( ) . display( ) ) ;
692696 }
693- SyncCommands :: Ls => match send_control ( home, ControlRequest :: SyncStatus ) . await ? {
697+ SyncCommands :: Ls { json } => match send_control ( home, ControlRequest :: SyncStatus ) . await ? {
694698 ControlResponse :: SyncStatus { entries } => {
699+ if json {
700+ let entries: Vec < _ > = entries. iter ( ) . map ( SyncLsJsonEntry :: from) . collect ( ) ;
701+ println ! ( "{}" , serde_json:: to_string_pretty( & entries) ?) ;
702+ return Ok ( ( ) ) ;
703+ }
695704 if entries. is_empty ( ) {
696705 println ! ( "no sync entries" ) ;
697706 }
698707 for entry in entries {
699- println ! (
700- "{}\t {}\t {}\t peers={}\t {} files" ,
701- entry. name, entry. folder, entry. policy, entry. peers, entry. files
702- ) ;
708+ let present = logical_present ( & entry) ;
709+ if entry. missing == 0 && entry. unexpected == 0 && entry. mismatched == 0 {
710+ println ! (
711+ "{}\t {}\t {}\t peers={}\t present={present}\t tombstones={}\t observed={}\t drift=clean" ,
712+ entry. name,
713+ entry. folder,
714+ entry. policy,
715+ entry. peers,
716+ entry. tombstones,
717+ entry. observed,
718+ ) ;
719+ } else {
720+ println ! (
721+ "{}\t {}\t {}\t peers={}\t present={present}\t tombstones={}\t observed={}\t drift=WARNING missing={} unexpected={} mismatched={}" ,
722+ entry. name,
723+ entry. folder,
724+ entry. policy,
725+ entry. peers,
726+ entry. tombstones,
727+ entry. observed,
728+ entry. missing,
729+ entry. unexpected,
730+ entry. mismatched,
731+ ) ;
732+ }
703733 }
704734 }
705735 response => bail ! ( "unexpected daemon response: {response:?}" ) ,
@@ -721,6 +751,98 @@ async fn run_sync(home: &FabricHome, command: SyncCommands) -> Result<()> {
721751 Ok ( ( ) )
722752}
723753
754+ #[ derive( serde:: Serialize ) ]
755+ struct SyncLsJsonEntry < ' a > {
756+ name : & ' a str ,
757+ folder : & ' a str ,
758+ policy : & ' a str ,
759+ peers : & ' a str ,
760+ present : usize ,
761+ tombstones : usize ,
762+ observed : usize ,
763+ drift : bool ,
764+ missing : usize ,
765+ unexpected : usize ,
766+ mismatched : usize ,
767+ }
768+
769+ impl < ' a > From < & ' a fabric:: control:: SyncEntryStatus > for SyncLsJsonEntry < ' a > {
770+ fn from ( entry : & ' a fabric:: control:: SyncEntryStatus ) -> Self {
771+ Self {
772+ name : & entry. name ,
773+ folder : & entry. folder ,
774+ policy : & entry. policy ,
775+ peers : & entry. peers ,
776+ present : logical_present ( entry) ,
777+ tombstones : entry. tombstones ,
778+ observed : entry. observed ,
779+ drift : entry. missing != 0 || entry. unexpected != 0 || entry. mismatched != 0 ,
780+ missing : entry. missing ,
781+ unexpected : entry. unexpected ,
782+ mismatched : entry. mismatched ,
783+ }
784+ }
785+ }
786+
787+ fn logical_present ( entry : & fabric:: control:: SyncEntryStatus ) -> usize {
788+ if entry. present == 0 {
789+ entry. files
790+ } else {
791+ entry. present
792+ }
793+ }
794+
795+ #[ cfg( test) ]
796+ mod sync_ls_tests {
797+ use super :: * ;
798+ use fabric:: control:: SyncEntryStatus ;
799+
800+ fn status ( ) -> SyncEntryStatus {
801+ SyncEntryStatus {
802+ name : "catalog" . to_string ( ) ,
803+ folder : "/catalog" . to_string ( ) ,
804+ policy : "catalog" . to_string ( ) ,
805+ peers : "*" . to_string ( ) ,
806+ files : 40 ,
807+ present : 40 ,
808+ tombstones : 3 ,
809+ observed : 42 ,
810+ missing : 0 ,
811+ unexpected : 2 ,
812+ mismatched : 0 ,
813+ }
814+ }
815+
816+ #[ test]
817+ fn sync_ls_json_schema_exposes_counts_and_drift ( ) {
818+ let status = status ( ) ;
819+ let json = serde_json:: to_value ( SyncLsJsonEntry :: from ( & status) ) . unwrap ( ) ;
820+ assert_eq ! (
821+ json,
822+ serde_json:: json!( {
823+ "name" : "catalog" ,
824+ "folder" : "/catalog" ,
825+ "policy" : "catalog" ,
826+ "peers" : "*" ,
827+ "present" : 40 ,
828+ "tombstones" : 3 ,
829+ "observed" : 42 ,
830+ "drift" : true ,
831+ "missing" : 0 ,
832+ "unexpected" : 2 ,
833+ "mismatched" : 0
834+ } )
835+ ) ;
836+ }
837+
838+ #[ test]
839+ fn sync_ls_accepts_legacy_control_present_count ( ) {
840+ let mut status = status ( ) ;
841+ status. present = 0 ;
842+ assert_eq ! ( logical_present( & status) , 40 ) ;
843+ }
844+ }
845+
724846fn absolutize ( folder : & str ) -> Result < PathBuf > {
725847 let path = PathBuf :: from ( folder) ;
726848 if path. is_absolute ( ) {
0 commit comments