1use std::fmt;
22use std::str::FromStr;
23
24use serde::de::{self, Visitor};
25use serde::{Deserialize, Deserializer, Serialize, Serializer};
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
32pub struct PhaseId {
33 major: u32,
34 minor: Option<u32>,
35}
36
37impl PhaseId {
38 #[must_use]
40 pub const fn new(major: u32) -> Self {
41 Self { major, minor: None }
42 }
43
44 #[must_use]
46 pub const fn with_minor(major: u32, minor: u32) -> Self {
47 Self {
48 major,
49 minor: Some(minor),
50 }
51 }
52
53 #[must_use]
55 pub const fn major(self) -> u32 {
56 self.major
57 }
58
59 #[must_use]
61 pub const fn minor(self) -> Option<u32> {
62 self.minor
63 }
64
65 #[must_use]
73 pub fn from_json(value: Option<&serde_json::Value>) -> Option<Self> {
74 match value? {
75 serde_json::Value::Number(number) => {
76 u32::try_from(number.as_u64()?).ok().map(Self::new)
77 }
78 serde_json::Value::String(text) => text.parse().ok(),
79 _ => None,
80 }
81 }
82
83 #[must_use]
89 pub fn matches_json(self, value: Option<&serde_json::Value>) -> bool {
90 Self::from_json(value) == Some(self)
91 }
92
93 #[must_use]
98 pub fn padded(self) -> String {
99 match self.minor {
100 Some(minor) => format!("{:02}.{minor}", self.major),
101 None => format!("{:02}", self.major),
102 }
103 }
104}
105
106impl fmt::Display for PhaseId {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self.minor {
114 Some(minor) => write!(f, "{}.{minor}", self.major),
115 None => write!(f, "{}", self.major),
116 }
117 }
118}
119
120impl From<u32> for PhaseId {
121 fn from(major: u32) -> Self {
122 Self::new(major)
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ParsePhaseIdError {
129 input: String,
130 reason: &'static str,
131}
132
133impl fmt::Display for ParsePhaseIdError {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 write!(
136 f,
137 "`{}` is not a phase number ({}) — expected `35` or `35.1`",
138 self.input, self.reason
139 )
140 }
141}
142
143impl std::error::Error for ParsePhaseIdError {}
144
145fn component(part: &str) -> Option<u32> {
150 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
151 return None;
152 }
153 part.parse::<u32>().ok()
154}
155
156impl FromStr for PhaseId {
157 type Err = ParsePhaseIdError;
158
159 fn from_str(s: &str) -> Result<Self, Self::Err> {
160 let fail = |reason: &'static str| ParsePhaseIdError {
161 input: s.to_string(),
162 reason,
163 };
164
165 let mut parts = s.split('.');
166 let major = component(parts.next().unwrap_or_default())
167 .ok_or_else(|| fail("the part before the dot is not a number"))?;
168 let minor = match parts.next() {
169 Some(part) => Some(
170 component(part).ok_or_else(|| fail("the part after the dot is not a number"))?,
171 ),
172 None => None,
173 };
174 if parts.next().is_some() {
175 return Err(fail("more than one dot"));
176 }
177
178 Ok(Self { major, minor })
179 }
180}
181
182impl Serialize for PhaseId {
183 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
190 match self.minor {
191 Some(_) => serializer.serialize_str(&self.to_string()),
192 None => serializer.serialize_u32(self.major),
193 }
194 }
195}
196
197impl<'de> Deserialize<'de> for PhaseId {
198 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
201 struct PhaseIdVisitor;
202
203 impl Visitor<'_> for PhaseIdVisitor {
204 type Value = PhaseId;
205
206 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 f.write_str("a phase number such as 35 or \"35.1\"")
208 }
209
210 fn visit_u64<E: de::Error>(self, value: u64) -> Result<PhaseId, E> {
211 u32::try_from(value)
212 .map(PhaseId::new)
213 .map_err(|_| E::custom(format!("phase number {value} is out of range")))
214 }
215
216 fn visit_i64<E: de::Error>(self, value: i64) -> Result<PhaseId, E> {
217 u32::try_from(value)
218 .map(PhaseId::new)
219 .map_err(|_| E::custom(format!("phase number {value} is out of range")))
220 }
221
222 fn visit_str<E: de::Error>(self, value: &str) -> Result<PhaseId, E> {
223 value.parse().map_err(E::custom)
224 }
225 }
226
227 deserializer.deserialize_any(PhaseIdVisitor)
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn parses_an_integer_phase() {
237 assert_eq!("35".parse::<PhaseId>().unwrap(), PhaseId::new(35));
238 }
239
240 #[test]
241 fn parses_a_decimal_phase() {
242 assert_eq!(
243 "35.1".parse::<PhaseId>().unwrap(),
244 PhaseId::with_minor(35, 1)
245 );
246 }
247
248 #[test]
252 fn rejects_what_is_not_a_phase_number() {
253 for input in [
254 "",
255 ".",
256 "35.",
257 ".1",
258 "35.1.2",
259 "-1",
260 "+5",
261 "35a",
262 "thirty-five",
263 "35 1",
264 "../../etc/passwd",
265 "35/../36",
266 "1e3",
267 " 35",
268 "35 ",
269 ] {
270 assert!(
271 input.parse::<PhaseId>().is_err(),
272 "`{input}` was accepted as a phase number"
273 );
274 }
275 }
276
277 #[test]
278 fn display_is_the_unpadded_label() {
279 assert_eq!(PhaseId::new(7).to_string(), "7");
280 assert_eq!(PhaseId::with_minor(35, 1).to_string(), "35.1");
281 }
282
283 #[test]
286 fn display_ignores_width_specifiers() {
287 assert_eq!(format!("{:02}", PhaseId::new(7)), "7");
288 }
289
290 #[test]
291 fn padded_is_the_path_form() {
292 assert_eq!(PhaseId::new(7).padded(), "07");
293 assert_eq!(PhaseId::new(35).padded(), "35");
294 assert_eq!(PhaseId::with_minor(35, 1).padded(), "35.1");
295 assert_eq!(PhaseId::with_minor(7, 2).padded(), "07.2");
296 }
297
298 #[test]
299 fn orders_a_decimal_phase_after_its_major() {
300 let mut phases = vec![
301 PhaseId::new(36),
302 PhaseId::with_minor(35, 2),
303 PhaseId::new(35),
304 PhaseId::with_minor(35, 1),
305 ];
306 phases.sort();
307 assert_eq!(
308 phases,
309 vec![
310 PhaseId::new(35),
311 PhaseId::with_minor(35, 1),
312 PhaseId::with_minor(35, 2),
313 PhaseId::new(36),
314 ]
315 );
316 }
317
318 #[test]
319 fn an_integer_phase_still_serializes_as_a_number() {
320 assert_eq!(serde_json::to_string(&PhaseId::new(35)).unwrap(), "35");
321 }
322
323 #[test]
324 fn a_decimal_phase_serializes_as_a_string() {
325 assert_eq!(
326 serde_json::to_string(&PhaseId::with_minor(35, 1)).unwrap(),
327 "\"35.1\""
328 );
329 }
330
331 #[test]
333 fn deserializes_both_persisted_shapes() {
334 assert_eq!(
335 serde_json::from_str::<PhaseId>("35").unwrap(),
336 PhaseId::new(35)
337 );
338 assert_eq!(
339 serde_json::from_str::<PhaseId>("\"35.1\"").unwrap(),
340 PhaseId::with_minor(35, 1)
341 );
342 }
343
344 #[test]
345 fn reads_a_phase_field_in_either_shape() {
346 assert_eq!(
347 PhaseId::from_json(Some(&serde_json::json!(35))),
348 Some(PhaseId::new(35))
349 );
350 assert_eq!(
351 PhaseId::from_json(Some(&serde_json::json!("35.1"))),
352 Some(PhaseId::with_minor(35, 1))
353 );
354 }
355
356 #[test]
359 fn an_absent_or_malformed_phase_field_reads_as_none() {
360 assert_eq!(PhaseId::from_json(None), None);
361 assert_eq!(PhaseId::from_json(Some(&serde_json::json!(null))), None);
362 assert_eq!(
363 PhaseId::from_json(Some(&serde_json::json!("nonsense"))),
364 None
365 );
366 assert_eq!(PhaseId::from_json(Some(&serde_json::json!(-1))), None);
367 }
368
369 #[test]
373 fn a_phase_does_not_match_its_decimal_sibling() {
374 let integer = serde_json::json!(35);
375 let decimal = serde_json::json!("35.1");
376
377 assert!(PhaseId::new(35).matches_json(Some(&integer)));
378 assert!(PhaseId::with_minor(35, 1).matches_json(Some(&decimal)));
379
380 assert!(!PhaseId::new(35).matches_json(Some(&decimal)));
381 assert!(!PhaseId::with_minor(35, 1).matches_json(Some(&integer)));
382 }
383
384 #[test]
385 fn round_trips_through_json() {
386 for phase in [
387 PhaseId::new(7),
388 PhaseId::new(35),
389 PhaseId::with_minor(35, 1),
390 ] {
391 let json = serde_json::to_string(&phase).unwrap();
392 assert_eq!(serde_json::from_str::<PhaseId>(&json).unwrap(), phase);
393 }
394 }
395
396 #[test]
403 fn phase_branch_name_matches_the_convention_gsd_computes() {
404 let template = "feature/phase-{phase}";
405 let cases = [
406 (PhaseId::new(7), "feature/phase-07"),
407 (PhaseId::new(35), "feature/phase-35"),
408 (PhaseId::with_minor(35, 2), "feature/phase-35.2"),
409 ];
410 for (phase, expected) in cases {
411 let branch = template.replace("{phase}", &phase.padded());
412 assert_eq!(
413 branch, expected,
414 "PhaseId {phase} produced branch '{branch}', expected '{expected}'"
415 );
416 }
417 }
418
419 #[test]
424 fn gsd_computes_the_same_phase_branch_name_when_available() {
425 let gsd_tools = which_gsd_tools();
426 let Some(gsd_tools) = gsd_tools else {
427 println!(
428 "NOTICE: gsd-tools absent — cross-repo branch-name parity NOT \
429 verified by this gate"
430 );
431 return;
432 };
433
434 let probe = std::process::Command::new(&gsd_tools)
436 .arg("query")
437 .arg("config-get")
438 .arg("git.branching_strategy")
439 .stdout(std::process::Stdio::piped())
440 .stderr(std::process::Stdio::null())
441 .output();
442 match probe {
443 Ok(out) if out.status.success() => {}
444 _ => {
445 println!(
446 "NOTICE: gsd-tools found at {gsd_tools} but did not respond — \
447 cross-repo branch-name parity NOT verified by this gate"
448 );
449 return;
450 }
451 }
452
453 let cases: &[(PhaseId, &str)] = &[
454 (PhaseId::new(7), "feature/phase-07"),
455 (PhaseId::new(35), "feature/phase-35"),
456 (PhaseId::with_minor(35, 2), "feature/phase-35.2"),
457 ];
458 for (phase, expected) in cases {
459 let branch = format!("feature/phase-{phase}");
460 assert_eq!(
461 branch.as_str(),
462 *expected,
463 "DevFlow and GSD disagree on the branch name for {phase}: \
464 DevFlow uses '{branch}', GSD is expected to use '{expected}'"
465 );
466 }
467 }
468
469 fn which_gsd_tools() -> Option<String> {
471 let path = std::env::var("PATH").ok()?;
472 for dir in path.split(':') {
473 let candidate = std::path::Path::new(dir).join("gsd-tools");
474 if candidate.exists() {
475 return candidate.to_str().map(String::from);
476 }
477 }
478 None
479 }
480}