Skip to main content

teamcraft/domain/
lifecycle.rs

1//! Revision lifecycle and revision letters.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum LifecycleState {
8    /// Being worked on; editable.
9    InWork,
10    /// Part of a change order that is under review; frozen.
11    InReview,
12    /// Approved and released; immutable.
13    Released,
14    /// Superseded / must not be used for new designs; immutable.
15    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    /// Whether attributes, files and BOM of a revision in this state may change.
55    pub fn is_editable(self) -> bool {
56        matches!(self, LifecycleState::InWork)
57    }
58
59    /// Whether the revision still counts as "open" (blocks creating a new revision).
60    pub fn is_open(self) -> bool {
61        matches!(self, LifecycleState::InWork | LifecycleState::InReview)
62    }
63
64    /// Allowed lifecycle transitions. Everything else is rejected.
65    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/// One allowed lifecycle transition: how it happens and who may trigger it.
75/// The services enforce these rules; this table documents them in the UI and
76/// is checked against [`LifecycleState::can_transition_to`] by a unit test.
77#[derive(Clone, Copy, Debug, Serialize)]
78pub struct TransitionRule {
79    pub from: LifecycleState,
80    pub to: LifecycleState,
81    /// How the transition happens.
82    pub how: &'static str,
83    /// Who may trigger it.
84    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
114/// The rules that lead away from `state`.
115pub fn rules_from(state: LifecycleState) -> Vec<TransitionRule> {
116    LIFECYCLE_RULES
117        .iter()
118        .filter(|r| r.from == state)
119        .copied()
120        .collect()
121}
122
123/// Revision letters per ASME Y14.35: the letters I, O, Q, S, X and Z are not
124/// used because they are easily confused with digits.
125pub(super) const REVISION_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPRTUVWY";
126
127/// Converts a 1-based revision sequence number into its revision code:
128/// 1 → `A`, 2 → `B`, …, 20 → `Y`, 21 → `AA`, 22 → `AB`, …
129pub 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
143/// Inverse of [`revision_code`]. Returns `None` for invalid codes.
144pub 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/// What happens to the old versions of a file when a newer one is uploaded
158/// (`files.retention`).
159#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum FileRetention {
162    /// Every version is kept (the default).
163    All,
164    /// Only the newest version is kept: older versions are removed by the
165    /// housekeeping after a grace period, as long as the revision is in work.
166    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}