Skip to main content

speccoder_core/
ladder.rs

1//! The ladder: where a unit of work came from. One provenance
2//! record per subject, written once. This build knows the variants
3//! a bug writes; later rungs add theirs as variants.
4
5use serde::{Deserialize, Serialize};
6
7use crate::actions::Via;
8
9/// Where a unit or a requirement came from.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
11#[serde(tag = "kind", rename_all = "snake_case")]
12pub enum Origin {
13    /// Recorded as a bug (`speccoder bug`).
14    Bug {
15        /// The unit.
16        unit: String,
17        /// The requirement.
18        requirement: String,
19        /// The task.
20        task: String,
21        /// The triad's digest.
22        triad_sha256: String,
23        /// Who.
24        by: String,
25        /// Through what.
26        via: Via,
27        /// Unix seconds.
28        at: u64,
29    },
30    /// From a GitHub issue.
31    Issue {
32        /// Its number.
33        number: u64,
34        /// `owner/repo`.
35        repo: Option<String>,
36        /// The digest of its context-manifest entry.
37        context_sha256: Option<String>,
38    },
39}
40
41impl Origin {
42    /// One line for `status` and the trace.
43    #[must_use]
44    pub fn describe(&self) -> String {
45        match self {
46            Self::Bug {
47                requirement,
48                by,
49                via,
50                ..
51            } => format!("bug {requirement}, recorded by {by} ({via})"),
52            Self::Issue { number, repo, .. } => match repo {
53                Some(r) => format!("issue {r}#{number}"),
54                None => format!("issue #{number}"),
55            },
56        }
57    }
58}
59
60/// The rung a unit stands on: a quick change (L1), a bug (L2) or
61/// a feature. A quick-change or bug unit's pipeline authors and confirms
62/// its own acceptance, so the review writes no tests for it.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
64#[serde(rename_all = "snake_case")]
65pub enum Rung {
66    /// A quick change's standing unit (`speccoder do`).
67    Quick,
68    /// A bug's home: a standing bug-fix unit or a bug of the format's own
69    /// bug structure.
70    Bug,
71    /// Any other unit.
72    Feature,
73}
74
75impl Rung {
76    /// Its recorded name.
77    #[must_use]
78    pub const fn as_str(self) -> &'static str {
79        match self {
80            Self::Quick => "quick",
81            Self::Bug => "bug",
82            Self::Feature => "feature",
83        }
84    }
85}
86
87/// The rung of `unit`, from the standing units the format names for the
88/// project (`(unit, rung)`): a standing unit, or a unit under one
89/// (`<standing>/<slug>`), is on its rung; every other unit is a feature.
90#[must_use]
91pub fn rung_of(unit: &str, standing: &[(String, Rung)]) -> Rung {
92    standing
93        .iter()
94        .find(|(name, _)| {
95            unit == name
96                || unit
97                    .strip_prefix(name.as_str())
98                    .is_some_and(|rest| rest.starts_with('/'))
99        })
100        .map_or(Rung::Feature, |(_, r)| *r)
101}
102
103/// The subject a bug's origin is recorded for: `<unit>/<requirement>`.
104#[must_use]
105pub fn subject(unit: &str, requirement: &str) -> String {
106    format!("{unit}/{requirement}")
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn origins_carry_their_kind_and_read_back() {
115        let o = Origin::Issue {
116            number: 42,
117            repo: Some("o/r".to_owned()),
118            context_sha256: None,
119        };
120        let v = serde_json::to_value(&o).unwrap_or_default();
121        assert_eq!(v["kind"], "issue");
122        assert_eq!(serde_json::from_value::<Origin>(v).ok(), Some(o.clone()));
123        assert_eq!(o.describe(), "issue o/r#42");
124        assert_eq!(subject("007-bugfixes", "FR-003"), "007-bugfixes/FR-003");
125    }
126
127    #[test]
128    fn every_rung_is_decided_by_the_standing_units() {
129        let standing = vec![
130            ("004-quick-changes".to_owned(), Rung::Quick),
131            ("007-bugfixes".to_owned(), Rung::Bug),
132            (".specify/bugs".to_owned(), Rung::Bug),
133        ];
134        assert_eq!(rung_of("004-quick-changes", &standing), Rung::Quick);
135        assert_eq!(rung_of("007-bugfixes", &standing), Rung::Bug);
136        assert_eq!(rung_of(".specify/bugs/empty-title", &standing), Rung::Bug);
137        assert_eq!(rung_of("007-bugfixes-extra", &standing), Rung::Feature);
138        assert_eq!(rung_of("001-greet", &standing), Rung::Feature);
139        assert_eq!(rung_of("001-greet", &[]), Rung::Feature);
140        assert_eq!(Rung::Quick.as_str(), "quick");
141    }
142}