Skip to main content

teamcraft/domain/
changes.rs

1//! Change orders (ECO) and change requests (ECR).
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum ChangeStatus {
8    Draft,
9    InReview,
10    Rejected,
11    Released,
12    Cancelled,
13}
14
15impl ChangeStatus {
16    pub const ALL: [ChangeStatus; 5] = [
17        ChangeStatus::Draft,
18        ChangeStatus::InReview,
19        ChangeStatus::Rejected,
20        ChangeStatus::Released,
21        ChangeStatus::Cancelled,
22    ];
23
24    pub fn as_str(self) -> &'static str {
25        match self {
26            ChangeStatus::Draft => "draft",
27            ChangeStatus::InReview => "in_review",
28            ChangeStatus::Rejected => "rejected",
29            ChangeStatus::Released => "released",
30            ChangeStatus::Cancelled => "cancelled",
31        }
32    }
33
34    pub fn label(self) -> &'static str {
35        match self {
36            ChangeStatus::Draft => "Draft",
37            ChangeStatus::InReview => "In Review",
38            ChangeStatus::Rejected => "Rejected",
39            ChangeStatus::Released => "Released",
40            ChangeStatus::Cancelled => "Cancelled",
41        }
42    }
43
44    pub fn parse(s: &str) -> Option<ChangeStatus> {
45        match s.trim().to_ascii_lowercase().as_str() {
46            "draft" => Some(ChangeStatus::Draft),
47            "in_review" => Some(ChangeStatus::InReview),
48            "rejected" => Some(ChangeStatus::Rejected),
49            "released" => Some(ChangeStatus::Released),
50            "cancelled" => Some(ChangeStatus::Cancelled),
51            _ => None,
52        }
53    }
54
55    /// Open changes still hold on to their affected revisions.
56    pub fn is_open(self) -> bool {
57        matches!(
58            self,
59            ChangeStatus::Draft | ChangeStatus::InReview | ChangeStatus::Rejected
60        )
61    }
62
63    /// Whether items, reviewers and texts of the change may be modified.
64    pub fn is_modifiable(self) -> bool {
65        matches!(self, ChangeStatus::Draft)
66    }
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum Priority {
72    Low,
73    Normal,
74    High,
75    Critical,
76}
77
78impl Priority {
79    pub const ALL: [Priority; 4] = [
80        Priority::Low,
81        Priority::Normal,
82        Priority::High,
83        Priority::Critical,
84    ];
85
86    pub fn as_str(self) -> &'static str {
87        match self {
88            Priority::Low => "low",
89            Priority::Normal => "normal",
90            Priority::High => "high",
91            Priority::Critical => "critical",
92        }
93    }
94
95    pub fn parse(s: &str) -> Option<Priority> {
96        match s.trim().to_ascii_lowercase().as_str() {
97            "low" => Some(Priority::Low),
98            "normal" | "" => Some(Priority::Normal),
99            "high" => Some(Priority::High),
100            "critical" => Some(Priority::Critical),
101            _ => None,
102        }
103    }
104}
105
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum Decision {
109    Pending,
110    Approved,
111    Rejected,
112}
113
114impl Decision {
115    pub fn as_str(self) -> &'static str {
116        match self {
117            Decision::Pending => "pending",
118            Decision::Approved => "approved",
119            Decision::Rejected => "rejected",
120        }
121    }
122
123    pub fn parse(s: &str) -> Option<Decision> {
124        match s.trim().to_ascii_lowercase().as_str() {
125            "pending" => Some(Decision::Pending),
126            "approved" | "approve" => Some(Decision::Approved),
127            "rejected" | "reject" => Some(Decision::Rejected),
128            _ => None,
129        }
130    }
131}
132
133/// Outcome of evaluating all review decisions of a change.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum ReviewOutcome {
136    /// At least one reviewer has not decided yet and nobody rejected.
137    Pending,
138    /// Every reviewer approved.
139    Approved,
140    /// At least one reviewer rejected.
141    Rejected,
142}
143
144/// A single rejection rejects the change; all reviewers must approve.
145pub fn review_outcome(decisions: &[Decision]) -> ReviewOutcome {
146    if decisions.contains(&Decision::Rejected) {
147        ReviewOutcome::Rejected
148    } else if !decisions.is_empty() && decisions.iter().all(|d| *d == Decision::Approved) {
149        ReviewOutcome::Approved
150    } else {
151        ReviewOutcome::Pending
152    }
153}
154
155/// A project group that must be represented among the approvals (a role
156/// group such as QA): `members` are the people whose approval counts.
157#[derive(Clone, Debug, Default, PartialEq, Eq)]
158pub struct RoleRequirement {
159    /// Name shown in messages, e.g. "the group QA".
160    pub label: String,
161    pub members: Vec<uuid::Uuid>,
162}
163
164/// What the review policies of all affected project groups ask of a change.
165/// The default asks for nothing beyond "every reviewer approves".
166#[derive(Clone, Debug, Default, PartialEq, Eq)]
167pub struct ReviewRequirement {
168    /// `None` = every reviewer must approve.
169    pub min_approvals: Option<usize>,
170    /// People who must be reviewers and approve (with their display names).
171    pub required_users: Vec<(uuid::Uuid, String)>,
172    /// Role groups that need at least one approval from a member.
173    pub role_groups: Vec<RoleRequirement>,
174}
175
176/// Combines the minimum approvals of several groups: a group without a
177/// value wants all reviewers, so the strictest wins.
178pub fn combine_min_approvals(mins: &[Option<usize>]) -> Option<usize> {
179    if mins.is_empty() || mins.contains(&None) {
180        None
181    } else {
182        mins.iter().copied().flatten().max()
183    }
184}
185
186/// Outcome of a change under a review policy. A single rejection still
187/// rejects; the change is approved when enough reviewers approved, every
188/// required person did, and every role group is represented. With the
189/// default requirement this equals [`review_outcome`].
190pub fn review_outcome_with(
191    votes: &[(uuid::Uuid, Decision)],
192    req: &ReviewRequirement,
193) -> ReviewOutcome {
194    if votes.iter().any(|(_, d)| *d == Decision::Rejected) {
195        return ReviewOutcome::Rejected;
196    }
197    if votes.is_empty() {
198        return ReviewOutcome::Pending;
199    }
200    let approved: Vec<uuid::Uuid> = votes
201        .iter()
202        .filter(|(_, d)| *d == Decision::Approved)
203        .map(|(u, _)| *u)
204        .collect();
205    let needed = req
206        .min_approvals
207        .map_or(votes.len(), |m| m.min(votes.len()));
208    let enough = approved.len() >= needed.max(1);
209    let people = req.required_users.iter().all(|(u, _)| approved.contains(u));
210    let roles = req
211        .role_groups
212        .iter()
213        .all(|g| g.members.iter().any(|m| approved.contains(m)));
214    if enough && people && roles {
215        ReviewOutcome::Approved
216    } else {
217        ReviewOutcome::Pending
218    }
219}
220
221/// The part of the requirement the assigned `reviewers` can still meet.
222/// Reviewers are only changed in Draft, so a policy or a group that changed
223/// after submit must not make an in-review change undecidable.
224pub fn attainable(req: &ReviewRequirement, reviewers: &[uuid::Uuid]) -> ReviewRequirement {
225    ReviewRequirement {
226        min_approvals: req.min_approvals,
227        required_users: req
228            .required_users
229            .iter()
230            .filter(|(u, _)| reviewers.contains(u))
231            .cloned()
232            .collect(),
233        role_groups: req
234            .role_groups
235            .iter()
236            .filter(|g| g.members.iter().any(|m| reviewers.contains(m)))
237            .cloned()
238            .collect(),
239    }
240}
241
242/// Why a set of reviewers can never satisfy the policy (empty = it can).
243/// `submit` refuses a change with any of these.
244pub fn policy_shortfalls(reviewers: &[uuid::Uuid], req: &ReviewRequirement) -> Vec<String> {
245    let mut out = Vec::new();
246    if let Some(min) = req.min_approvals {
247        if min > reviewers.len() {
248            out.push(format!(
249                "{min} approvals are required but only {} reviewer{} assigned",
250                reviewers.len(),
251                if reviewers.len() == 1 { " is" } else { "s are" }
252            ));
253        }
254    }
255    for (user, name) in &req.required_users {
256        if !reviewers.contains(user) {
257            out.push(format!("{name} must be a reviewer"));
258        }
259    }
260    for group in &req.role_groups {
261        if !group.members.iter().any(|m| reviewers.contains(m)) {
262            out.push(format!("a reviewer from {} is required", group.label));
263        }
264    }
265    out
266}
267
268/// Why somebody may not review a change order (separation of duties).
269#[derive(Clone, Copy, Debug, PartialEq, Eq)]
270pub enum ReviewConflict {
271    /// Created the change order.
272    Author,
273    /// Submitted (or, as an administrator, prepared) the change order for the author.
274    Submitter,
275    /// Changed the data under review: created a revision, uploaded a file or edited it.
276    Editor,
277}
278
279impl ReviewConflict {
280    /// Sentence fragment for error messages, e.g. "edited the revisions under review".
281    pub fn describe(self) -> &'static str {
282        match self {
283            ReviewConflict::Author => "created this change",
284            ReviewConflict::Submitter => "prepared or submitted this change",
285            ReviewConflict::Editor => "changed the revisions under review",
286        }
287    }
288}
289
290/// Four-eyes check: the reviewer must be neither the author, nor the person
291/// who submitted the change (an administrator acting for the author), nor
292/// anyone who changed the affected revisions (`editors`).
293pub fn conflicting_reviewer(
294    reviewer: uuid::Uuid,
295    author: uuid::Uuid,
296    submitter: Option<uuid::Uuid>,
297    editors: &[uuid::Uuid],
298) -> Option<ReviewConflict> {
299    if reviewer == author {
300        Some(ReviewConflict::Author)
301    } else if submitter == Some(reviewer) {
302        Some(ReviewConflict::Submitter)
303    } else if editors.contains(&reviewer) {
304        Some(ReviewConflict::Editor)
305    } else {
306        None
307    }
308}
309
310// ---------------------------------------------------------------------------
311// Change requests (ECR) – the step before a change order (ECO)
312// ---------------------------------------------------------------------------
313
314/// Formats an automatically generated change request number, e.g. `ECR-000007`.
315pub fn format_request_number(value: i64) -> String {
316    format!("ECR-{value:06}")
317}
318
319/// Status of an engineering change request.
320///
321/// ```text
322///   new ──▶ in_triage ──▶ accepted ──▶ implemented (linked change order released)
323///    │          │      ──▶ rejected
324///    │          │      ──▶ deferred ──▶ in_triage (reopened) / accepted / rejected
325///    └──────────┴──▶ withdrawn (by the requester)
326/// ```
327#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(rename_all = "snake_case")]
329pub enum RequestStatus {
330    New,
331    InTriage,
332    Accepted,
333    Rejected,
334    Deferred,
335    Implemented,
336    Withdrawn,
337}
338
339impl RequestStatus {
340    pub const ALL: [RequestStatus; 7] = [
341        RequestStatus::New,
342        RequestStatus::InTriage,
343        RequestStatus::Accepted,
344        RequestStatus::Rejected,
345        RequestStatus::Deferred,
346        RequestStatus::Implemented,
347        RequestStatus::Withdrawn,
348    ];
349
350    pub fn as_str(self) -> &'static str {
351        match self {
352            RequestStatus::New => "new",
353            RequestStatus::InTriage => "in_triage",
354            RequestStatus::Accepted => "accepted",
355            RequestStatus::Rejected => "rejected",
356            RequestStatus::Deferred => "deferred",
357            RequestStatus::Implemented => "implemented",
358            RequestStatus::Withdrawn => "withdrawn",
359        }
360    }
361
362    pub fn label(self) -> &'static str {
363        match self {
364            RequestStatus::New => "New",
365            RequestStatus::InTriage => "In Triage",
366            RequestStatus::Accepted => "Accepted",
367            RequestStatus::Rejected => "Rejected",
368            RequestStatus::Deferred => "Deferred",
369            RequestStatus::Implemented => "Implemented",
370            RequestStatus::Withdrawn => "Withdrawn",
371        }
372    }
373
374    pub fn parse(s: &str) -> Option<RequestStatus> {
375        let s = s.trim().to_ascii_lowercase();
376        RequestStatus::ALL.into_iter().find(|v| v.as_str() == s)
377    }
378
379    /// Not finished yet: still waiting for a decision or for its change order.
380    pub fn is_open(self) -> bool {
381        matches!(
382            self,
383            RequestStatus::New
384                | RequestStatus::InTriage
385                | RequestStatus::Accepted
386                | RequestStatus::Deferred
387        )
388    }
389
390    /// Allowed status transitions. Everything else is rejected.
391    pub fn can_transition_to(self, to: RequestStatus) -> bool {
392        use RequestStatus::*;
393        matches!(
394            (self, to),
395            (New, InTriage)
396                | (New | InTriage | Deferred, Accepted | Rejected)
397                | (New | InTriage, Deferred)
398                | (Deferred, InTriage)
399                | (New | InTriage, Withdrawn)
400                | (Accepted, Implemented)
401        )
402    }
403}
404
405/// Why a change is requested.
406#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "snake_case")]
408pub enum RequestReason {
409    Defect,
410    Improvement,
411    Cost,
412    Manufacturing,
413    Supplier,
414    Safety,
415    Customer,
416    Other,
417}
418
419impl RequestReason {
420    pub const ALL: [RequestReason; 8] = [
421        RequestReason::Defect,
422        RequestReason::Improvement,
423        RequestReason::Cost,
424        RequestReason::Manufacturing,
425        RequestReason::Supplier,
426        RequestReason::Safety,
427        RequestReason::Customer,
428        RequestReason::Other,
429    ];
430
431    pub fn as_str(self) -> &'static str {
432        match self {
433            RequestReason::Defect => "defect",
434            RequestReason::Improvement => "improvement",
435            RequestReason::Cost => "cost",
436            RequestReason::Manufacturing => "manufacturing",
437            RequestReason::Supplier => "supplier",
438            RequestReason::Safety => "safety",
439            RequestReason::Customer => "customer",
440            RequestReason::Other => "other",
441        }
442    }
443
444    pub fn label(self) -> &'static str {
445        match self {
446            RequestReason::Defect => "Defect / error",
447            RequestReason::Improvement => "Improvement",
448            RequestReason::Cost => "Cost reduction",
449            RequestReason::Manufacturing => "Manufacturing / assembly",
450            RequestReason::Supplier => "Supplier / purchasing",
451            RequestReason::Safety => "Safety / compliance",
452            RequestReason::Customer => "Customer request",
453            RequestReason::Other => "Other",
454        }
455    }
456
457    pub fn parse(s: &str) -> Option<RequestReason> {
458        let s = s.trim().to_ascii_lowercase();
459        if s.is_empty() {
460            return Some(RequestReason::Other);
461        }
462        RequestReason::ALL.into_iter().find(|v| v.as_str() == s)
463    }
464}
465
466/// The decision of the triage on a change request.
467#[derive(Clone, Copy, Debug, PartialEq, Eq)]
468pub enum RequestDecision {
469    Accept,
470    Reject,
471    Defer,
472}
473
474impl RequestDecision {
475    pub fn parse(s: &str) -> Option<RequestDecision> {
476        match s.trim().to_ascii_lowercase().as_str() {
477            "accept" | "accepted" => Some(RequestDecision::Accept),
478            "reject" | "rejected" => Some(RequestDecision::Reject),
479            "defer" | "deferred" => Some(RequestDecision::Defer),
480            _ => None,
481        }
482    }
483
484    pub fn status(self) -> RequestStatus {
485        match self {
486            RequestDecision::Accept => RequestStatus::Accepted,
487            RequestDecision::Reject => RequestStatus::Rejected,
488            RequestDecision::Defer => RequestStatus::Deferred,
489        }
490    }
491}
492
493/// What a line of a change order does when the change is released.
494#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "snake_case")]
496pub enum LineAction {
497    /// Releases an in-work revision (the classic line).
498    Release,
499    /// Marks a released revision obsolete.
500    Obsolete,
501    /// Marks a released revision obsolete and declares its item superseded by another item.
502    Supersede,
503}
504
505impl LineAction {
506    pub const ALL: [LineAction; 3] = [
507        LineAction::Release,
508        LineAction::Obsolete,
509        LineAction::Supersede,
510    ];
511
512    pub fn as_str(self) -> &'static str {
513        match self {
514            LineAction::Release => "release",
515            LineAction::Obsolete => "obsolete",
516            LineAction::Supersede => "supersede",
517        }
518    }
519
520    pub fn parse(raw: &str) -> Option<LineAction> {
521        let raw = raw.trim().to_ascii_lowercase();
522        LineAction::ALL.into_iter().find(|a| a.as_str() == raw)
523    }
524
525    /// The lifecycle state the revision of the line must be in when the line is added.
526    pub fn required_state(self) -> crate::domain::LifecycleState {
527        match self {
528            LineAction::Release => crate::domain::LifecycleState::InWork,
529            LineAction::Obsolete | LineAction::Supersede => crate::domain::LifecycleState::Released,
530        }
531    }
532}
533
534/// Whether a review decision in the browser needs a fresh sign-in
535/// (`TEAMCRAFT_APPROVAL_REAUTH`, #152 step 4). API tokens are not asked:
536/// approving needs the `approve` token scope instead.
537#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
538#[serde(rename_all = "snake_case")]
539pub enum ApprovalReauth {
540    /// No extra step (the default).
541    #[default]
542    Off,
543    /// Every approval – approvals are what release a change.
544    Release,
545    /// Every approval and every rejection.
546    EveryDecision,
547}
548
549impl ApprovalReauth {
550    pub fn as_str(self) -> &'static str {
551        match self {
552            ApprovalReauth::Off => "off",
553            ApprovalReauth::Release => "release",
554            ApprovalReauth::EveryDecision => "every_decision",
555        }
556    }
557
558    pub fn parse(raw: &str) -> Option<ApprovalReauth> {
559        match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() {
560            "off" | "" => Some(ApprovalReauth::Off),
561            "release" => Some(ApprovalReauth::Release),
562            "every_decision" => Some(ApprovalReauth::EveryDecision),
563            _ => None,
564        }
565    }
566
567    /// Whether this decision needs a fresh sign-in.
568    pub fn requires(self, decision: Decision) -> bool {
569        match self {
570            ApprovalReauth::Off => false,
571            ApprovalReauth::Release => decision == Decision::Approved,
572            ApprovalReauth::EveryDecision => {
573                matches!(decision, Decision::Approved | Decision::Rejected)
574            }
575        }
576    }
577}
578
579/// How a reviewer confirmed a decision (stored with the review).
580#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "snake_case")]
582pub enum SignOffMethod {
583    /// The web session only, no fresh sign-in.
584    Session,
585    /// The account password, entered with the decision.
586    Password,
587    /// Signed in again with single sign-on shortly before.
588    Sso,
589    /// A personal access token with the `approve` scope.
590    Token,
591}
592
593impl SignOffMethod {
594    pub fn as_str(self) -> &'static str {
595        match self {
596            SignOffMethod::Session => "session",
597            SignOffMethod::Password => "password",
598            SignOffMethod::Sso => "sso",
599            SignOffMethod::Token => "token",
600        }
601    }
602
603    /// Whether the method proves that the person is at the keyboard now.
604    pub fn is_fresh(self) -> bool {
605        !matches!(self, SignOffMethod::Session)
606    }
607}
608
609/// Longest stated meaning of a sign-off ("approved as design owner").
610pub const MAX_SIGN_OFF_MEANING: usize = 200;
611
612#[cfg(test)]
613mod sign_off_tests {
614    use super::*;
615
616    #[test]
617    fn which_decisions_need_a_fresh_sign_in() {
618        use ApprovalReauth::*;
619        use Decision::*;
620        for d in [Approved, Rejected, Pending] {
621            assert!(!Off.requires(d));
622        }
623        assert!(Release.requires(Approved));
624        assert!(!Release.requires(Rejected));
625        assert!(EveryDecision.requires(Approved));
626        assert!(EveryDecision.requires(Rejected));
627        assert!(!EveryDecision.requires(Pending));
628    }
629
630    #[test]
631    fn settings_and_methods_parse() {
632        assert_eq!(ApprovalReauth::parse(""), Some(ApprovalReauth::Off));
633        assert_eq!(
634            ApprovalReauth::parse(" Every-Decision "),
635            Some(ApprovalReauth::EveryDecision)
636        );
637        assert_eq!(ApprovalReauth::parse("always"), None);
638        for r in [
639            ApprovalReauth::Off,
640            ApprovalReauth::Release,
641            ApprovalReauth::EveryDecision,
642        ] {
643            assert_eq!(ApprovalReauth::parse(r.as_str()), Some(r));
644        }
645        assert!(!SignOffMethod::Session.is_fresh());
646        assert!(SignOffMethod::Password.is_fresh());
647        assert!(SignOffMethod::Sso.is_fresh());
648        assert!(SignOffMethod::Token.is_fresh());
649    }
650}
651
652#[cfg(test)]
653mod line_action_tests {
654    use super::*;
655    use crate::domain::LifecycleState;
656
657    #[test]
658    fn parses_and_round_trips() {
659        for a in LineAction::ALL {
660            assert_eq!(LineAction::parse(a.as_str()), Some(a));
661        }
662        assert_eq!(
663            LineAction::parse(" Supersede "),
664            Some(LineAction::Supersede)
665        );
666        assert_eq!(LineAction::parse("delete"), None);
667        assert_eq!(LineAction::parse(""), None);
668    }
669
670    #[test]
671    fn actions_need_a_released_revision_and_release_an_in_work_one() {
672        assert_eq!(LineAction::Release.required_state(), LifecycleState::InWork);
673        assert_eq!(
674            LineAction::Obsolete.required_state(),
675            LifecycleState::Released
676        );
677        assert_eq!(
678            LineAction::Supersede.required_state(),
679            LifecycleState::Released
680        );
681        // an action line is valid only because the lifecycle allows the transition
682        assert!(LineAction::Obsolete
683            .required_state()
684            .can_transition_to(LifecycleState::Obsolete));
685    }
686}
687
688/// The stored values and labels (found by cargo mutants: the round-trip
689/// tests above do not pin the strings that are in the database and the API).
690#[cfg(test)]
691mod text_tests {
692    use super::*;
693
694    #[test]
695    fn stored_values_and_labels() {
696        let stored: Vec<&str> = ChangeStatus::ALL.iter().map(|s| s.as_str()).collect();
697        assert_eq!(
698            stored,
699            ["draft", "in_review", "rejected", "released", "cancelled"]
700        );
701        let labels: Vec<&str> = ChangeStatus::ALL.iter().map(|s| s.label()).collect();
702        assert_eq!(
703            labels,
704            ["Draft", "In Review", "Rejected", "Released", "Cancelled"]
705        );
706
707        let stored: Vec<&str> = Priority::ALL.iter().map(|p| p.as_str()).collect();
708        assert_eq!(stored, ["low", "normal", "high", "critical"]);
709
710        let decisions = [Decision::Pending, Decision::Approved, Decision::Rejected];
711        let stored: Vec<&str> = decisions.iter().map(|d| d.as_str()).collect();
712        assert_eq!(stored, ["pending", "approved", "rejected"]);
713
714        let stored: Vec<&str> = RequestStatus::ALL.iter().map(|s| s.as_str()).collect();
715        assert_eq!(
716            stored,
717            [
718                "new",
719                "in_triage",
720                "accepted",
721                "rejected",
722                "deferred",
723                "implemented",
724                "withdrawn"
725            ]
726        );
727        let labels: Vec<&str> = RequestStatus::ALL.iter().map(|s| s.label()).collect();
728        assert_eq!(
729            labels,
730            [
731                "New",
732                "In Triage",
733                "Accepted",
734                "Rejected",
735                "Deferred",
736                "Implemented",
737                "Withdrawn"
738            ]
739        );
740
741        let stored: Vec<&str> = RequestReason::ALL.iter().map(|r| r.as_str()).collect();
742        assert_eq!(
743            stored,
744            [
745                "defect",
746                "improvement",
747                "cost",
748                "manufacturing",
749                "supplier",
750                "safety",
751                "customer",
752                "other"
753            ]
754        );
755        let labels: Vec<&str> = RequestReason::ALL.iter().map(|r| r.label()).collect();
756        assert_eq!(
757            labels,
758            [
759                "Defect / error",
760                "Improvement",
761                "Cost reduction",
762                "Manufacturing / assembly",
763                "Supplier / purchasing",
764                "Safety / compliance",
765                "Customer request",
766                "Other"
767            ]
768        );
769
770        let stored: Vec<&str> = LineAction::ALL.iter().map(|a| a.as_str()).collect();
771        assert_eq!(stored, ["release", "obsolete", "supersede"]);
772
773        assert_eq!(
774            [
775                RequestDecision::Accept.status(),
776                RequestDecision::Reject.status(),
777                RequestDecision::Defer.status()
778            ],
779            [
780                RequestStatus::Accepted,
781                RequestStatus::Rejected,
782                RequestStatus::Deferred
783            ]
784        );
785        assert_eq!(ReviewConflict::Author.describe(), "created this change");
786        assert_eq!(
787            ReviewConflict::Submitter.describe(),
788            "prepared or submitted this change"
789        );
790        assert_eq!(
791            ReviewConflict::Editor.describe(),
792            "changed the revisions under review"
793        );
794    }
795
796    /// Which changes still hold their revisions, and which may be edited;
797    /// which change requests are still open (found by cargo mutants).
798    #[test]
799    fn open_and_modifiable() {
800        let open: Vec<bool> = ChangeStatus::ALL.iter().map(|s| s.is_open()).collect();
801        // draft, in_review, rejected, released, cancelled
802        assert_eq!(open, [true, true, true, false, false]);
803        let modifiable: Vec<bool> = ChangeStatus::ALL
804            .iter()
805            .map(|s| s.is_modifiable())
806            .collect();
807        assert_eq!(modifiable, [true, false, false, false, false]);
808        let open: Vec<bool> = RequestStatus::ALL.iter().map(|s| s.is_open()).collect();
809        // new, in_triage, accepted, rejected, deferred, implemented, withdrawn
810        assert_eq!(open, [true, true, true, false, true, false, false]);
811    }
812
813    /// Every stored value reads back, in any case and with white space;
814    /// unknown values are refused (found by cargo mutants).
815    #[test]
816    fn parse_round_trips_and_aliases() {
817        for s in ChangeStatus::ALL {
818            assert_eq!(ChangeStatus::parse(s.as_str()), Some(s));
819            assert_eq!(
820                ChangeStatus::parse(&format!(" {} ", s.as_str().to_uppercase())),
821                Some(s)
822            );
823        }
824        assert_eq!(ChangeStatus::parse("open"), None);
825        for p in Priority::ALL {
826            assert_eq!(Priority::parse(p.as_str()), Some(p));
827        }
828        assert_eq!(Priority::parse(""), Some(Priority::Normal));
829        assert_eq!(Priority::parse("urgent"), None);
830        for d in [Decision::Pending, Decision::Approved, Decision::Rejected] {
831            assert_eq!(Decision::parse(d.as_str()), Some(d));
832        }
833        assert_eq!(Decision::parse("Approve"), Some(Decision::Approved));
834        assert_eq!(Decision::parse("reject"), Some(Decision::Rejected));
835        assert_eq!(Decision::parse("maybe"), None);
836        for s in RequestStatus::ALL {
837            assert_eq!(RequestStatus::parse(s.as_str()), Some(s));
838        }
839        assert_eq!(
840            RequestStatus::parse(" In_Triage "),
841            Some(RequestStatus::InTriage)
842        );
843        assert_eq!(RequestStatus::parse("done"), None);
844        for r in RequestReason::ALL {
845            assert_eq!(RequestReason::parse(r.as_str()), Some(r));
846        }
847        assert_eq!(RequestReason::parse(""), Some(RequestReason::Other));
848        assert_eq!(RequestReason::parse("boredom"), None);
849        for (raw, decision) in [
850            ("accept", RequestDecision::Accept),
851            ("Accepted", RequestDecision::Accept),
852            ("reject", RequestDecision::Reject),
853            ("rejected", RequestDecision::Reject),
854            ("defer", RequestDecision::Defer),
855            (" Deferred ", RequestDecision::Defer),
856        ] {
857            assert_eq!(
858                RequestDecision::parse(raw).map(RequestDecision::status),
859                Some(decision.status()),
860                "{raw}"
861            );
862        }
863        assert!(RequestDecision::parse("later").is_none());
864    }
865}