use chirograph::{ContentHash, PortableCardV1};
use serde_json::Value;
use super::manifest::{FACET_ARTIFACTS, FACET_RUN};
struct Capture {
name: String,
blob: Option<ContentHash>,
bytes: u64,
}
pub fn receipt_card(
title: &str,
address: &str,
run: Option<&Value>,
artifacts: Option<&Value>,
) -> Option<PortableCardV1> {
let run = run?;
let captures = captures_from(artifacts);
let exit = run.get("exit_code").and_then(Value::as_i64);
let passed = exit == Some(0);
let dirty = run.get("dirty").and_then(Value::as_u64).unwrap_or(0);
let mut values = vec![
field("Repo", run.get("repo")),
field("Scenario", run.get("scenario")),
field("Machine", run.get("target")),
field("System", run.get("os")),
field("Session", run.get("session")),
pair(
"Commit",
match dirty {
0 => text(run.get("commit")),
n => format!("{} (+{n} uncommitted)", text(run.get("commit"))),
},
),
field("Ran", run.get("ran_at")),
pair(
"Result",
match exit {
Some(0) => "passed".to_string(),
Some(code) => format!("failed (exit {code})"),
None => "unknown".to_string(),
},
),
];
values.push(pair(
"Captures",
if captures.is_empty() {
"none".to_string()
} else {
captures
.iter()
.map(|c| format!("{} ({})", c.name, human_bytes(c.bytes)))
.collect::<Vec<_>>()
.join(", ")
},
));
values.push(pair("Address", address.to_string()));
let mut badges = vec!["Receipt".to_string()];
badges.push(if passed {
"Passed".into()
} else {
"Failed".into()
});
if dirty > 0 {
badges.push("Dirty checkout".into());
}
Some(PortableCardV1 {
title: title.to_string(),
values,
badges,
media: captures.into_iter().filter_map(|c| c.blob).collect(),
})
}
pub fn is_receipt(run: Option<&Value>) -> bool {
run.is_some()
}
pub const CARD_FACETS: [&str; 2] = [FACET_RUN, FACET_ARTIFACTS];
fn captures_from(artifacts: Option<&Value>) -> Vec<Capture> {
let Some(Value::Array(items)) = artifacts else {
return Vec::new();
};
items
.iter()
.map(|item| Capture {
name: item
.get("name")
.and_then(Value::as_str)
.unwrap_or("unnamed")
.to_string(),
blob: item
.get("blake3")
.and_then(Value::as_str)
.and_then(parse_hash),
bytes: item.get("bytes").and_then(Value::as_u64).unwrap_or(0),
})
.collect()
}
fn parse_hash(hex: &str) -> Option<ContentHash> {
if hex.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
let text = std::str::from_utf8(pair).ok()?;
out[index] = u8::from_str_radix(text, 16).ok()?;
}
Some(ContentHash(out))
}
fn text(value: Option<&Value>) -> String {
value
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string()
}
fn field(label: &str, value: Option<&Value>) -> chirograph::CardValueV1 {
pair(label, text(value))
}
fn pair(label: &str, value: String) -> chirograph::CardValueV1 {
chirograph::CardValueV1 {
label: label.to_string(),
value,
}
}
fn human_bytes(bytes: u64) -> String {
match bytes {
0 => "empty".to_string(),
n if n < 1024 => format!("{n} B"),
n if n < 1024 * 1024 => format!("{:.0} KiB", n as f64 / 1024.0),
n => format!("{:.1} MiB", n as f64 / (1024.0 * 1024.0)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn run(exit: i64, dirty: u64) -> Value {
json!({
"repo": "woodshed",
"package": "woodshed-genet",
"scenario": "design_docs/scenarios/frame.scn",
"target": "mark@thinkpad",
"platform": "linux",
"os": "Linux 6.9.4",
"commit": "abc123def456",
"dirty": dirty,
"session": "wayland (wayland-0)",
"ran_at": "2026-08-10T14:31:05Z",
"exit_code": exit,
})
}
fn artifacts() -> Value {
json!([{
"name": "frame.png",
"bytes": 4096,
"blake3": "94061c9510edfeb114dc8a2102339f42c7e6a2aff2a7fdc922bef9903071860e",
"sha256": "00",
}])
}
fn labelled<'a>(card: &'a PortableCardV1, label: &str) -> &'a str {
card.values
.iter()
.find(|v| v.label == label)
.map(|v| v.value.as_str())
.unwrap_or("<missing>")
}
#[test]
fn a_plain_node_is_not_a_receipt() {
assert!(receipt_card("Some page", "https://x", None, None).is_none());
assert!(!is_receipt(None));
}
#[test]
fn the_card_carries_the_provenance_a_reader_needs() {
let card = receipt_card(
"woodshed · frame on thinkpad · ok",
"receipt:woodshed/thinkpad/x/2026",
Some(&run(0, 0)),
Some(&artifacts()),
)
.unwrap();
assert_eq!(labelled(&card, "Repo"), "woodshed");
assert_eq!(labelled(&card, "Machine"), "mark@thinkpad");
assert_eq!(labelled(&card, "System"), "Linux 6.9.4");
assert_eq!(labelled(&card, "Session"), "wayland (wayland-0)");
assert_eq!(labelled(&card, "Commit"), "abc123def456");
assert_eq!(labelled(&card, "Result"), "passed");
assert!(card.badges.contains(&"Passed".to_string()));
}
#[test]
fn captures_become_media_hashes() {
let card = receipt_card("t", "receipt:x", Some(&run(0, 0)), Some(&artifacts())).unwrap();
assert_eq!(card.media.len(), 1);
assert_eq!(
card.media[0].to_string(),
"94061c9510edfeb114dc8a2102339f42c7e6a2aff2a7fdc922bef9903071860e",
);
assert_eq!(labelled(&card, "Captures"), "frame.png (4 KiB)");
}
#[test]
fn a_dirty_checkout_is_said_twice() {
let card = receipt_card("t", "receipt:x", Some(&run(0, 3)), None).unwrap();
assert_eq!(labelled(&card, "Commit"), "abc123def456 (+3 uncommitted)");
assert!(
card.badges.contains(&"Dirty checkout".to_string()),
"a reader skimming badges must see it too, got {:?}",
card.badges,
);
}
#[test]
fn a_failed_run_says_so_and_names_the_code() {
let card = receipt_card("t", "receipt:x", Some(&run(101, 0)), None).unwrap();
assert_eq!(labelled(&card, "Result"), "failed (exit 101)");
assert!(card.badges.contains(&"Failed".to_string()));
assert!(!card.badges.contains(&"Passed".to_string()));
}
#[test]
fn missing_or_malformed_artifacts_degrade_rather_than_vanish() {
let card = receipt_card("t", "receipt:x", Some(&run(0, 0)), None).unwrap();
assert_eq!(labelled(&card, "Captures"), "none");
assert!(card.media.is_empty());
let bad = json!([{ "name": "x.png", "blake3": "not-a-hash" }]);
let card = receipt_card("t", "receipt:x", Some(&run(0, 0)), Some(&bad)).unwrap();
assert!(card.media.is_empty(), "a bad hash is dropped, not guessed");
assert_eq!(labelled(&card, "Captures"), "x.png (empty)");
}
#[test]
fn a_sparse_run_facet_reads_as_unknown_rather_than_panicking() {
let card = receipt_card("t", "receipt:x", Some(&json!({})), None).unwrap();
assert_eq!(labelled(&card, "Repo"), "unknown");
assert_eq!(labelled(&card, "Result"), "unknown");
}
}