use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AssetState {
Published,
PlaceholderOutput,
PlaceholderFetch,
PinnedNotPublished,
DriftBucket,
DriftUpstream,
TransformBroken,
NotRequired,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetStatusEvent {
pub at: DateTime<Utc>,
pub asset: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<AssetState>,
pub to: AssetState,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blake3: Option<String>,
}
pub struct AssetStatusJournal {
path: PathBuf,
tx: Arc<broadcast::Sender<AssetStatusEvent>>,
}
impl AssetStatusJournal {
pub fn new(path: PathBuf) -> Self {
let (tx, _) = broadcast::channel(256);
Self {
path,
tx: Arc::new(tx),
}
}
pub fn at_workspace(workspace_root: &Path) -> Self {
Self::new(crate::paths::asset_status_journal(workspace_root))
}
pub async fn append(&self, event: &AssetStatusEvent) {
if let Err(e) = self.try_append(event).await {
tracing::warn!(
asset = %event.asset,
error = %e,
"asset status journal write failed (non-fatal)"
);
} else {
let _ = self.tx.send(event.clone());
}
}
async fn try_append(&self, event: &AssetStatusEvent) -> Result<()> {
use tokio::io::AsyncWriteExt;
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("creating {}", parent.display()))?;
}
let mut line = serde_json::to_string(event).context("serializing AssetStatusEvent")?;
line.push('\n');
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.await
.with_context(|| format!("opening {}", self.path.display()))?;
file.write_all(line.as_bytes())
.await
.with_context(|| format!("writing to {}", self.path.display()))?;
Ok(())
}
pub async fn last_state_per_asset(&self) -> HashMap<String, AssetState> {
match self.try_replay().await {
Ok(map) => map,
Err(e) => {
tracing::debug!(
error = %e,
journal = %self.path.display(),
"journal replay failed — returning empty map",
);
HashMap::new()
}
}
}
async fn try_replay(&self) -> Result<HashMap<String, AssetState>> {
let content = match tokio::fs::read_to_string(&self.path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
Err(e) => return Err(e).with_context(|| format!("reading {}", self.path.display())),
};
let mut map = HashMap::new();
for (lineno, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<AssetStatusEvent>(line) {
Ok(event) => {
map.insert(event.asset.clone(), event.to);
}
Err(e) => {
tracing::warn!(
line = lineno + 1,
journal = %self.path.display(),
error = %e,
"skipping unparseable journal line",
);
}
}
}
Ok(map)
}
pub fn subscribe(&self) -> broadcast::Receiver<AssetStatusEvent> {
self.tx.subscribe()
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn sample_event(asset: &str, to: AssetState) -> AssetStatusEvent {
AssetStatusEvent {
at: Utc::now(),
asset: asset.to_string(),
from: None,
to,
bytes: None,
blake3: None,
}
}
#[test]
fn asset_state_serde_round_trip() {
let cases = [
(AssetState::Published, "\"published\""),
(AssetState::PlaceholderOutput, "\"placeholder-output\""),
(AssetState::PlaceholderFetch, "\"placeholder-fetch\""),
(AssetState::PinnedNotPublished, "\"pinned-not-published\""),
(AssetState::DriftBucket, "\"drift-bucket\""),
(AssetState::DriftUpstream, "\"drift-upstream\""),
(AssetState::TransformBroken, "\"transform-broken\""),
(AssetState::NotRequired, "\"not-required\""),
];
for (state, expected_json) in cases {
let json = serde_json::to_string(&state).unwrap();
assert_eq!(json, expected_json, "serialize {state:?}");
let rt: AssetState = serde_json::from_str(&json).unwrap();
assert_eq!(rt, state, "round-trip {state:?}");
}
}
#[test]
fn event_serde_round_trip() {
let event = AssetStatusEvent {
at: DateTime::parse_from_rfc3339("2026-06-06T21:00:00Z")
.unwrap()
.with_timezone(&Utc),
asset: "yah-desktop:whisper/model.bin".to_string(),
from: Some(AssetState::PinnedNotPublished),
to: AssetState::Published,
bytes: Some(1024),
blake3: Some("abc123".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
let rt: AssetStatusEvent = serde_json::from_str(&json).unwrap();
assert_eq!(rt.asset, event.asset);
assert_eq!(rt.from, event.from);
assert_eq!(rt.to, event.to);
assert_eq!(rt.bytes, event.bytes);
assert_eq!(rt.blake3, event.blake3);
}
#[test]
fn event_skips_none_fields_in_json() {
let event = sample_event("svc:file.bin", AssetState::PlaceholderOutput);
let json = serde_json::to_string(&event).unwrap();
assert!(
!json.contains("\"from\""),
"None from should be omitted: {json}"
);
assert!(
!json.contains("\"bytes\""),
"None bytes should be omitted: {json}"
);
assert!(
!json.contains("\"blake3\""),
"None blake3 should be omitted: {json}"
);
}
#[tokio::test]
async fn append_creates_file_and_writes_jsonl() {
let dir = tempdir().unwrap();
let journal = AssetStatusJournal::new(dir.path().join("cloud/status.jsonl"));
let event = AssetStatusEvent {
at: Utc::now(),
asset: "svc:model.bin".to_string(),
from: Some(AssetState::PinnedNotPublished),
to: AssetState::Published,
bytes: Some(512),
blake3: Some("deafbeef".to_string()),
};
journal.append(&event).await;
let content = tokio::fs::read_to_string(journal.path()).await.unwrap();
assert!(
!content.is_empty(),
"journal file must be non-empty after append"
);
let parsed: AssetStatusEvent = serde_json::from_str(content.trim()).unwrap();
assert_eq!(parsed.asset, event.asset);
assert_eq!(parsed.to, AssetState::Published);
}
#[tokio::test]
async fn replay_empty_when_no_file() {
let dir = tempdir().unwrap();
let journal = AssetStatusJournal::new(dir.path().join("cloud/status.jsonl"));
let map = journal.last_state_per_asset().await;
assert!(map.is_empty(), "missing journal → empty map");
}
#[tokio::test]
async fn replay_yields_last_state_per_asset() {
let dir = tempdir().unwrap();
let journal = AssetStatusJournal::new(dir.path().join("cloud/status.jsonl"));
let asset = "svc:model.bin";
journal
.append(&AssetStatusEvent {
at: Utc::now(),
asset: asset.to_string(),
from: Some(AssetState::PinnedNotPublished),
to: AssetState::Published,
bytes: None,
blake3: None,
})
.await;
journal
.append(&AssetStatusEvent {
at: Utc::now(),
asset: asset.to_string(),
from: Some(AssetState::Published),
to: AssetState::DriftBucket,
bytes: None,
blake3: None,
})
.await;
let map = journal.last_state_per_asset().await;
assert_eq!(map.get(asset), Some(&AssetState::DriftBucket));
}
#[tokio::test]
async fn replay_tracks_multiple_assets_independently() {
let dir = tempdir().unwrap();
let journal = AssetStatusJournal::new(dir.path().join("cloud/status.jsonl"));
journal
.append(&sample_event("svc:a.bin", AssetState::Published))
.await;
journal
.append(&sample_event("svc:b.bin", AssetState::PinnedNotPublished))
.await;
journal
.append(&sample_event("svc:a.bin", AssetState::DriftBucket))
.await;
let map = journal.last_state_per_asset().await;
assert_eq!(map.get("svc:a.bin"), Some(&AssetState::DriftBucket));
assert_eq!(map.get("svc:b.bin"), Some(&AssetState::PinnedNotPublished));
}
#[tokio::test]
async fn replay_skips_blank_lines_without_panic() {
let dir = tempdir().unwrap();
let path = dir.path().join("status.jsonl");
let valid = serde_json::to_string(&sample_event("svc:x.bin", AssetState::Published))
.unwrap()
+ "\n";
tokio::fs::write(&path, format!("{valid}\n{valid}"))
.await
.unwrap();
let journal = AssetStatusJournal::new(path);
let map = journal.last_state_per_asset().await;
assert_eq!(map.get("svc:x.bin"), Some(&AssetState::Published));
}
#[tokio::test]
async fn subscribe_receives_appended_events() {
let dir = tempdir().unwrap();
let journal = AssetStatusJournal::new(dir.path().join("cloud/status.jsonl"));
let mut rx = journal.subscribe();
let event = sample_event("svc:w.bin", AssetState::Published);
journal.append(&event).await;
let received = rx.try_recv().expect("event must be received");
assert_eq!(received.asset, event.asset);
assert_eq!(received.to, AssetState::Published);
}
}