1use 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 pub fn is_open(self) -> bool {
57 matches!(
58 self,
59 ChangeStatus::Draft | ChangeStatus::InReview | ChangeStatus::Rejected
60 )
61 }
62
63 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum ReviewOutcome {
136 Pending,
138 Approved,
140 Rejected,
142}
143
144pub 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
158pub struct RoleRequirement {
159 pub label: String,
161 pub members: Vec<uuid::Uuid>,
162}
163
164#[derive(Clone, Debug, Default, PartialEq, Eq)]
167pub struct ReviewRequirement {
168 pub min_approvals: Option<usize>,
170 pub required_users: Vec<(uuid::Uuid, String)>,
172 pub role_groups: Vec<RoleRequirement>,
174}
175
176pub 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
186pub 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
221pub 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
242pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
270pub enum ReviewConflict {
271 Author,
273 Submitter,
275 Editor,
277}
278
279impl ReviewConflict {
280 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
290pub 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
310pub fn format_request_number(value: i64) -> String {
316 format!("ECR-{value:06}")
317}
318
319#[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 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 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#[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "snake_case")]
496pub enum LineAction {
497 Release,
499 Obsolete,
501 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
538#[serde(rename_all = "snake_case")]
539pub enum ApprovalReauth {
540 #[default]
542 Off,
543 Release,
545 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "snake_case")]
582pub enum SignOffMethod {
583 Session,
585 Password,
587 Sso,
589 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 pub fn is_fresh(self) -> bool {
605 !matches!(self, SignOffMethod::Session)
606 }
607}
608
609pub 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 assert!(LineAction::Obsolete
683 .required_state()
684 .can_transition_to(LifecycleState::Obsolete));
685 }
686}
687
688#[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 #[test]
799 fn open_and_modifiable() {
800 let open: Vec<bool> = ChangeStatus::ALL.iter().map(|s| s.is_open()).collect();
801 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 assert_eq!(open, [true, true, true, false, true, false, false]);
811 }
812
813 #[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}