Skip to main content

release_kit/plan/
classify.rs

1//! Classification: the corpus verdict a target earns before anything
2//! lands, and the plan classification derived from it and the record.
3//!
4//! Two words answer two questions. The verdict — `greenfield`,
5//! `brownfield`, or `needs-decision` — is what `rk assess` has always
6//! answered and reads the repository alone. The plan classification —
7//! `setup`, `migration`, `upgrade`, `drift`, or `invalid` — names which
8//! procedure a plan is, and it reads the verdict beside the record and
9//! the comparison. One enum carries the routing word, and the findings
10//! beside it carry the detail the word compresses: the marker that made
11//! a target brownfield, the file that made a landing drifted, the reason
12//! a record is invalid. A reader routes on the word and reads the
13//! findings, and neither needs the other to change shape when a new
14//! finding appears.
15
16use serde::{Deserialize, Serialize};
17
18/// The corpus verdict, computed from the repository's evidence alone.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum Verdict {
22    /// No release mechanism and no release history: land the workflow.
23    Greenfield,
24    /// A release mechanism is in place: migrate, never land beside it.
25    Brownfield,
26    /// Release activity no mechanism explains: the operator decides.
27    NeedsDecision,
28}
29
30impl Verdict {
31    /// The kebab-case verdict word, as the JSON serializes it.
32    #[must_use]
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Self::Greenfield => "greenfield",
36            Self::Brownfield => "brownfield",
37            Self::NeedsDecision => "needs-decision",
38        }
39    }
40}
41
42/// The repository facts the verdict reads: a projection of the
43/// assessment's evidence, so the rule stays pure and testable.
44#[derive(Debug, Clone, Default)]
45pub struct RepositoryFacts {
46    /// Release-mechanism files of other tools found at the target.
47    pub release_markers: Vec<String>,
48    /// Payload destinations already present.
49    pub collisions: Vec<String>,
50    /// How many tags the repository holds.
51    pub tags: usize,
52    /// Long-lived branches found besides the trunk.
53    pub long_lived_branches: Vec<String>,
54}
55
56/// Compute the verdict from the facts. Pure, so the rule is testable
57/// without a repository.
58#[must_use]
59pub fn verdict(facts: &RepositoryFacts) -> Verdict {
60    if !facts.release_markers.is_empty() || !facts.collisions.is_empty() {
61        return Verdict::Brownfield;
62    }
63    if facts.tags > 0 || !facts.long_lived_branches.is_empty() {
64        return Verdict::NeedsDecision;
65    }
66    Verdict::Greenfield
67}
68
69/// Which procedure a plan is.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Classification {
73    /// No record and a greenfield verdict: a first landing.
74    Setup,
75    /// No record and a mechanism or a history in the way: a migration,
76    /// gated by what the findings name.
77    Migration,
78    /// A record, and every owned file as the record left it: a newer
79    /// payload taken, or nothing to take.
80    Upgrade,
81    /// A record, and an owned file the target edited or removed: blocked
82    /// until reconciled.
83    Drift,
84    /// A record this engine cannot read: nothing is planned over it.
85    Invalid,
86}
87
88impl Classification {
89    /// The wire form, identical to the serde rendering.
90    #[must_use]
91    pub const fn as_str(self) -> &'static str {
92        match self {
93            Self::Setup => "setup",
94            Self::Migration => "migration",
95            Self::Upgrade => "upgrade",
96            Self::Drift => "drift",
97            Self::Invalid => "invalid",
98        }
99    }
100}
101
102/// One fact the classification compresses.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct Finding {
105    /// A stable code: `release-marker`, `payload-collision`, `tag`,
106    /// `long-lived-branch`, `owned-drift`, `owned-missing`,
107    /// `record-invalid`, `record-newer`.
108    pub code: std::borrow::Cow<'static, str>,
109    /// What was found, one line.
110    pub detail: String,
111}
112
113/// What the record was found to be, for the classification alone.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum RecordState {
116    /// No record at the target.
117    Absent,
118    /// A record this engine read.
119    Present,
120    /// A record this engine could not read.
121    Invalid,
122}
123
124/// Derive the classification from the record's state, the verdict, and
125/// whether the comparison found owned drift.
126#[must_use]
127pub const fn classify(record: RecordState, verdict: Verdict, owned_drift: bool) -> Classification {
128    match record {
129        RecordState::Invalid => Classification::Invalid,
130        RecordState::Absent => match verdict {
131            Verdict::Greenfield => Classification::Setup,
132            Verdict::Brownfield | Verdict::NeedsDecision => Classification::Migration,
133        },
134        RecordState::Present => {
135            if owned_drift {
136                Classification::Drift
137            } else {
138                Classification::Upgrade
139            }
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::{Classification, RecordState, RepositoryFacts, Verdict, classify, verdict};
147
148    #[test]
149    fn nothing_is_greenfield() {
150        assert_eq!(verdict(&RepositoryFacts::default()), Verdict::Greenfield);
151    }
152
153    #[test]
154    fn a_release_marker_or_a_collision_is_brownfield() {
155        let with_marker = RepositoryFacts {
156            release_markers: vec!["CHANGELOG.md".into()],
157            ..RepositoryFacts::default()
158        };
159        assert_eq!(verdict(&with_marker), Verdict::Brownfield);
160        let with_collision = RepositoryFacts {
161            collisions: vec!["release-plz.toml".into()],
162            ..RepositoryFacts::default()
163        };
164        assert_eq!(verdict(&with_collision), Verdict::Brownfield);
165    }
166
167    /// A mechanism outranks unexplained activity: tags beside a marker
168    /// are a history the mechanism made, not a question.
169    #[test]
170    fn a_mechanism_beside_activity_is_still_brownfield() {
171        let both = RepositoryFacts {
172            release_markers: vec!["CHANGELOG.md".into()],
173            tags: 7,
174            long_lived_branches: vec!["develop".into()],
175            ..RepositoryFacts::default()
176        };
177        assert_eq!(verdict(&both), Verdict::Brownfield);
178    }
179
180    #[test]
181    fn activity_with_no_mechanism_needs_a_decision() {
182        let tagged = RepositoryFacts {
183            tags: 1,
184            ..RepositoryFacts::default()
185        };
186        assert_eq!(verdict(&tagged), Verdict::NeedsDecision);
187        let branched = RepositoryFacts {
188            long_lived_branches: vec!["develop".into()],
189            ..RepositoryFacts::default()
190        };
191        assert_eq!(verdict(&branched), Verdict::NeedsDecision);
192    }
193
194    /// The five classifications over the six target states: empty,
195    /// brownfield, landed-current and landed-old (both upgrade: the
196    /// operations tell them apart), drifted, and invalid.
197    #[test]
198    fn the_classification_table_covers_the_six_states() {
199        assert_eq!(
200            classify(RecordState::Absent, Verdict::Greenfield, false),
201            Classification::Setup
202        );
203        assert_eq!(
204            classify(RecordState::Absent, Verdict::Brownfield, false),
205            Classification::Migration
206        );
207        assert_eq!(
208            classify(RecordState::Absent, Verdict::NeedsDecision, false),
209            Classification::Migration
210        );
211        assert_eq!(
212            classify(RecordState::Present, Verdict::Brownfield, false),
213            Classification::Upgrade
214        );
215        assert_eq!(
216            classify(RecordState::Present, Verdict::Brownfield, true),
217            Classification::Drift
218        );
219        assert_eq!(
220            classify(RecordState::Invalid, Verdict::Brownfield, true),
221            Classification::Invalid
222        );
223    }
224
225    #[test]
226    fn the_words_are_the_wire_form() {
227        for (classification, word) in [
228            (Classification::Setup, "setup"),
229            (Classification::Migration, "migration"),
230            (Classification::Upgrade, "upgrade"),
231            (Classification::Drift, "drift"),
232            (Classification::Invalid, "invalid"),
233        ] {
234            assert_eq!(classification.as_str(), word);
235            assert_eq!(
236                serde_json::to_string(&classification).expect("serializes"),
237                format!("\"{word}\"")
238            );
239        }
240        assert_eq!(Verdict::Greenfield.as_str(), "greenfield");
241        assert_eq!(Verdict::Brownfield.as_str(), "brownfield");
242        assert_eq!(Verdict::NeedsDecision.as_str(), "needs-decision");
243    }
244}