teamcraft/domain/
lifecycle.rs1use serde::{Deserialize, Serialize};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum LifecycleState {
8 InWork,
10 InReview,
12 Released,
14 Obsolete,
16}
17
18impl LifecycleState {
19 pub const ALL: [LifecycleState; 4] = [
20 LifecycleState::InWork,
21 LifecycleState::InReview,
22 LifecycleState::Released,
23 LifecycleState::Obsolete,
24 ];
25
26 pub fn as_str(self) -> &'static str {
27 match self {
28 LifecycleState::InWork => "in_work",
29 LifecycleState::InReview => "in_review",
30 LifecycleState::Released => "released",
31 LifecycleState::Obsolete => "obsolete",
32 }
33 }
34
35 pub fn label(self) -> &'static str {
36 match self {
37 LifecycleState::InWork => "WIP",
38 LifecycleState::InReview => "In Review",
39 LifecycleState::Released => "Released",
40 LifecycleState::Obsolete => "Obsolete",
41 }
42 }
43
44 pub fn parse(s: &str) -> Option<LifecycleState> {
45 match s.trim().to_ascii_lowercase().as_str() {
46 "in_work" => Some(LifecycleState::InWork),
47 "in_review" => Some(LifecycleState::InReview),
48 "released" => Some(LifecycleState::Released),
49 "obsolete" => Some(LifecycleState::Obsolete),
50 _ => None,
51 }
52 }
53
54 pub fn is_editable(self) -> bool {
56 matches!(self, LifecycleState::InWork)
57 }
58
59 pub fn is_open(self) -> bool {
61 matches!(self, LifecycleState::InWork | LifecycleState::InReview)
62 }
63
64 pub fn can_transition_to(self, to: LifecycleState) -> bool {
66 use LifecycleState::*;
67 matches!(
68 (self, to),
69 (InWork, InReview) | (InReview, Released) | (InReview, InWork) | (Released, Obsolete)
70 )
71 }
72}
73
74#[derive(Clone, Copy, Debug, Serialize)]
78pub struct TransitionRule {
79 pub from: LifecycleState,
80 pub to: LifecycleState,
81 pub how: &'static str,
83 pub who: &'static str,
85}
86
87pub const LIFECYCLE_RULES: [TransitionRule; 4] = [
88 TransitionRule {
89 from: LifecycleState::InWork,
90 to: LifecycleState::InReview,
91 how: "Submit a change order that contains the revision (it must not be checked out by somebody else).",
92 who: "The author of the change – a CAD editor of the item's project group – or an admin.",
93 },
94 TransitionRule {
95 from: LifecycleState::InReview,
96 to: LifecycleState::Released,
97 how: "Automatically when every reviewer of the change has approved; all its revisions are released together.",
98 who: "The assigned reviewers: CAD editors or admins, never the author, the submitter or anyone who changed the revisions (four-eyes principle).",
99 },
100 TransitionRule {
101 from: LifecycleState::InReview,
102 to: LifecycleState::InWork,
103 how: "A reviewer rejects the change (with a reason) or its author withdraws it.",
104 who: "Any assigned reviewer; the author of the change or an admin (for a deactivated author also a manager of every affected project group).",
105 },
106 TransitionRule {
107 from: LifecycleState::Released,
108 to: LifecycleState::Obsolete,
109 how: "Mark obsolete – final, cannot be undone. Directly, or through a change order with an obsolete or supersede line, applied when the change is released.",
110 who: "Directly: CAD editors of the item's project group, admins – unless the group requires a change order (then 409). Through a change order: the reviewers of the change approve it (four-eyes principle as for every release).",
111 },
112];
113
114pub fn rules_from(state: LifecycleState) -> Vec<TransitionRule> {
116 LIFECYCLE_RULES
117 .iter()
118 .filter(|r| r.from == state)
119 .copied()
120 .collect()
121}
122
123pub(super) const REVISION_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPRTUVWY";
126
127pub fn revision_code(seq: i32) -> String {
130 assert!(seq >= 1, "revision sequence numbers start at 1");
131 let base = REVISION_ALPHABET.len() as u64;
132 let mut n = seq as u64;
133 let mut out = Vec::new();
134 while n > 0 {
135 n -= 1;
136 out.push(REVISION_ALPHABET[(n % base) as usize]);
137 n /= base;
138 }
139 out.reverse();
140 String::from_utf8(out).expect("alphabet is ASCII")
141}
142
143pub fn revision_seq(code: &str) -> Option<i32> {
145 if code.is_empty() || code.len() > 6 {
146 return None;
147 }
148 let base = REVISION_ALPHABET.len() as i64;
149 let mut n: i64 = 0;
150 for b in code.trim().to_ascii_uppercase().bytes() {
151 let digit = REVISION_ALPHABET.iter().position(|&a| a == b)? as i64;
152 n = n * base + digit + 1;
153 }
154 i32::try_from(n).ok().filter(|n| *n >= 1)
155}
156
157#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum FileRetention {
162 All,
164 Latest,
167}
168
169impl FileRetention {
170 pub fn as_str(self) -> &'static str {
171 match self {
172 FileRetention::All => "all",
173 FileRetention::Latest => "latest",
174 }
175 }
176
177 pub fn parse(s: &str) -> Option<FileRetention> {
178 match s.trim().to_ascii_lowercase().as_str() {
179 "all" => Some(FileRetention::All),
180 "latest" => Some(FileRetention::Latest),
181 _ => None,
182 }
183 }
184}