use std::fmt;
use std::str::FromStr;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PhaseId {
major: u32,
minor: Option<u32>,
}
impl PhaseId {
#[must_use]
pub const fn new(major: u32) -> Self {
Self { major, minor: None }
}
#[must_use]
pub const fn with_minor(major: u32, minor: u32) -> Self {
Self {
major,
minor: Some(minor),
}
}
#[must_use]
pub const fn major(self) -> u32 {
self.major
}
#[must_use]
pub const fn minor(self) -> Option<u32> {
self.minor
}
#[must_use]
pub fn from_json(value: Option<&serde_json::Value>) -> Option<Self> {
match value? {
serde_json::Value::Number(number) => {
u32::try_from(number.as_u64()?).ok().map(Self::new)
}
serde_json::Value::String(text) => text.parse().ok(),
_ => None,
}
}
#[must_use]
pub fn matches_json(self, value: Option<&serde_json::Value>) -> bool {
Self::from_json(value) == Some(self)
}
#[must_use]
pub fn padded(self) -> String {
match self.minor {
Some(minor) => format!("{:02}.{minor}", self.major),
None => format!("{:02}", self.major),
}
}
}
impl fmt::Display for PhaseId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.minor {
Some(minor) => write!(f, "{}.{minor}", self.major),
None => write!(f, "{}", self.major),
}
}
}
impl From<u32> for PhaseId {
fn from(major: u32) -> Self {
Self::new(major)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsePhaseIdError {
input: String,
reason: &'static str,
}
impl fmt::Display for ParsePhaseIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"`{}` is not a phase number ({}) — expected `35` or `35.1`",
self.input, self.reason
)
}
}
impl std::error::Error for ParsePhaseIdError {}
fn component(part: &str) -> Option<u32> {
if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
part.parse::<u32>().ok()
}
impl FromStr for PhaseId {
type Err = ParsePhaseIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let fail = |reason: &'static str| ParsePhaseIdError {
input: s.to_string(),
reason,
};
let mut parts = s.split('.');
let major = component(parts.next().unwrap_or_default())
.ok_or_else(|| fail("the part before the dot is not a number"))?;
let minor = match parts.next() {
Some(part) => Some(
component(part).ok_or_else(|| fail("the part after the dot is not a number"))?,
),
None => None,
};
if parts.next().is_some() {
return Err(fail("more than one dot"));
}
Ok(Self { major, minor })
}
}
impl Serialize for PhaseId {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self.minor {
Some(_) => serializer.serialize_str(&self.to_string()),
None => serializer.serialize_u32(self.major),
}
}
}
impl<'de> Deserialize<'de> for PhaseId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct PhaseIdVisitor;
impl Visitor<'_> for PhaseIdVisitor {
type Value = PhaseId;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a phase number such as 35 or \"35.1\"")
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<PhaseId, E> {
u32::try_from(value)
.map(PhaseId::new)
.map_err(|_| E::custom(format!("phase number {value} is out of range")))
}
fn visit_i64<E: de::Error>(self, value: i64) -> Result<PhaseId, E> {
u32::try_from(value)
.map(PhaseId::new)
.map_err(|_| E::custom(format!("phase number {value} is out of range")))
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<PhaseId, E> {
value.parse().map_err(E::custom)
}
}
deserializer.deserialize_any(PhaseIdVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_an_integer_phase() {
assert_eq!("35".parse::<PhaseId>().unwrap(), PhaseId::new(35));
}
#[test]
fn parses_a_decimal_phase() {
assert_eq!(
"35.1".parse::<PhaseId>().unwrap(),
PhaseId::with_minor(35, 1)
);
}
#[test]
fn rejects_what_is_not_a_phase_number() {
for input in [
"",
".",
"35.",
".1",
"35.1.2",
"-1",
"+5",
"35a",
"thirty-five",
"35 1",
"../../etc/passwd",
"35/../36",
"1e3",
" 35",
"35 ",
] {
assert!(
input.parse::<PhaseId>().is_err(),
"`{input}` was accepted as a phase number"
);
}
}
#[test]
fn display_is_the_unpadded_label() {
assert_eq!(PhaseId::new(7).to_string(), "7");
assert_eq!(PhaseId::with_minor(35, 1).to_string(), "35.1");
}
#[test]
fn display_ignores_width_specifiers() {
assert_eq!(format!("{:02}", PhaseId::new(7)), "7");
}
#[test]
fn padded_is_the_path_form() {
assert_eq!(PhaseId::new(7).padded(), "07");
assert_eq!(PhaseId::new(35).padded(), "35");
assert_eq!(PhaseId::with_minor(35, 1).padded(), "35.1");
assert_eq!(PhaseId::with_minor(7, 2).padded(), "07.2");
}
#[test]
fn orders_a_decimal_phase_after_its_major() {
let mut phases = vec![
PhaseId::new(36),
PhaseId::with_minor(35, 2),
PhaseId::new(35),
PhaseId::with_minor(35, 1),
];
phases.sort();
assert_eq!(
phases,
vec![
PhaseId::new(35),
PhaseId::with_minor(35, 1),
PhaseId::with_minor(35, 2),
PhaseId::new(36),
]
);
}
#[test]
fn an_integer_phase_still_serializes_as_a_number() {
assert_eq!(serde_json::to_string(&PhaseId::new(35)).unwrap(), "35");
}
#[test]
fn a_decimal_phase_serializes_as_a_string() {
assert_eq!(
serde_json::to_string(&PhaseId::with_minor(35, 1)).unwrap(),
"\"35.1\""
);
}
#[test]
fn deserializes_both_persisted_shapes() {
assert_eq!(
serde_json::from_str::<PhaseId>("35").unwrap(),
PhaseId::new(35)
);
assert_eq!(
serde_json::from_str::<PhaseId>("\"35.1\"").unwrap(),
PhaseId::with_minor(35, 1)
);
}
#[test]
fn reads_a_phase_field_in_either_shape() {
assert_eq!(
PhaseId::from_json(Some(&serde_json::json!(35))),
Some(PhaseId::new(35))
);
assert_eq!(
PhaseId::from_json(Some(&serde_json::json!("35.1"))),
Some(PhaseId::with_minor(35, 1))
);
}
#[test]
fn an_absent_or_malformed_phase_field_reads_as_none() {
assert_eq!(PhaseId::from_json(None), None);
assert_eq!(PhaseId::from_json(Some(&serde_json::json!(null))), None);
assert_eq!(
PhaseId::from_json(Some(&serde_json::json!("nonsense"))),
None
);
assert_eq!(PhaseId::from_json(Some(&serde_json::json!(-1))), None);
}
#[test]
fn a_phase_does_not_match_its_decimal_sibling() {
let integer = serde_json::json!(35);
let decimal = serde_json::json!("35.1");
assert!(PhaseId::new(35).matches_json(Some(&integer)));
assert!(PhaseId::with_minor(35, 1).matches_json(Some(&decimal)));
assert!(!PhaseId::new(35).matches_json(Some(&decimal)));
assert!(!PhaseId::with_minor(35, 1).matches_json(Some(&integer)));
}
#[test]
fn round_trips_through_json() {
for phase in [
PhaseId::new(7),
PhaseId::new(35),
PhaseId::with_minor(35, 1),
] {
let json = serde_json::to_string(&phase).unwrap();
assert_eq!(serde_json::from_str::<PhaseId>(&json).unwrap(), phase);
}
}
#[test]
fn phase_branch_name_matches_the_convention_gsd_computes() {
let template = "feature/phase-{phase}";
let cases = [
(PhaseId::new(7), "feature/phase-07"),
(PhaseId::new(35), "feature/phase-35"),
(PhaseId::with_minor(35, 2), "feature/phase-35.2"),
];
for (phase, expected) in cases {
let branch = template.replace("{phase}", &phase.padded());
assert_eq!(
branch, expected,
"PhaseId {phase} produced branch '{branch}', expected '{expected}'"
);
}
}
#[test]
fn gsd_computes_the_same_phase_branch_name_when_available() {
let gsd_tools = which_gsd_tools();
let Some(gsd_tools) = gsd_tools else {
println!(
"NOTICE: gsd-tools absent — cross-repo branch-name parity NOT \
verified by this gate"
);
return;
};
let probe = std::process::Command::new(&gsd_tools)
.arg("query")
.arg("config-get")
.arg("git.branching_strategy")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
match probe {
Ok(out) if out.status.success() => {}
_ => {
println!(
"NOTICE: gsd-tools found at {gsd_tools} but did not respond — \
cross-repo branch-name parity NOT verified by this gate"
);
return;
}
}
let cases: &[(PhaseId, &str)] = &[
(PhaseId::new(7), "feature/phase-07"),
(PhaseId::new(35), "feature/phase-35"),
(PhaseId::with_minor(35, 2), "feature/phase-35.2"),
];
for (phase, expected) in cases {
let branch = format!("feature/phase-{phase}");
assert_eq!(
branch.as_str(),
*expected,
"DevFlow and GSD disagree on the branch name for {phase}: \
DevFlow uses '{branch}', GSD is expected to use '{expected}'"
);
}
}
fn which_gsd_tools() -> Option<String> {
let path = std::env::var("PATH").ok()?;
for dir in path.split(':') {
let candidate = std::path::Path::new(dir).join("gsd-tools");
if candidate.exists() {
return candidate.to_str().map(String::from);
}
}
None
}
}