use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncManifest {
pub version: u32,
pub last_sync: Option<DateTime<Utc>>,
pub provider_id: String,
pub files: HashMap<String, FileSyncState>,
#[serde(default)]
pub cursor: Option<String>,
}
impl SyncManifest {
pub const CURRENT_VERSION: u32 = 1;
pub fn new(provider_id: impl Into<String>) -> Self {
Self {
version: Self::CURRENT_VERSION,
last_sync: None,
provider_id: provider_id.into(),
files: HashMap::new(),
cursor: None,
}
}
pub fn get_file(&self, path: &str) -> Option<&FileSyncState> {
self.files.get(path)
}
pub fn set_file(&mut self, path: impl Into<String>, state: FileSyncState) {
self.files.insert(path.into(), state);
}
pub fn remove_file(&mut self, path: &str) -> Option<FileSyncState> {
self.files.remove(path)
}
pub fn mark_synced(&mut self) {
self.last_sync = Some(Utc::now());
}
pub fn needs_upload(&self, path: &str, local_modified_at: i64, content_hash: &str) -> bool {
match self.files.get(path) {
None => true, Some(state) => {
local_modified_at > state.local_modified_at || state.content_hash != content_hash
}
}
}
pub fn get_locally_deleted(&self, current_paths: &[String]) -> Vec<String> {
let current_set: std::collections::HashSet<_> = current_paths.iter().collect();
self.files
.keys()
.filter(|path| !current_set.contains(path))
.cloned()
.collect()
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub async fn load_from_file(
fs: &dyn crate::fs::AsyncFileSystem,
path: &Path,
) -> Result<Self, String> {
let content = fs
.read_to_string(path)
.await
.map_err(|e| format!("Failed to read manifest: {}", e))?;
Self::from_json(&content).map_err(|e| format!("Failed to parse manifest: {}", e))
}
pub async fn save_to_file(
&self,
fs: &dyn crate::fs::AsyncFileSystem,
path: &Path,
) -> Result<(), String> {
let content = self
.to_json()
.map_err(|e| format!("Failed to serialize manifest: {}", e))?;
fs.write_file(path, &content)
.await
.map_err(|e| format!("Failed to write manifest: {}", e))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileSyncState {
pub path: String,
pub content_hash: String,
pub synced_at: DateTime<Utc>,
#[serde(default)]
pub remote_version: Option<String>,
pub local_modified_at: i64,
#[serde(default)]
pub size: u64,
}
impl FileSyncState {
pub fn new(
path: impl Into<String>,
content_hash: impl Into<String>,
local_modified_at: i64,
) -> Self {
Self {
path: path.into(),
content_hash: content_hash.into(),
synced_at: Utc::now(),
remote_version: None,
local_modified_at,
size: 0,
}
}
pub fn with_remote_version(mut self, version: impl Into<String>) -> Self {
self.remote_version = Some(version.into());
self
}
pub fn with_size(mut self, size: u64) -> Self {
self.size = size;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_new() {
let manifest = SyncManifest::new("s3:my-bucket");
assert_eq!(manifest.version, SyncManifest::CURRENT_VERSION);
assert_eq!(manifest.provider_id, "s3:my-bucket");
assert!(manifest.files.is_empty());
assert!(manifest.last_sync.is_none());
}
#[test]
fn test_manifest_file_operations() {
let mut manifest = SyncManifest::new("test");
let state = FileSyncState::new("notes/test.md", "abc123", 1000);
manifest.set_file("notes/test.md", state);
let retrieved = manifest.get_file("notes/test.md").unwrap();
assert_eq!(retrieved.content_hash, "abc123");
assert_eq!(retrieved.local_modified_at, 1000);
let removed = manifest.remove_file("notes/test.md");
assert!(removed.is_some());
assert!(manifest.get_file("notes/test.md").is_none());
}
#[test]
fn test_needs_upload() {
let mut manifest = SyncManifest::new("test");
let state = FileSyncState::new("test.md", "hash123", 1000);
manifest.set_file("test.md", state);
assert!(manifest.needs_upload("new.md", 500, "anything"));
assert!(!manifest.needs_upload("test.md", 1000, "hash123"));
assert!(manifest.needs_upload("test.md", 2000, "hash123"));
assert!(manifest.needs_upload("test.md", 1000, "different_hash"));
}
#[test]
fn test_get_locally_deleted() {
let mut manifest = SyncManifest::new("test");
manifest.set_file("a.md", FileSyncState::new("a.md", "h1", 100));
manifest.set_file("b.md", FileSyncState::new("b.md", "h2", 200));
manifest.set_file("c.md", FileSyncState::new("c.md", "h3", 300));
let current = vec!["a.md".to_string(), "c.md".to_string()];
let deleted = manifest.get_locally_deleted(¤t);
assert_eq!(deleted, vec!["b.md".to_string()]);
}
#[test]
fn test_json_roundtrip() {
let mut manifest = SyncManifest::new("s3:test-bucket");
manifest.set_file(
"notes/test.md",
FileSyncState::new("notes/test.md", "abc123", 1000)
.with_remote_version("etag-xyz")
.with_size(256),
);
manifest.mark_synced();
let json = manifest.to_json().unwrap();
let parsed = SyncManifest::from_json(&json).unwrap();
assert_eq!(parsed.provider_id, "s3:test-bucket");
assert!(parsed.last_sync.is_some());
let file = parsed.get_file("notes/test.md").unwrap();
assert_eq!(file.content_hash, "abc123");
assert_eq!(file.remote_version, Some("etag-xyz".to_string()));
}
}