use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PathSegment {
Scenario(String),
ScenarioInclude(String),
ForEach { var: String },
ForCombinations { vars: Vec<String> },
DoWhile { counter: Option<String> },
DoUntil { counter: Option<String> },
Phase(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PhaseIdentity {
pub yaml_path: Vec<PathSegment>,
pub coords: String,
#[serde(default, with = "hex_opt")]
pub phase_hash: Option<[u8; 32]>,
}
impl PhaseIdentity {
pub fn matches_structural(&self, other: &PhaseIdentity) -> bool {
self.yaml_path == other.yaml_path && self.coords == other.coords
}
pub fn matches_full(&self, other: &PhaseIdentity) -> bool {
if !self.matches_structural(other) {
return false;
}
match (&self.phase_hash, &other.phase_hash) {
(Some(a), Some(b)) => a == b,
(None, _) | (_, None) => true,
}
}
}
mod hex_opt {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(v: &Option<[u8; 32]>, s: S) -> Result<S::Ok, S::Error> {
match v {
None => s.serialize_none(),
Some(bytes) => {
let mut hex = String::with_capacity(64);
for b in bytes {
hex.push_str(&format!("{b:02x}"));
}
hex.serialize(s)
}
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<[u8; 32]>, D::Error> {
let s: Option<String> = Option::deserialize(d)?;
match s {
None => Ok(None),
Some(hex) => {
if hex.len() != 64 {
return Err(serde::de::Error::custom(format!(
"phase_hash: expected 64 hex chars, got {}",
hex.len()
)));
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let pair = &hex[i * 2..i * 2 + 2];
*byte = u8::from_str_radix(pair, 16).map_err(|e| {
serde::de::Error::custom(format!(
"phase_hash: invalid hex at byte {i}: {e}"
))
})?;
}
Ok(Some(out))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(path: Vec<PathSegment>, coords: &str, hash: Option<[u8; 32]>) -> PhaseIdentity {
PhaseIdentity {
yaml_path: path,
coords: coords.to_string(),
phase_hash: hash,
}
}
#[test]
fn structural_match_requires_path_and_coords() {
let a = id(vec![PathSegment::Phase("p".into())], "", None);
let b = id(vec![PathSegment::Phase("p".into())], "", None);
let c = id(vec![PathSegment::Phase("q".into())], "", None);
let d = id(vec![PathSegment::Phase("p".into())], "(k=1)", None);
assert!(a.matches_structural(&b));
assert!(!a.matches_structural(&c));
assert!(!a.matches_structural(&d));
}
#[test]
fn full_match_with_both_hashes_present() {
let h1 = [1u8; 32];
let h2 = [2u8; 32];
let a = id(vec![PathSegment::Phase("p".into())], "", Some(h1));
let b = id(vec![PathSegment::Phase("p".into())], "", Some(h1));
let c = id(vec![PathSegment::Phase("p".into())], "", Some(h2));
assert!(a.matches_full(&b));
assert!(!a.matches_full(&c));
}
#[test]
fn full_match_with_one_side_unhashed() {
let h = [1u8; 32];
let a = id(vec![PathSegment::Phase("p".into())], "", Some(h));
let b = id(vec![PathSegment::Phase("p".into())], "", None);
assert!(a.matches_full(&b));
assert!(b.matches_full(&a));
}
#[test]
fn json_round_trip_with_hash() {
let h = [0xab; 32];
let original = id(
vec![
PathSegment::Scenario("fulltest".into()),
PathSegment::ForEach {
var: "profile".into(),
},
PathSegment::Phase("rampup".into()),
],
"(profile=label_03)",
Some(h),
);
let json = serde_json::to_string(&original).expect("serialize");
assert!(
json.contains(&"ab".repeat(32)),
"expected hex hash in JSON: {json}"
);
let parsed: PhaseIdentity = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, original);
}
#[test]
fn json_round_trip_without_hash() {
let original = id(vec![PathSegment::Phase("p".into())], "", None);
let json = serde_json::to_string(&original).expect("serialize");
let parsed: PhaseIdentity = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, original);
}
#[test]
fn rejects_invalid_hex_length() {
let bad = r#"{"yaml_path":[],"coords":"","phase_hash":"ab12"}"#;
let err = serde_json::from_str::<PhaseIdentity>(bad).unwrap_err();
assert!(err.to_string().contains("64 hex"), "got: {err}");
}
}