use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const SCHEMA: &str = "phoxal.catalog/v0";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Catalog {
pub schema: String,
pub build: BuildProvenance,
pub artifacts: Vec<Artifact>,
pub heads: Heads,
}
impl Catalog {
pub fn new(build: BuildProvenance, artifacts: Vec<Artifact>, heads: Heads) -> Self {
Self {
schema: SCHEMA.to_string(),
build,
artifacts,
heads,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Heads {
pub stable: String,
pub nightly: String,
}
impl Heads {
#[must_use]
pub fn empty() -> Self {
Self {
stable: String::new(),
nightly: String::new(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BuildProvenance {
pub tag: String,
pub run_number: u64,
pub run_id: u64,
pub commit: String,
pub created_at: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Artifact {
pub package: String,
pub version: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub targets: BTreeMap<String, Blob>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<Blob>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Blob {
pub url: String,
pub sha256: String,
pub size: u64,
}
pub fn is_sha256(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_json() {
let mut targets = BTreeMap::new();
targets.insert(
"aarch64-unknown-linux-musl".to_string(),
Blob {
url: "https://example.invalid/a.tar.zst".to_string(),
sha256: "a".repeat(64),
size: 42,
},
);
let catalog = Catalog::new(
BuildProvenance {
tag: "build-20260708-0001234".to_string(),
run_number: 1234,
run_id: 987_654_321,
commit: "deadbeef".to_string(),
created_at: "2026-07-08T12:00:00Z".to_string(),
},
vec![Artifact {
package: "phoxal/service-drive".to_string(),
version: "0.19.8".to_string(),
targets,
assets: None,
}],
Heads {
stable: "build-20260708-0001234".to_string(),
nightly: "build-20260708-0001234".to_string(),
},
);
let json = serde_json::to_string_pretty(&catalog).expect("serialize");
assert!(!json.contains("\"assets\""));
let reparsed: Catalog = serde_json::from_str(&json).expect("deserialize");
assert_eq!(catalog, reparsed);
}
#[test]
fn sha256_format_check() {
assert!(is_sha256(&"a".repeat(64)));
assert!(!is_sha256(&"A".repeat(64)));
assert!(!is_sha256("short"));
}
#[test]
fn deny_unknown_fields_rejects_stray_keys() {
let json = r#"{"schema":"phoxal.catalog/v0","build":{"tag":"t","run_number":1,"run_id":1,"commit":"c","created_at":"c"},"artifacts":[],"heads":{"stable":"","nightly":""},"bogus":true}"#;
let err = serde_json::from_str::<Catalog>(json).unwrap_err();
assert!(err.to_string().contains("unknown field"));
}
#[test]
fn removed_artifact_metadata_fields_are_rejected() {
let with_contracts =
r#"{"package":"phoxal/service-drive","version":"0.1.0","contracts":[],"targets":{}}"#;
let err = serde_json::from_str::<Artifact>(with_contracts).unwrap_err();
assert!(err.to_string().contains("unknown field `contracts`"));
let with_config_schema = r#"{"package":"phoxal/service-drive","version":"0.1.0","config_schema":null,"targets":{}}"#;
let err = serde_json::from_str::<Artifact>(with_config_schema).unwrap_err();
assert!(err.to_string().contains("unknown field `config_schema`"));
}
#[test]
fn heads_round_trips_and_rejects_unknown_fields() {
let heads = Heads {
stable: "build-1".to_string(),
nightly: "build-2".to_string(),
};
let json = serde_json::to_string(&heads).expect("serialize");
let reparsed: Heads = serde_json::from_str(&json).expect("deserialize");
assert_eq!(heads, reparsed);
let err =
serde_json::from_str::<Heads>(r#"{"stable":"","nightly":"","extra":1}"#).unwrap_err();
assert!(err.to_string().contains("unknown field"));
}
}