use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub type PackId = String;
pub const SCHEMA_VERSION: &str = "1";
#[non_exhaustive]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Event {
Add {
ts: DateTime<Utc>,
id: PackId,
url: String,
path: String,
#[serde(rename = "type")]
pack_type: String,
schema_version: String,
},
Update {
ts: DateTime<Utc>,
id: PackId,
field: String,
value: serde_json::Value,
},
Rm {
ts: DateTime<Utc>,
id: PackId,
},
Sync {
ts: DateTime<Utc>,
id: PackId,
sha: String,
},
ActionStarted {
ts: DateTime<Utc>,
pack: PackId,
action_idx: usize,
action_name: String,
},
ActionCompleted {
ts: DateTime<Utc>,
pack: PackId,
action_idx: usize,
result_summary: String,
},
ActionHalted {
ts: DateTime<Utc>,
pack: PackId,
action_idx: usize,
action_name: String,
error_summary: String,
},
ForcePruneExecuted {
ts: DateTime<Utc>,
path: String,
kind: String,
force_prune_with_ignored: bool,
},
}
pub const ACTION_ERROR_SUMMARY_MAX: usize = 2048;
impl Event {
pub fn id(&self) -> &PackId {
match self {
Event::Add { id, .. }
| Event::Update { id, .. }
| Event::Rm { id, .. }
| Event::Sync { id, .. } => id,
Event::ActionStarted { pack, .. }
| Event::ActionCompleted { pack, .. }
| Event::ActionHalted { pack, .. } => pack,
Event::ForcePruneExecuted { path, .. } => path,
}
}
pub fn ts(&self) -> DateTime<Utc> {
match self {
Event::Add { ts, .. }
| Event::Update { ts, .. }
| Event::Rm { ts, .. }
| Event::Sync { ts, .. }
| Event::ActionStarted { ts, .. }
| Event::ActionCompleted { ts, .. }
| Event::ActionHalted { ts, .. }
| Event::ForcePruneExecuted { ts, .. } => *ts,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackState {
pub id: PackId,
pub url: String,
pub path: String,
pub pack_type: String,
pub ref_spec: Option<String>,
pub last_sync_sha: Option<String>,
pub added_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn ts() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 19, 10, 0, 0).unwrap()
}
#[test]
fn add_roundtrip() {
let e = Event::Add {
ts: ts(),
id: "warp-cfg".into(),
url: "git@example:warp".into(),
path: "warp-cfg".into(),
pack_type: "declarative".into(),
schema_version: SCHEMA_VERSION.into(),
};
let s = serde_json::to_string(&e).unwrap();
let back: Event = serde_json::from_str(&s).unwrap();
assert_eq!(e, back);
assert!(s.contains(r#""op":"add""#));
assert!(s.contains(r#""type":"declarative""#));
}
#[test]
fn update_roundtrip() {
let e = Event::Update {
ts: ts(),
id: "warp-cfg".into(),
field: "ref".into(),
value: serde_json::json!("v0.2.0"),
};
let s = serde_json::to_string(&e).unwrap();
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn rm_roundtrip() {
let e = Event::Rm { ts: ts(), id: "warp-cfg".into() };
let s = serde_json::to_string(&e).unwrap();
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn sync_roundtrip() {
let e = Event::Sync { ts: ts(), id: "warp-cfg".into(), sha: "abc123".into() };
let s = serde_json::to_string(&e).unwrap();
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn unknown_fields_are_accepted() {
let raw = r#"{"op":"rm","ts":"2026-04-19T10:00:00Z","id":"x","future_field":true}"#;
let e: Event = serde_json::from_str(raw).unwrap();
assert_eq!(e.id(), "x");
}
#[test]
fn id_and_ts_accessors() {
let e = Event::Sync { ts: ts(), id: "a".into(), sha: "s".into() };
assert_eq!(e.id(), "a");
assert_eq!(e.ts(), ts());
}
#[test]
fn action_started_roundtrip() {
let e = Event::ActionStarted {
ts: ts(),
pack: "warp".into(),
action_idx: 3,
action_name: "symlink".into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""op":"action_started""#));
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn action_completed_roundtrip() {
let e = Event::ActionCompleted {
ts: ts(),
pack: "warp".into(),
action_idx: 1,
result_summary: "performed_change".into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""op":"action_completed""#));
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn action_halted_roundtrip() {
let e = Event::ActionHalted {
ts: ts(),
pack: "warp".into(),
action_idx: 2,
action_name: "exec".into(),
error_summary: "non-zero exit 3".into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""op":"action_halted""#));
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn legacy_lowercase_tags_still_parse() {
let raw = r#"{"op":"add","ts":"2026-04-19T10:00:00Z","id":"a","url":"u","path":"a","type":"declarative","schema_version":"1"}"#;
let _: Event = serde_json::from_str(raw).unwrap();
let raw = r#"{"op":"sync","ts":"2026-04-19T10:00:00Z","id":"a","sha":"deadbeef"}"#;
let _: Event = serde_json::from_str(raw).unwrap();
}
}