use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Metadata {
#[serde(default)]
pub version: u32,
pub projects: HashMap<String, ProjectEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEntry {
pub last_pushed_by: String,
pub last_pushed_at: DateTime<Utc>,
pub object_count: u32,
pub total_bytes: u64,
}
pub struct MetadataManager {
r2: Arc<crate::r2::R2Client>,
}
impl MetadataManager {
pub fn new(r2: Arc<crate::r2::R2Client>) -> Self {
Self { r2 }
}
pub async fn load(&self) -> Result<Metadata, crate::error::AppError> {
match self
.r2
.get_object_bytes(crate::r2::R2Client::METADATA_KEY)
.await
{
Ok(bytes) => {
let metadata: Metadata = serde_json::from_slice(&bytes).map_err(|e| {
crate::error::AppError::R2Error(format!(
"failed to deserialize metadata.json: {}",
e
))
})?;
Ok(metadata)
}
Err(crate::error::AppError::DownloadFailed { .. }) => Ok(Metadata::default()),
Err(e) => Err(crate::error::AppError::R2Error(e.to_string())),
}
}
pub async fn save(&self, metadata: &Metadata) -> Result<(), crate::error::AppError> {
let bytes = serde_json::to_vec(metadata).map_err(|e| {
crate::error::AppError::R2Error(format!("failed to serialize metadata.json: {}", e))
})?;
self.r2
.put_object(crate::r2::R2Client::METADATA_KEY, bytes)
.await
.map_err(|e| crate::error::AppError::R2Error(e.to_string()))?;
Ok(())
}
pub async fn record_push(
&self,
project: &str,
pushed_by: &str,
object_count: u32,
total_bytes: u64,
) -> Result<(), crate::error::AppError> {
let mut metadata = self.load().await?;
metadata.projects.insert(
project.to_string(),
ProjectEntry {
last_pushed_by: pushed_by.to_string(),
last_pushed_at: Utc::now(),
object_count,
total_bytes,
},
);
metadata.version = 1;
self.save(&metadata).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_default_has_empty_projects() {
let m = Metadata::default();
assert!(m.projects.is_empty());
}
#[test]
fn metadata_round_trips_json() {
let mut m = Metadata::default();
m.projects.insert(
"ep-1".to_string(),
ProjectEntry {
last_pushed_by: "alice".to_string(),
last_pushed_at: DateTime::from_timestamp(0, 0).unwrap(),
object_count: 3,
total_bytes: 1024,
},
);
let json = serde_json::to_string(&m).unwrap();
let m2: Metadata = serde_json::from_str(&json).unwrap();
assert_eq!(m2.projects["ep-1"].last_pushed_by, "alice");
assert_eq!(m2.projects["ep-1"].object_count, 3);
}
#[test]
fn metadata_deserializes_without_version_field() {
let json = r#"{"projects":{}}"#;
let m: Metadata = serde_json::from_str(json).unwrap();
assert_eq!(m.version, 0);
assert!(m.projects.is_empty());
}
#[test]
fn metadata_deserializes_full_tdd_example() {
let json = r#"{
"version": 1,
"projects": {
"episode-47": {
"last_pushed_by": "alice",
"last_pushed_at": "2026-03-28T10:00:00Z",
"object_count": 3,
"total_bytes": 847392810
}
}
}"#;
let m: Metadata = serde_json::from_str(json).unwrap();
assert_eq!(m.version, 1);
let ep = &m.projects["episode-47"];
assert_eq!(ep.last_pushed_by, "alice");
assert_eq!(ep.object_count, 3);
assert_eq!(ep.total_bytes, 847_392_810);
}
#[test]
fn project_entry_round_trips_json() {
let entry = ProjectEntry {
last_pushed_by: "bob".to_string(),
last_pushed_at: DateTime::from_timestamp(1_000_000, 0).unwrap(),
object_count: 5,
total_bytes: 99_999,
};
let json = serde_json::to_string(&entry).unwrap();
let entry2: ProjectEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry2.last_pushed_by, "bob");
assert_eq!(entry2.object_count, 5);
assert_eq!(entry2.total_bytes, 99_999);
assert_eq!(entry2.last_pushed_at, entry.last_pushed_at);
}
}