@@ -378,32 +378,202 @@ fn cmd_sync_download(
378378 Ok ( ( ) )
379379}
380380
381- /// Upload local configs to Cloudflare (placeholder).
381+ /// Upload local configs to Cloudflare with diff preview.
382+ ///
383+ /// Reads a saved JSON config file, compares each setting against live values,
384+ /// and (unless --dry-run) patches any differences.
382385fn cmd_sync_upload (
383- _client : & api:: CloudflareClient ,
386+ client : & api:: CloudflareClient ,
384387 path : & str ,
385388 dry_run : bool ,
386- _json_output : bool ,
389+ json_output : bool ,
387390) -> Result < ( ) , String > {
388- println ! ( "Upload from: {} (dry_run: {})" , path, dry_run) ;
389- println ! ( "TODO: Implement config upload with diff preview." ) ;
391+ let file_content = std:: fs:: read_to_string ( path)
392+ . map_err ( |e| format ! ( "Failed to read {}: {}" , path, e) ) ?;
393+ let config: serde_json:: Value = serde_json:: from_str ( & file_content)
394+ . map_err ( |e| format ! ( "Failed to parse JSON from {}: {}" , path, e) ) ?;
395+
396+ let zone_id = config. get ( "zone_id" )
397+ . and_then ( |v| v. as_str ( ) )
398+ . ok_or_else ( || "Config file missing 'zone_id' field" . to_string ( ) ) ?;
399+ let domain = config. get ( "domain" )
400+ . and_then ( |v| v. as_str ( ) )
401+ . unwrap_or ( "unknown" ) ;
402+
403+ // Get live settings for comparison.
404+ let live_settings = client. get_zone_settings ( zone_id) ?;
405+
406+ let offline_settings = config. get ( "settings" )
407+ . and_then ( |v| v. as_array ( ) )
408+ . ok_or_else ( || "Config file missing 'settings' array" . to_string ( ) ) ?;
409+
410+ let mut diffs = Vec :: new ( ) ;
411+
412+ for offline in offline_settings {
413+ let id = offline. get ( "id" ) . and_then ( |v| v. as_str ( ) ) . unwrap_or ( "" ) ;
414+ let offline_val = offline. get ( "value" ) ;
415+ let live = live_settings. iter ( ) . find ( |s| s. id == id) ;
416+
417+ if let ( Some ( off_v) , Some ( live_s) ) = ( offline_val, live) {
418+ if off_v != & live_s. value {
419+ diffs. push ( ( id. to_string ( ) , off_v. clone ( ) , live_s. value . clone ( ) ) ) ;
420+ }
421+ }
422+ }
423+
424+ if json_output {
425+ let diff_json: Vec < _ > = diffs. iter ( ) . map ( |( id, offline, live) | {
426+ serde_json:: json!( {
427+ "setting_id" : id,
428+ "offline" : offline,
429+ "live" : live,
430+ } )
431+ } ) . collect ( ) ;
432+
433+ println ! ( "{}" , serde_json:: json!( {
434+ "domain" : domain,
435+ "zone_id" : zone_id,
436+ "diffs" : diff_json,
437+ "total_diffs" : diffs. len( ) ,
438+ "dry_run" : dry_run,
439+ "applied" : !dry_run && !diffs. is_empty( ) ,
440+ } ) ) ;
441+ } else {
442+ println ! ( "Config upload: {} ({})" , domain, zone_id) ;
443+ if diffs. is_empty ( ) {
444+ println ! ( " No differences — live matches offline config." ) ;
445+ } else {
446+ println ! ( " {} setting(s) differ:" , diffs. len( ) ) ;
447+ for ( id, offline_val, live_val) in & diffs {
448+ println ! ( " {}: live={} -> offline={}" , id, live_val, offline_val) ;
449+ }
450+ }
451+ }
452+
453+ if !dry_run && !diffs. is_empty ( ) {
454+ let items: Vec < _ > = diffs. iter ( ) . map ( |( id, val, _) | {
455+ serde_json:: json!( { "id" : id, "value" : val} )
456+ } ) . collect ( ) ;
457+ let patch_body = serde_json:: json!( { "items" : items} ) ;
458+ client. patch_zone_settings ( zone_id, & patch_body) ?;
459+
460+ if !json_output {
461+ println ! ( " Applied {} setting changes." , diffs. len( ) ) ;
462+ }
463+ } else if dry_run && !diffs. is_empty ( ) && !json_output {
464+ println ! ( " [DRY RUN] Use without --dry-run to apply changes." ) ;
465+ }
466+
390467 Ok ( ( ) )
391468}
392469
393- /// Show config drift between offline and live.
470+ /// Show three-way config drift: offline vs live vs policy.
471+ ///
472+ /// For each hardening policy setting, compares the live value, the offline
473+ /// saved value (if a config file exists), and the policy-expected value.
394474fn cmd_diff (
395- _client : & api:: CloudflareClient ,
475+ client : & api:: CloudflareClient ,
396476 domain : Option < String > ,
397- _json_output : bool ,
477+ json_output : bool ,
398478) -> Result < ( ) , String > {
399- match domain {
400- Some ( d) => println ! ( "Diff for: {}" , d) ,
401- None => println ! ( "Diff for all domains" ) ,
479+ let zones = match domain {
480+ Some ( ref d) => vec ! [ client. find_zone_by_name( d) ?] ,
481+ None => client. list_zones ( ) ?,
482+ } ;
483+
484+ for zone in & zones {
485+ let live_settings = client. get_zone_settings ( & zone. id ) ?;
486+
487+ // Try to load offline config if it exists.
488+ let offline_config = load_offline_config ( & zone. name ) ;
489+
490+ let mut entries = Vec :: new ( ) ;
491+
492+ for & ( setting_id, expected, _severity) in api:: hardening_policy ( ) {
493+ let live_val = live_settings. iter ( )
494+ . find ( |s| s. id == setting_id)
495+ . map ( |s| setting_value_to_string ( & s. value ) )
496+ . unwrap_or_else ( || "<missing>" . to_string ( ) ) ;
497+
498+ let offline_val = offline_config. as_ref ( )
499+ . and_then ( |cfg| cfg. get ( "settings" ) )
500+ . and_then ( |s| s. as_array ( ) )
501+ . and_then ( |arr| arr. iter ( ) . find ( |s| s. get ( "id" ) . and_then ( |v| v. as_str ( ) ) == Some ( setting_id) ) )
502+ . and_then ( |s| s. get ( "value" ) )
503+ . map ( |v| setting_value_to_string ( v) )
504+ . unwrap_or_else ( || "<no offline>" . to_string ( ) ) ;
505+
506+ let policy_val = expected. to_string ( ) ;
507+
508+ let live_matches_policy = live_val == policy_val;
509+ let live_matches_offline = live_val == offline_val;
510+
511+ entries. push ( serde_json:: json!( {
512+ "setting" : setting_id,
513+ "live" : live_val,
514+ "offline" : offline_val,
515+ "policy" : policy_val,
516+ "live_matches_policy" : live_matches_policy,
517+ "live_matches_offline" : live_matches_offline,
518+ } ) ) ;
519+ }
520+
521+ if json_output {
522+ println ! ( "{}" , serde_json:: json!( {
523+ "domain" : zone. name,
524+ "diffs" : entries,
525+ } ) ) ;
526+ } else {
527+ println ! ( "Three-way diff for: {}" , zone. name) ;
528+ println ! ( "{:<25} {:<15} {:<15} {:<15} {}" , "Setting" , "Live" , "Offline" , "Policy" , "Status" ) ;
529+ println ! ( "{}" , "-" . repeat( 80 ) ) ;
530+
531+ for entry in & entries {
532+ let setting = entry[ "setting" ] . as_str ( ) . unwrap_or ( "" ) ;
533+ let live = entry[ "live" ] . as_str ( ) . unwrap_or ( "" ) ;
534+ let offline = entry[ "offline" ] . as_str ( ) . unwrap_or ( "" ) ;
535+ let policy = entry[ "policy" ] . as_str ( ) . unwrap_or ( "" ) ;
536+ let matches_policy = entry[ "live_matches_policy" ] . as_bool ( ) . unwrap_or ( false ) ;
537+ let matches_offline = entry[ "live_matches_offline" ] . as_bool ( ) . unwrap_or ( false ) ;
538+
539+ let status = if matches_policy && matches_offline {
540+ "OK"
541+ } else if !matches_policy && !matches_offline {
542+ "CONFLICT"
543+ } else if !matches_policy {
544+ "DRIFT"
545+ } else {
546+ "CHANGED"
547+ } ;
548+
549+ println ! ( "{:<25} {:<15} {:<15} {:<15} {}" , setting, live, offline, policy, status) ;
550+ }
551+ println ! ( ) ;
552+ }
402553 }
403- println ! ( "TODO: Implement three-way diff (offline vs live vs policy)." ) ;
554+
404555 Ok ( ( ) )
405556}
406557
558+ /// Load an offline config file for a domain (if it exists).
559+ fn load_offline_config ( domain : & str ) -> Option < serde_json:: Value > {
560+ let dir = dirs:: config_dir ( ) ?. join ( "cloudguard" ) . join ( "configs" ) ;
561+ let filename = domain. replace ( '.' , "_" ) ;
562+ let path = dir. join ( format ! ( "{}.json" , filename) ) ;
563+ let content = std:: fs:: read_to_string ( path) . ok ( ) ?;
564+ serde_json:: from_str ( & content) . ok ( )
565+ }
566+
567+ /// Convert a serde_json::Value to a comparable string.
568+ fn setting_value_to_string ( value : & serde_json:: Value ) -> String {
569+ match value {
570+ serde_json:: Value :: String ( v) => v. clone ( ) ,
571+ serde_json:: Value :: Bool ( b) => if * b { "on" . to_string ( ) } else { "off" . to_string ( ) } ,
572+ serde_json:: Value :: Number ( n) => n. to_string ( ) ,
573+ other => other. to_string ( ) ,
574+ }
575+ }
576+
407577/// List DNS records for a domain.
408578fn cmd_dns_list (
409579 client : & api:: CloudflareClient ,
@@ -573,11 +743,28 @@ fn cmd_zones_status(
573743 Ok ( ( ) )
574744}
575745
576- /// List Pages projects (placeholder) .
746+ /// List Cloudflare Pages projects.
577747fn cmd_pages_list (
578- _client : & api:: CloudflareClient ,
579- _json_output : bool ,
748+ client : & api:: CloudflareClient ,
749+ json_output : bool ,
580750) -> Result < ( ) , String > {
581- println ! ( "TODO: Implement Pages project listing." ) ;
751+ let projects = client. list_pages_projects ( ) ?;
752+
753+ if json_output {
754+ println ! ( "{}" , serde_json:: to_string_pretty( & projects) . unwrap( ) ) ;
755+ } else {
756+ println ! ( "Pages Projects ({} total)" , projects. len( ) ) ;
757+ println ! ( "{:<30} {:<40} {:<15} {}" , "Name" , "Subdomain" , "Branch" , "Domains" ) ;
758+ println ! ( "{}" , "-" . repeat( 100 ) ) ;
759+ for p in & projects {
760+ let domains = if p. domains . is_empty ( ) {
761+ "none" . to_string ( )
762+ } else {
763+ p. domains . join ( ", " )
764+ } ;
765+ println ! ( "{:<30} {:<40} {:<15} {}" , p. name, p. subdomain, p. production_branch, domains) ;
766+ }
767+ }
768+
582769 Ok ( ( ) )
583770}
0 commit comments