//! Config diff engine — classifies changes between old and new configs into tiers. //! //! Tier A: Bandwidth — live RC API call, no restart. //! Tier B: Protocols — regen SMB/NFS configs, restart smbd/exportfs. //! Tier C: Per-share — drain affected share, unmount, remount. //! Tier D: Global — drain all, stop everything, restart. use crate::config::Config; /// The set of changes between two configs, classified by tier. #[derive(Debug, Default)] pub struct ConfigDiff { /// Tier A: bandwidth limits changed. pub bandwidth_changed: bool, /// Tier B: protocol settings changed (SMB/NFS/WebDAV toggles, SMB auth, NFS network). pub protocols_changed: bool, /// Tier C: shares that were removed (by name). pub shares_removed: Vec, /// Tier C: shares that were added (by name). pub shares_added: Vec, /// Tier C: shares that were modified (remote_path, mount_point, read_only, or connection changed). pub shares_modified: Vec, /// Tier C: connections that were added (by name). pub connections_added: Vec, /// Tier C: connections that were removed (by name). pub connections_removed: Vec, /// Tier C: connections that were modified (by name) — affects shares referencing them. pub connections_modified: Vec, /// Tier D: global settings changed (cache, read, writeback, directory_cache). pub global_changed: bool, /// Warmup settings changed (no restart needed, just update in-memory config). pub warmup_changed: bool, /// Dir-refresh settings changed (no restart needed, just re-spawn background threads). pub dir_refresh_changed: bool, } impl ConfigDiff { /// Returns true if no changes were detected. pub fn is_empty(&self) -> bool { !self.bandwidth_changed && !self.protocols_changed && self.shares_removed.is_empty() && self.shares_added.is_empty() && self.shares_modified.is_empty() && self.connections_added.is_empty() && self.connections_removed.is_empty() && self.connections_modified.is_empty() && !self.global_changed && !self.warmup_changed && !self.dir_refresh_changed } /// Returns the highest tier of change detected. pub fn highest_tier(&self) -> ChangeTier { if self.global_changed { ChangeTier::Global } else if !self.shares_removed.is_empty() || !self.shares_added.is_empty() || !self.shares_modified.is_empty() || !self.connections_added.is_empty() || !self.connections_removed.is_empty() || !self.connections_modified.is_empty() { ChangeTier::PerShare } else if self.protocols_changed { ChangeTier::Protocol } else if self.bandwidth_changed { ChangeTier::Live } else { ChangeTier::None } } /// Human-readable summary of changes. pub fn summary(&self) -> String { let mut parts = Vec::new(); if self.global_changed { parts.push("global settings changed (full restart required)".to_string()); } if !self.connections_removed.is_empty() { parts.push(format!("connections removed: {}", self.connections_removed.join(", "))); } if !self.connections_added.is_empty() { parts.push(format!("connections added: {}", self.connections_added.join(", "))); } if !self.connections_modified.is_empty() { parts.push(format!("connections modified: {}", self.connections_modified.join(", "))); } if !self.shares_removed.is_empty() { parts.push(format!("shares removed: {}", self.shares_removed.join(", "))); } if !self.shares_added.is_empty() { parts.push(format!("shares added: {}", self.shares_added.join(", "))); } if !self.shares_modified.is_empty() { parts.push(format!( "shares modified: {}", self.shares_modified.join(", ") )); } if self.protocols_changed { parts.push("protocol settings changed".to_string()); } if self.bandwidth_changed { parts.push("bandwidth limits changed".to_string()); } if self.warmup_changed { parts.push("warmup settings changed".to_string()); } if self.dir_refresh_changed { parts.push("dir-refresh settings changed".to_string()); } if parts.is_empty() { "no changes detected".to_string() } else { parts.join("; ") } } } /// Tier of change, from least to most disruptive. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ChangeTier { None, /// Tier A: live RC API call. Live, /// Tier B: protocol restart only. Protocol, /// Tier C: per-share mount restart. PerShare, /// Tier D: full global restart. Global, } /// Compare two configs and classify all changes. pub fn diff(old: &Config, new: &Config) -> ConfigDiff { let mut d = ConfigDiff::default(); // Tier D: global settings (cache, read, writeback, directory_cache) d.global_changed = old.cache.dir != new.cache.dir || old.cache.max_size != new.cache.max_size || old.cache.max_age != new.cache.max_age || old.cache.min_free != new.cache.min_free || old.read.chunk_size != new.read.chunk_size || old.read.chunk_limit != new.read.chunk_limit || old.read.read_ahead != new.read.read_ahead || old.read.buffer_size != new.read.buffer_size || old.writeback.write_back != new.writeback.write_back || old.writeback.transfers != new.writeback.transfers || old.directory_cache.cache_time != new.directory_cache.cache_time; // Tier A: bandwidth d.bandwidth_changed = old.bandwidth.limit_up != new.bandwidth.limit_up || old.bandwidth.limit_down != new.bandwidth.limit_down || old.bandwidth.adaptive != new.bandwidth.adaptive; // Tier B: protocols d.protocols_changed = old.protocols.enable_smb != new.protocols.enable_smb || old.protocols.enable_nfs != new.protocols.enable_nfs || old.protocols.enable_webdav != new.protocols.enable_webdav || old.protocols.nfs_allowed_network != new.protocols.nfs_allowed_network || old.protocols.webdav_port != new.protocols.webdav_port || old.smb_auth.enabled != new.smb_auth.enabled || old.smb_auth.username != new.smb_auth.username || old.smb_auth.smb_pass != new.smb_auth.smb_pass; // Tier C: connection changes let old_conns: std::collections::HashMap<&str, &crate::config::ConnectionConfig> = old.connections.iter().map(|c| (c.name.as_str(), c)).collect(); let new_conns: std::collections::HashMap<&str, &crate::config::ConnectionConfig> = new.connections.iter().map(|c| (c.name.as_str(), c)).collect(); // Removed connections for name in old_conns.keys() { if !new_conns.contains_key(name) { d.connections_removed.push(name.to_string()); } } // Added connections for name in new_conns.keys() { if !old_conns.contains_key(name) { d.connections_added.push(name.to_string()); } } // Modified connections for (name, old_conn) in &old_conns { if let Some(new_conn) = new_conns.get(name) { if old_conn != new_conn { d.connections_modified.push(name.to_string()); } } } // Tier C: per-share changes let old_shares: std::collections::HashMap<&str, &crate::config::ShareConfig> = old.shares.iter().map(|s| (s.name.as_str(), s)).collect(); let new_shares: std::collections::HashMap<&str, &crate::config::ShareConfig> = new.shares.iter().map(|s| (s.name.as_str(), s)).collect(); // Removed shares for name in old_shares.keys() { if !new_shares.contains_key(name) { d.shares_removed.push(name.to_string()); } } // Added shares for name in new_shares.keys() { if !old_shares.contains_key(name) { d.shares_added.push(name.to_string()); } } // Modified shares (direct changes or indirectly via connection modification) let mut modified_set = std::collections::HashSet::new(); for (name, old_share) in &old_shares { if let Some(new_share) = new_shares.get(name) { if old_share.remote_path != new_share.remote_path || old_share.mount_point != new_share.mount_point || old_share.read_only != new_share.read_only || old_share.connection != new_share.connection { modified_set.insert(name.to_string()); } } } // Shares indirectly affected by connection modifications for conn_name in &d.connections_modified { for share in &new.shares { if share.connection == *conn_name && !modified_set.contains(&share.name) { modified_set.insert(share.name.clone()); } } } // Shares referencing removed connections for conn_name in &d.connections_removed { for share in &old.shares { if share.connection == *conn_name && !modified_set.contains(&share.name) { modified_set.insert(share.name.clone()); } } } d.shares_modified = modified_set.into_iter().collect(); // Warmup changes (no restart needed) d.warmup_changed = old.warmup.auto != new.warmup.auto || old.warmup.rules.len() != new.warmup.rules.len() || old .warmup .rules .iter() .zip(new.warmup.rules.iter()) .any(|(o, n)| o.share != n.share || o.path != n.path || o.newer_than != n.newer_than); // Dir-refresh changes (no restart needed, just re-spawn background threads) let dir_refresh_global_changed = old.dir_refresh != new.dir_refresh; let dir_refresh_per_share_changed = { // Compare per-share overrides by (name → dir_refresh_interval) mapping let old_map: std::collections::HashMap<&str, Option<&str>> = old .shares .iter() .map(|s| (s.name.as_str(), s.dir_refresh_interval.as_deref())) .collect(); let new_map: std::collections::HashMap<&str, Option<&str>> = new .shares .iter() .map(|s| (s.name.as_str(), s.dir_refresh_interval.as_deref())) .collect(); old_map != new_map }; d.dir_refresh_changed = dir_refresh_global_changed || dir_refresh_per_share_changed; d } #[cfg(test)] mod tests { use super::*; fn minimal_config() -> Config { toml::from_str( r#" [[connections]] name = "nas" nas_host = "10.0.0.1" nas_user = "admin" [cache] dir = "/tmp/cache" [read] [bandwidth] [writeback] [directory_cache] [protocols] [[shares]] name = "photos" connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, ) .unwrap() } #[test] fn test_no_changes() { let config = minimal_config(); let d = diff(&config, &config); assert!(d.is_empty()); assert_eq!(d.highest_tier(), ChangeTier::None); } #[test] fn test_bandwidth_change() { let old = minimal_config(); let mut new = old.clone(); new.bandwidth.limit_up = "10M".to_string(); let d = diff(&old, &new); assert!(d.bandwidth_changed); assert!(!d.global_changed); assert_eq!(d.highest_tier(), ChangeTier::Live); } #[test] fn test_protocol_change() { let old = minimal_config(); let mut new = old.clone(); new.protocols.enable_nfs = true; let d = diff(&old, &new); assert!(d.protocols_changed); assert_eq!(d.highest_tier(), ChangeTier::Protocol); } #[test] fn test_share_added() { let old = minimal_config(); let mut new = old.clone(); new.shares.push(crate::config::ShareConfig { name: "videos".to_string(), connection: "nas".to_string(), remote_path: "/videos".to_string(), mount_point: "/mnt/videos".into(), read_only: false, dir_refresh_interval: None, }); let d = diff(&old, &new); assert_eq!(d.shares_added, vec!["videos"]); assert_eq!(d.highest_tier(), ChangeTier::PerShare); } #[test] fn test_share_removed() { let old = minimal_config(); let mut new = old.clone(); new.shares.clear(); new.shares.push(crate::config::ShareConfig { name: "videos".to_string(), connection: "nas".to_string(), remote_path: "/videos".to_string(), mount_point: "/mnt/videos".into(), read_only: false, dir_refresh_interval: None, }); let d = diff(&old, &new); assert_eq!(d.shares_removed, vec!["photos"]); assert_eq!(d.shares_added, vec!["videos"]); } #[test] fn test_share_modified() { let old = minimal_config(); let mut new = old.clone(); new.shares[0].remote_path = "/volume1/photos".to_string(); let d = diff(&old, &new); assert_eq!(d.shares_modified, vec!["photos"]); assert_eq!(d.highest_tier(), ChangeTier::PerShare); } #[test] fn test_global_change() { let old = minimal_config(); let mut new = old.clone(); new.cache.max_size = "500G".to_string(); let d = diff(&old, &new); assert!(d.global_changed); assert_eq!(d.highest_tier(), ChangeTier::Global); } #[test] fn test_connection_modified_affects_shares() { let old = minimal_config(); let mut new = old.clone(); new.connections[0].nas_host = "192.168.1.1".to_string(); let d = diff(&old, &new); assert_eq!(d.connections_modified, vec!["nas"]); // Share "photos" references "nas", so it should be in shares_modified assert!(d.shares_modified.contains(&"photos".to_string())); assert_eq!(d.highest_tier(), ChangeTier::PerShare); assert!(!d.global_changed); } #[test] fn test_connection_added() { let old = minimal_config(); let mut new = old.clone(); new.connections.push(crate::config::ConnectionConfig { name: "office".to_string(), nas_host: "10.0.0.2".to_string(), nas_user: "admin".to_string(), nas_pass: None, nas_key_file: None, sftp_port: 22, sftp_connections: 8, }); let d = diff(&old, &new); assert_eq!(d.connections_added, vec!["office"]); assert_eq!(d.highest_tier(), ChangeTier::PerShare); } #[test] fn test_connection_removed_affects_shares() { let config: Config = toml::from_str( r#" [[connections]] name = "home" nas_host = "10.0.0.1" nas_user = "admin" [[connections]] name = "office" nas_host = "10.0.0.2" nas_user = "admin" [cache] dir = "/tmp/cache" [read] [bandwidth] [writeback] [directory_cache] [protocols] [[shares]] name = "photos" connection = "home" remote_path = "/photos" mount_point = "/mnt/photos" [[shares]] name = "projects" connection = "office" remote_path = "/projects" mount_point = "/mnt/projects" "#, ) .unwrap(); let mut new = config.clone(); // Remove "office" connection and its share new.connections.retain(|c| c.name != "office"); new.shares.retain(|s| s.name != "projects"); let d = diff(&config, &new); assert_eq!(d.connections_removed, vec!["office"]); assert!(d.shares_removed.contains(&"projects".to_string())); } #[test] fn test_share_connection_changed() { let config: Config = toml::from_str( r#" [[connections]] name = "home" nas_host = "10.0.0.1" nas_user = "admin" [[connections]] name = "office" nas_host = "10.0.0.2" nas_user = "admin" [cache] dir = "/tmp/cache" [read] [bandwidth] [writeback] [directory_cache] [protocols] [[shares]] name = "photos" connection = "home" remote_path = "/photos" mount_point = "/mnt/photos" "#, ) .unwrap(); let mut new = config.clone(); new.shares[0].connection = "office".to_string(); let d = diff(&config, &new); assert!(d.shares_modified.contains(&"photos".to_string())); assert_eq!(d.highest_tier(), ChangeTier::PerShare); } #[test] fn test_summary() { let old = minimal_config(); let mut new = old.clone(); new.bandwidth.limit_up = "10M".to_string(); new.protocols.enable_nfs = true; let d = diff(&old, &new); let summary = d.summary(); assert!(summary.contains("protocol")); assert!(summary.contains("bandwidth")); } #[test] fn test_warmup_change() { let old = minimal_config(); let mut new = old.clone(); new.warmup.auto = true; new.warmup.rules.push(crate::config::WarmupRule { share: "photos".to_string(), path: "/2024".to_string(), newer_than: Some("7d".to_string()), }); let d = diff(&old, &new); assert!(d.warmup_changed); assert!(!d.global_changed); // Warmup-only changes need no restart assert_eq!(d.highest_tier(), ChangeTier::None); assert!(!d.is_empty()); assert!(d.summary().contains("warmup")); } #[test] fn test_tier_ordering() { assert!(ChangeTier::None < ChangeTier::Live); assert!(ChangeTier::Live < ChangeTier::Protocol); assert!(ChangeTier::Protocol < ChangeTier::PerShare); assert!(ChangeTier::PerShare < ChangeTier::Global); } }