use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::personal_sync::PersonalGraphEvent;
pub fn inbox_dir(data_root: &Path) -> PathBuf {
data_root.join("receipts").join("inbox")
}
fn applied_dir(inbox: &Path) -> PathBuf {
inbox.join("applied")
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InboxEntry {
pub source: PathBuf,
pub events: Vec<PersonalGraphEvent>,
}
#[derive(Clone, Debug)]
pub struct PendingReceipt {
pub path: PathBuf,
pub source: PathBuf,
pub events: Vec<PersonalGraphEvent>,
}
pub fn captures_in(events: &[PersonalGraphEvent]) -> Vec<(String, [u8; 32])> {
let mut found = Vec::new();
for event in events {
let PersonalGraphEvent::SetFacet { facet, value, .. } = event else {
continue;
};
if facet != crate::receipts::FACET_ARTIFACTS {
continue;
}
let Some(items) = value.as_array() else {
continue;
};
for item in items {
let (Some(name), Some(hex)) = (
item.get("name").and_then(|v| v.as_str()),
item.get("blake3").and_then(|v| v.as_str()),
) else {
continue;
};
if let Some(hash) = parse_hex32(hex) {
found.push((name.to_string(), hash));
}
}
}
found
}
fn parse_hex32(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
out[index] = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
}
Some(out)
}
pub fn write_to_inbox(
inbox: &Path,
node: uuid::Uuid,
source: &Path,
events: &[PersonalGraphEvent],
) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(inbox)?;
let path = inbox.join(format!("{node}.json"));
let entry = InboxEntry {
source: source.to_path_buf(),
events: events.to_vec(),
};
let json = serde_json::to_string_pretty(&entry).map_err(std::io::Error::other)?;
std::fs::write(&path, json)?;
Ok(path)
}
pub fn pending(inbox: &Path) -> std::io::Result<Vec<PendingReceipt>> {
if !inbox.exists() {
return Ok(Vec::new());
}
let mut found = Vec::new();
for entry in std::fs::read_dir(inbox)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let text = std::fs::read_to_string(&path)?;
match serde_json::from_str::<InboxEntry>(&text) {
Ok(entry) => found.push(PendingReceipt {
path,
source: entry.source,
events: entry.events,
}),
Err(_) => continue,
}
}
found.sort_by(|a, b| a.path.cmp(&b.path));
Ok(found)
}
pub fn mark_applied(path: &Path) -> std::io::Result<()> {
let Some(inbox) = path.parent() else {
return Ok(());
};
let applied = applied_dir(inbox);
std::fs::create_dir_all(&applied)?;
let Some(name) = path.file_name() else {
return Ok(());
};
let target = applied.join(name);
std::fs::copy(path, &target)?;
std::fs::remove_file(path)
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn events() -> Vec<PersonalGraphEvent> {
vec![PersonalGraphEvent::AddNode {
id: Uuid::from_u128(1),
address: "receipt:woodshed/thinkpad/x/2026".into(),
title: "woodshed · frame on thinkpad · ok".into(),
}]
}
#[test]
fn an_absent_inbox_is_empty_rather_than_an_error() {
let dir = tempfile::tempdir().unwrap();
let inbox = inbox_dir(dir.path());
assert!(pending(&inbox).unwrap().is_empty());
}
#[test]
fn a_deposited_receipt_round_trips() {
let dir = tempfile::tempdir().unwrap();
let inbox = inbox_dir(dir.path());
let node = Uuid::from_u128(1);
write_to_inbox(&inbox, node, dir.path(), &events()).unwrap();
let found = pending(&inbox).unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].events.len(), 1);
assert!(found[0].path.ends_with(format!("{node}.json")));
}
#[test]
fn re_depositing_the_same_receipt_does_not_queue_it_twice() {
let dir = tempfile::tempdir().unwrap();
let inbox = inbox_dir(dir.path());
let node = Uuid::from_u128(1);
write_to_inbox(&inbox, node, dir.path(), &events()).unwrap();
write_to_inbox(&inbox, node, dir.path(), &events()).unwrap();
assert_eq!(pending(&inbox).unwrap().len(), 1);
}
#[test]
fn applying_clears_the_pending_file_and_keeps_the_record() {
let dir = tempfile::tempdir().unwrap();
let inbox = inbox_dir(dir.path());
let path = write_to_inbox(&inbox, Uuid::from_u128(1), dir.path(), &events()).unwrap();
mark_applied(&path).unwrap();
assert!(pending(&inbox).unwrap().is_empty(), "no longer pending");
assert!(!path.exists());
assert!(
applied_dir(&inbox).join(path.file_name().unwrap()).exists(),
"the local record of what this device authored survives",
);
}
#[test]
fn a_malformed_file_is_skipped_not_fatal() {
let dir = tempfile::tempdir().unwrap();
let inbox = inbox_dir(dir.path());
write_to_inbox(&inbox, Uuid::from_u128(1), dir.path(), &events()).unwrap();
std::fs::write(inbox.join("garbage.json"), "{not json").unwrap();
let found = pending(&inbox).unwrap();
assert_eq!(found.len(), 1, "the good one still comes through");
assert!(
inbox.join("garbage.json").exists(),
"and the bad one stays put to be looked at",
);
}
#[test]
fn the_applied_directory_is_not_scanned_as_a_receipt() {
let dir = tempfile::tempdir().unwrap();
let inbox = inbox_dir(dir.path());
let path = write_to_inbox(&inbox, Uuid::from_u128(1), dir.path(), &events()).unwrap();
mark_applied(&path).unwrap();
write_to_inbox(&inbox, Uuid::from_u128(2), dir.path(), &events()).unwrap();
assert_eq!(
pending(&inbox).unwrap().len(),
1,
"only the new one is pending",
);
}
}