1use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum Verdict {
22 Greenfield,
24 Brownfield,
26 NeedsDecision,
28}
29
30impl Verdict {
31 #[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#[derive(Debug, Clone, Default)]
45pub struct RepositoryFacts {
46 pub release_markers: Vec<String>,
48 pub collisions: Vec<String>,
50 pub tags: usize,
52 pub long_lived_branches: Vec<String>,
54}
55
56#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Classification {
73 Setup,
75 Migration,
78 Upgrade,
81 Drift,
84 Invalid,
86}
87
88impl Classification {
89 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct Finding {
105 pub code: std::borrow::Cow<'static, str>,
109 pub detail: String,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum RecordState {
116 Absent,
118 Present,
120 Invalid,
122}
123
124#[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 #[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 #[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}