1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use tracing::{debug, warn};
9
10use crate::config::{Config, RuntimeMode};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SyncStatus {
15 Enabled,
17 Disabled,
19 Syncing,
21 Failed,
23}
24
25pub struct SyncManager {
27 api_url: Option<String>,
28 tenant_id: Option<String>,
29 auto_sync: bool,
30 runtime_mode: RuntimeMode,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34struct SyncRequest {
35 tenant_id: String,
36 config: Config,
37 timestamp: i64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41pub struct SyncResponse {
42 pub config: Config,
43 pub timestamp: i64,
44 pub conflicts: Vec<String>,
45}
46
47impl SyncManager {
48 pub fn new(
50 api_url: Option<String>,
51 tenant_id: Option<String>,
52 auto_sync: bool,
53 runtime_mode: RuntimeMode,
54 ) -> Self {
55 Self {
56 api_url,
57 tenant_id,
58 auto_sync,
59 runtime_mode,
60 }
61 }
62
63 pub fn is_enabled(&self) -> bool {
70 if !self.auto_sync {
71 return false;
72 }
73
74 if matches!(self.runtime_mode, RuntimeMode::AirGapped) {
75 debug!("Sync disabled: air-gapped mode");
76 return false;
77 }
78
79 if self.api_url.is_none() || self.tenant_id.is_none() {
80 debug!("Sync disabled: missing API URL or tenant ID");
81 return false;
82 }
83
84 true
85 }
86
87 pub fn status(&self) -> SyncStatus {
89 if self.is_enabled() {
90 SyncStatus::Enabled
91 } else {
92 SyncStatus::Disabled
93 }
94 }
95
96 pub async fn sync_to_cloud(&self, config: &Config) -> Result<Option<SyncResponse>> {
106 if !self.is_enabled() {
107 return Ok(None);
108 }
109
110 let api_url = self.api_url.as_ref().unwrap();
111 let tenant_id = self.tenant_id.as_ref().unwrap();
112
113 let sync_request = SyncRequest {
114 tenant_id: tenant_id.clone(),
115 config: config.clone(),
116 timestamp: chrono::Utc::now().timestamp(),
117 };
118
119 let client = reqwest::Client::new();
120 let url = format!("{}/api/v1/config/sync", api_url);
121
122 debug!("Syncing configuration to cloud: {}", url);
123
124 let response = client
125 .post(&url)
126 .json(&sync_request)
127 .send()
128 .await
129 .context("Failed to send sync request")?;
130
131 if !response.status().is_success() {
132 let status = response.status();
133 let error_text = response.text().await.unwrap_or_default();
134 return Err(anyhow::anyhow!(
135 "Sync failed with status {}: {}",
136 status,
137 error_text
138 ));
139 }
140
141 let sync_response: SyncResponse = response
142 .json()
143 .await
144 .context("Failed to parse sync response")?;
145
146 if !sync_response.conflicts.is_empty() {
147 warn!(
148 "Configuration conflicts detected: {:?}",
149 sync_response.conflicts
150 );
151 }
152
153 debug!("Configuration synced successfully");
154 Ok(Some(sync_response))
155 }
156
157 pub async fn sync_from_cloud(&self) -> Result<Option<Config>> {
164 if !self.is_enabled() {
165 return Ok(None);
166 }
167
168 let api_url = self.api_url.as_ref().unwrap();
169 let tenant_id = self.tenant_id.as_ref().unwrap();
170
171 let client = reqwest::Client::new();
172 let url = format!("{}/api/v1/config/sync?tenant_id={}", api_url, tenant_id);
173
174 debug!("Syncing configuration from cloud: {}", url);
175
176 let response = client
177 .get(&url)
178 .send()
179 .await
180 .context("Failed to send sync request")?;
181
182 if !response.status().is_success() {
183 let status = response.status();
184 let error_text = response.text().await.unwrap_or_default();
185 return Err(anyhow::anyhow!(
186 "Sync failed with status {}: {}",
187 status,
188 error_text
189 ));
190 }
191
192 let sync_response: SyncResponse = response
193 .json()
194 .await
195 .context("Failed to parse sync response")?;
196
197 debug!("Configuration synced from cloud successfully");
198 Ok(Some(sync_response.config))
199 }
200
201 pub fn resolve_conflicts(
211 &self,
212 _local_config: &Config,
213 cloud_config: &Config,
214 conflicts: &[String],
215 ) -> Config {
216 warn!(
219 "Resolving {} conflicts using last-write-wins (cloud wins)",
220 conflicts.len()
221 );
222 cloud_config.clone()
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn test_sync_enabled() {
232 let manager = SyncManager::new(
233 Some("https://api.example.com".to_string()),
234 Some("tenant-123".to_string()),
235 true,
236 RuntimeMode::Local,
237 );
238 assert!(manager.is_enabled());
239 assert_eq!(manager.status(), SyncStatus::Enabled);
240 }
241
242 #[test]
243 fn test_sync_disabled_airgapped() {
244 let manager = SyncManager::new(
245 Some("https://api.example.com".to_string()),
246 Some("tenant-123".to_string()),
247 true,
248 RuntimeMode::AirGapped,
249 );
250 assert!(!manager.is_enabled());
251 assert_eq!(manager.status(), SyncStatus::Disabled);
252 }
253
254 #[test]
255 fn test_sync_disabled_no_auth() {
256 let manager = SyncManager::new(None, None, true, RuntimeMode::Local);
257 assert!(!manager.is_enabled());
258 assert_eq!(manager.status(), SyncStatus::Disabled);
259 }
260}