1use std::io;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use chrono::{DateTime, SecondsFormat, Utc};
6use futures_util::StreamExt;
7use serde::{Deserialize, Serialize};
8use tokio::fs;
9
10use crate::client::Client;
11use crate::error::Error;
12use crate::wire::decode_segment_stream;
13
14pub struct Config {
15 pub server_url: http::Uri,
16 pub data_dir: PathBuf,
17 pub segments: u8,
18}
19
20pub enum Outcome {
21 UpToDate,
22 DeltaSync { changed_count: usize },
23 FullSync { file_count: usize },
24}
25
26#[derive(Serialize, Deserialize, Default)]
27struct LocalState {
28 last_updated: Option<DateTime<Utc>>,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32struct Plan {
33 server_last_updated: DateTime<Utc>,
34 since: Option<String>,
35 segments: u8,
36}
37
38#[tracing::instrument(skip_all)]
39pub async fn sync(config: &Config) -> Result<Outcome, Error> {
40 if config.segments == 0 {
41 return Err(Error::InvalidConfig("segments must be >= 1"));
42 }
43
44 let staging = config.data_dir.join(".staging");
45 let complete_marker = staging.join(".complete");
46 let plan_path = staging.join(".sync-plan.json");
47
48 if staging.exists() {
49 if complete_marker.exists() {
50 tracing::info!("staging/.complete exists - finishing interrupted commit");
51 return finish_commit(&staging, &config.data_dir).await;
52 } else if plan_path.exists() {
53 tracing::info!("resuming interrupted download");
54 let plan: Plan = serde_json::from_slice(&fs::read(&plan_path).await?)?;
55
56 let client = Client::new(&config.server_url)?;
57 let status = client.status().await?;
58
59 if status.last_updated != Some(plan.server_last_updated) {
60 tracing::warn!(
61 "server state changed since last attempt; discarding staging and starting fresh"
62 );
63 clear_staging(&staging).await?;
64 } else {
65 fetch_missing_segments(&config.server_url, &staging, &plan).await?;
66 fs::write(&complete_marker, b"").await?;
67 return finish_commit(&staging, &config.data_dir).await;
68 }
69 } else {
70 tracing::warn!("staging exists without .sync-plan.json; discarding");
71 clear_staging(&staging).await?;
72 }
73 }
74
75 let state_path = config.data_dir.join("sync-state.json");
76 let local: LocalState = match fs::read(&state_path).await {
77 Ok(bytes) => serde_json::from_slice(&bytes)?,
78 Err(e) if e.kind() == io::ErrorKind::NotFound => LocalState::default(),
79 Err(e) => return Err(e.into()),
80 };
81 let client = Client::new(&config.server_url)?;
82 let server_last_updated = match client.status().await?.last_updated {
83 Some(t) => t,
84 None => return Ok(Outcome::UpToDate),
85 };
86
87 if Some(server_last_updated) <= local.last_updated {
88 return Ok(Outcome::UpToDate);
89 }
90
91 let since_opt: Option<String> = if local.last_updated.is_none() {
94 None
95 } else {
96 let changed = client.changed().await?;
97 if changed.prev_last_updated == local.last_updated {
98 local.last_updated.map(|t| t.to_rfc3339_opts(SecondsFormat::Secs, true))
99 } else {
100 tracing::warn!(
101 "server prev_last_updated does not match local last_updated; falling back to full sync"
102 );
103 None
104 }
105 };
106
107 fs::create_dir_all(&staging).await?;
108
109 let plan = Plan { server_last_updated, since: since_opt, segments: config.segments };
110 fs::write(&plan_path, serde_json::to_vec_pretty(&plan)?).await?;
111
112 fetch_missing_segments(&config.server_url, &staging, &plan).await?;
113 fs::write(&complete_marker, b"").await?;
114
115 finish_commit(&staging, &config.data_dir).await
116}
117
118#[tracing::instrument(skip(server_url, staging), fields(segments = plan.segments, since = plan.since.as_deref()))]
119async fn fetch_missing_segments(
120 server_url: &http::Uri,
121 staging: &Path,
122 plan: &Plan,
123) -> Result<(), Error> {
124 let segments = plan.segments;
125 let client = Client::new(server_url)?;
126
127 for seg in 0..segments {
128 if staging.join(format!(".seg.{}.done", seg)).exists() {
129 continue;
130 }
131 fetch_segment_with_retry(&client, seg, segments, plan.since.as_deref(), staging).await?;
132 }
133
134 Ok(())
135}
136
137#[tracing::instrument(skip(client, staging))]
138async fn fetch_segment_with_retry(
139 client: &Client,
140 segment: u8,
141 of: u8,
142 since: Option<&str>,
143 staging: &Path,
144) -> Result<(), Error> {
145 const MAX_RETRIES: u32 = 5;
146 let mut delay = Duration::from_millis(500);
147 let mut last_result = Ok(());
148
149 for attempt in 0..MAX_RETRIES {
150 if attempt > 0 {
151 tokio::time::sleep(delay).await;
152 delay *= 2;
153 }
154 match client.segment_stream(segment, of, since).await {
155 Ok(decoder) => {
156 let mut stream = Box::pin(decode_segment_stream(decoder));
157 while let Some(entry_res) = stream.next().await {
158 let entry = entry_res?;
159 let prefix_str = std::str::from_utf8(&entry.prefix)
160 .map_err(|e| Error::Decode(format!("invalid prefix bytes: {e}")))?;
161 fs::write(staging.join(format!("{}.bin", prefix_str)), &entry.content).await?;
162 }
163 fs::write(staging.join(format!(".seg.{}.done", segment)), b"").await?;
164 return Ok(());
165 }
166 Err(e) => last_result = Err(e),
167 }
168 }
169
170 last_result
171}
172
173#[tracing::instrument(skip_all)]
174async fn finish_commit(staging: &Path, data_dir: &Path) -> Result<Outcome, Error> {
175 let plan: Plan = serde_json::from_slice(&fs::read(staging.join(".sync-plan.json")).await?)?;
176
177 let mut entries = fs::read_dir(staging).await?;
178 let mut file_count = 0usize;
179 while let Some(entry) = entries.next_entry().await? {
180 let src = entry.path();
181 if src.extension().is_some_and(|e| e == "bin") {
182 fs::rename(&src, data_dir.join(src.file_name().unwrap())).await?;
183 file_count += 1;
184 }
185 }
186
187 let state_path = data_dir.join("sync-state.json");
188 let new_state = LocalState { last_updated: Some(plan.server_last_updated) };
189 let tmp = state_path.with_extension("json.tmp");
190 fs::write(&tmp, serde_json::to_vec_pretty(&new_state)?).await?;
191 fs::rename(&tmp, &state_path).await?;
192
193 clear_staging(staging).await?;
194
195 Ok(match plan.since {
196 Some(_) => Outcome::DeltaSync { changed_count: file_count },
197 None => Outcome::FullSync { file_count },
198 })
199}
200
201async fn clear_staging(staging: &Path) -> Result<(), Error> {
202 fs::remove_dir_all(staging).await?;
203 Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[tokio::test]
211 async fn sync_rejects_zero_segments() {
212 let tmp = tempfile::tempdir().unwrap();
213 let cfg = Config {
214 server_url: "http://127.0.0.1:8765".parse().unwrap(),
215 data_dir: tmp.path().to_path_buf(),
216 segments: 0,
217 };
218
219 match sync(&cfg).await {
220 Err(Error::InvalidConfig(_)) => {}
221 Err(e) => panic!("expected InvalidConfig, got {e}"),
222 Ok(_) => panic!("expected error for zero segments"),
223 }
224 }
225}