Skip to main content

devflow_core/
phase_id.rs

1//! The identifier of a phase: `35`, or `35.1`.
2//!
3//! DevFlow originally carried this as a bare `u32`. GSD's `--insert` mode
4//! numbers an inserted phase with a decimal (`35.1`, `35.2`), so a `u32`
5//! identifier made every such phase unreachable by `devflow start` — the
6//! defect recorded as 999.97 and hotfixed on 2026-08-07.
7//!
8//! Two renderings exist, and the distinction matters:
9//!
10//! - [`Display`] is the canonical label — `7`, `35.1`. It is what goes into
11//!   prompts and messages a human or a GSD skill reads.
12//! - [`PhaseId::padded`] is the zero-padded *path* form — `07`, `35.1`. It is
13//!   what names `.devflow/state-07.json`, `feature/phase-07`, and the
14//!   `.planning/phases/07-*` glob.
15//!
16//! `Display` deliberately ignores width specifiers, so a stray `{phase:02}`
17//! left over from the `u32` era cannot silently produce a wrong path — it
18//! produces the unpadded label and any path built from it fails loudly. Call
19//! [`PhaseId::padded`] where a path is meant.
20
21use std::fmt;
22use std::str::FromStr;
23
24use serde::de::{self, Visitor};
25use serde::{Deserialize, Deserializer, Serialize, Serializer};
26
27/// A phase identifier — a major number, optionally with a minor number.
28///
29/// Ordering is `(major, minor)` with an absent minor sorting first, so
30/// `35 < 35.1 < 35.2 < 36`.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
32pub struct PhaseId {
33    major: u32,
34    minor: Option<u32>,
35}
36
37impl PhaseId {
38    /// An integer-numbered phase, e.g. `35`.
39    #[must_use]
40    pub const fn new(major: u32) -> Self {
41        Self { major, minor: None }
42    }
43
44    /// A decimal-numbered phase, e.g. `35.1`.
45    #[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    /// The major number — `35` for both `35` and `35.1`.
54    #[must_use]
55    pub const fn major(self) -> u32 {
56        self.major
57    }
58
59    /// The minor number, if this phase has one.
60    #[must_use]
61    pub const fn minor(self) -> Option<u32> {
62        self.minor
63    }
64
65    /// Reads a `phase` field out of a persisted JSON record, in either shape
66    /// — a bare number (written before the widening, and still what an
67    /// integer phase writes) or a string.
68    ///
69    /// Returns `None` when the field is absent or is neither shape. Callers
70    /// index rather than defaulting, so an absent field cannot read as a
71    /// phase that happens to match.
72    #[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    /// Whether a persisted JSON `phase` field denotes *this* phase.
84    ///
85    /// The minor number is part of the identity: phase `35`'s records must
86    /// not match phase `35.1`, which is the same cross-matching hazard the
87    /// artifact glob has.
88    #[must_use]
89    pub fn matches_json(self, value: Option<&serde_json::Value>) -> bool {
90        Self::from_json(value) == Some(self)
91    }
92
93    /// The zero-padded path form: `07`, `35.1`.
94    ///
95    /// Only the major number is padded. `35.1` is already unambiguous and is
96    /// exactly what GSD writes on disk as `.planning/phases/35.1-*`.
97    #[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    /// Writes the canonical label, ignoring any width or fill specifier.
108    ///
109    /// See the module docs: silently honouring `{:02}` here would let a
110    /// leftover padding specifier build a path that looks right for `35` and
111    /// is wrong for `35.1`.
112    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/// Why a string is not a usable phase identifier.
127#[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
145/// Parses one dot-separated component.
146///
147/// Rejects anything `u32::from_str` would accept but a path or branch name
148/// should not — notably a leading `+`, which `"+5".parse::<u32>()` accepts.
149fn 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    /// Serializes an integer phase as a JSON number and a decimal phase as a
184    /// string.
185    ///
186    /// The number arm preserves the on-disk shape of every `state-NN.json`
187    /// written before the widening, so an existing run is not disturbed by
188    /// this change.
189    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    /// Accepts either shape: a bare number (state files written before the
199    /// widening) or a string (`"35.1"`).
200    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    /// The negative control for the widening: relaxing a `u32` parse is the
249    /// kind of change that accepts everything if validation is forgotten, and
250    /// this identifier reaches a filesystem path and a git branch name.
251    #[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    /// A leftover `{phase:02}` from the `u32` era must not silently produce a
284    /// path-shaped string — see the module docs.
285    #[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    /// State files written before the widening hold a bare number.
332    #[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    /// An absent field must read as absent, never as a phase — the
357    /// distinction the whole matcher exists to preserve.
358    #[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    /// The cross-matching hazard: a record belonging to phase 35 must not be
370    /// read as belonging to phase 35.1, or either one's history is the
371    /// other's.
372    #[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    /// 35.2 criterion 3, D-03. 999.84: DevFlow and GSD independently compute
397    /// the phase branch name from the same phase number using the same
398    /// template. Nothing enforces agreement — two conventions in two
399    /// repositories. This test pins DevFlow's side. A failure means GSD would
400    /// compute a different branch name for the same phase number, and a
401    /// checkout would routinely replace the verification artifact.
402    #[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    /// 35.2 criterion 3, D-03 — defense-in-depth. Verifies GSD independently
420    /// computes the same branch name for the same phase numbers. When
421    /// `gsd-tools` is not on PATH this test prints a notice and passes
422    /// vacuously; the notice in output is what distinguishes the two outcomes.
423    #[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        // Confirm the tool is functional before trusting its output.
435        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    /// Resolves `gsd-tools` from PATH, returning the first match.
470    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}