use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub type PackId = String;
pub const SCHEMA_VERSION: &str = "2";
#[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>,
id: PackId,
action_idx: usize,
action_name: String,
schema_version: String,
},
ActionCompleted {
ts: DateTime<Utc>,
id: PackId,
action_idx: usize,
result_summary: String,
schema_version: String,
},
ActionHalted {
ts: DateTime<Utc>,
id: PackId,
action_idx: usize,
action_name: String,
error_summary: String,
schema_version: String,
},
DryRunWouldClone {
ts: DateTime<Utc>,
id: PackId,
url: String,
#[serde(rename = "ref")]
ref_: Option<String>,
schema_version: String,
},
ForcePruneExecuted {
ts: DateTime<Utc>,
path: String,
kind: String,
force_prune_with_ignored: bool,
},
QuarantineStart {
ts: DateTime<Utc>,
src: String,
trash: String,
},
QuarantineComplete {
ts: DateTime<Utc>,
src: String,
trash: String,
},
QuarantineFailed {
ts: DateTime<Utc>,
src: String,
trash: String,
error: String,
},
QuarantineRestored {
ts: DateTime<Utc>,
src: String,
dest: String,
},
QuarantineGcSwept {
ts: DateTime<Utc>,
entry: String,
age_days: u64,
},
#[serde(other)]
Unknown,
}
fn empty_pack_id() -> &'static PackId {
static EMPTY: std::sync::OnceLock<PackId> = std::sync::OnceLock::new();
EMPTY.get_or_init(String::new)
}
pub const ACTION_ERROR_SUMMARY_MAX: usize = 2048;
impl Event {
pub fn op_name(&self) -> &'static str {
match self {
Event::Add { .. } => "add",
Event::Update { .. } => "update",
Event::Rm { .. } => "rm",
Event::Sync { .. } => "sync",
Event::ActionStarted { .. } => "action_started",
Event::ActionCompleted { .. } => "action_completed",
Event::ActionHalted { .. } => "action_halted",
Event::DryRunWouldClone { .. } => "dry_run_would_clone",
Event::ForcePruneExecuted { .. } => "force_prune_executed",
Event::QuarantineStart { .. } => "quarantine_start",
Event::QuarantineComplete { .. } => "quarantine_complete",
Event::QuarantineFailed { .. } => "quarantine_failed",
Event::QuarantineRestored { .. } => "quarantine_restored",
Event::QuarantineGcSwept { .. } => "quarantine_gc_swept",
Event::Unknown => "unknown",
}
}
pub fn id(&self) -> &PackId {
match self {
Event::Add { id, .. }
| Event::Update { id, .. }
| Event::Rm { id, .. }
| Event::Sync { id, .. }
| Event::ActionStarted { id, .. }
| Event::ActionCompleted { id, .. }
| Event::ActionHalted { id, .. }
| Event::DryRunWouldClone { id, .. } => id,
Event::ForcePruneExecuted { path, .. } => path,
Event::QuarantineStart { src, .. }
| Event::QuarantineComplete { src, .. }
| Event::QuarantineFailed { src, .. }
| Event::QuarantineRestored { src, .. } => src,
Event::QuarantineGcSwept { entry, .. } => entry,
Event::Unknown => empty_pack_id(),
}
}
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::DryRunWouldClone { ts, .. }
| Event::ForcePruneExecuted { ts, .. }
| Event::QuarantineStart { ts, .. }
| Event::QuarantineComplete { ts, .. }
| Event::QuarantineFailed { ts, .. }
| Event::QuarantineRestored { ts, .. }
| Event::QuarantineGcSwept { ts, .. } => *ts,
Event::Unknown => DateTime::<Utc>::from_timestamp(0, 0).unwrap_or_default(),
}
}
}
#[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(),
id: "warp".into(),
action_idx: 3,
action_name: "symlink".into(),
schema_version: SCHEMA_VERSION.into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""op":"action_started""#));
assert!(s.contains(r#""id":"warp""#));
assert!(!s.contains(r#""pack":"warp""#));
assert!(s.contains(r#""schema_version":"2""#));
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn action_completed_roundtrip() {
let e = Event::ActionCompleted {
ts: ts(),
id: "warp".into(),
action_idx: 1,
result_summary: "performed_change".into(),
schema_version: SCHEMA_VERSION.into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""op":"action_completed""#));
assert!(s.contains(r#""id":"warp""#));
assert!(!s.contains(r#""pack":"warp""#));
assert!(s.contains(r#""schema_version":"2""#));
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn action_halted_roundtrip() {
let e = Event::ActionHalted {
ts: ts(),
id: "warp".into(),
action_idx: 2,
action_name: "exec".into(),
error_summary: "non-zero exit 3".into(),
schema_version: SCHEMA_VERSION.into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""op":"action_halted""#));
assert!(s.contains(r#""id":"warp""#));
assert!(!s.contains(r#""pack":"warp""#));
assert!(s.contains(r#""schema_version":"2""#));
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn dry_run_would_clone_roundtrip() {
let e = Event::DryRunWouldClone {
ts: ts(),
id: "warp-cfgs".into(),
url: "https://example.com/warp-cfgs.git".into(),
ref_: Some("main".into()),
schema_version: SCHEMA_VERSION.into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""op":"dry_run_would_clone""#));
assert!(s.contains(r#""id":"warp-cfgs""#));
assert!(s.contains(r#""ref":"main""#));
assert!(s.contains(r#""schema_version":"2""#));
assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
}
#[test]
fn dry_run_would_clone_no_ref_serializes_null() {
let e = Event::DryRunWouldClone {
ts: ts(),
id: "warp-cfgs".into(),
url: "https://example.com/warp-cfgs.git".into(),
ref_: None,
schema_version: SCHEMA_VERSION.into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""ref":null"#));
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();
}
}