1use std::fmt;
4use std::ops::Deref;
5
6use serde::{Deserialize, Deserializer, Serialize};
7
8pub const MAX_REVIEW_COMMENTS_CHARS: usize = 32_768;
13
14pub const MAX_REPORT_PANELS: usize = 32;
20
21pub const MAX_PANEL_LABEL_CHARS: usize = 256;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ReportFieldError {
30 Empty,
32 InvalidPageSuffix,
34 InvalidPanelSuffix,
36 CommentsTooLong,
38 LabelTooLong,
40}
41
42impl fmt::Display for ReportFieldError {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 match self {
45 Self::Empty => f.write_str("report field must be a non-empty string"),
46 Self::InvalidPageSuffix => {
47 f.write_str("report page path must end with .html or .xhtml")
48 }
49 Self::InvalidPanelSuffix => f.write_str("manifest panel path must end with .xhtml"),
50 Self::CommentsTooLong => write!(
51 f,
52 "review comments must be at most {MAX_REVIEW_COMMENTS_CHARS} characters"
53 ),
54 Self::LabelTooLong => write!(
55 f,
56 "panel label must be at most {MAX_PANEL_LABEL_CHARS} characters"
57 ),
58 }
59 }
60}
61
62impl std::error::Error for ReportFieldError {}
63
64macro_rules! report_newtype {
65 ($(#[$meta:meta])* $name:ident, $doc:literal) => {
66 $(#[$meta])*
67 #[doc = $doc]
68 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72 #[serde(transparent)]
73 pub struct $name(String);
74
75 impl $name {
76 pub fn new(value: impl Into<String>) -> Self {
81 Self(value.into())
82 }
83
84 pub fn as_str(&self) -> &str {
86 &self.0
87 }
88
89 pub fn into_inner(self) -> String {
91 self.0
92 }
93 }
94
95 impl Deref for $name {
96 type Target = str;
97
98 fn deref(&self) -> &Self::Target {
99 &self.0
100 }
101 }
102
103 impl AsRef<str> for $name {
104 fn as_ref(&self) -> &str {
105 self.as_str()
106 }
107 }
108
109 impl fmt::Display for $name {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 self.0.fmt(f)
112 }
113 }
114
115 impl PartialEq<str> for $name {
116 fn eq(&self, other: &str) -> bool {
117 self.0 == other
118 }
119 }
120
121 impl PartialEq<&str> for $name {
122 fn eq(&self, other: &&str) -> bool {
123 self.0 == *other
124 }
125 }
126 };
127}
128
129report_newtype!(
130 ReportPagePath,
131 "Validated report page path relative to `--ui-root` (`.html` or `.xhtml`)."
132);
133report_newtype!(ReportTitle, "Validated report window title (non-empty).");
134report_newtype!(
135 ManifestPanelPath,
136 "Validated manifest panel path (non-empty `.xhtml` relative path)."
137);
138
139impl ReportTitle {
140 pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
146 let value = value.into();
147 if value.is_empty() {
148 return Err(ReportFieldError::Empty);
149 }
150 Ok(Self(value))
151 }
152}
153
154impl ReportPagePath {
155 pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
163 let value = value.into();
164 if value.is_empty() {
165 return Err(ReportFieldError::Empty);
166 }
167 if !has_html_or_xhtml_suffix(&value) {
168 return Err(ReportFieldError::InvalidPageSuffix);
169 }
170 Ok(Self(value))
171 }
172}
173
174impl ManifestPanelPath {
175 pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
183 let value = value.into();
184 if value.is_empty() {
185 return Err(ReportFieldError::Empty);
186 }
187 if !has_xhtml_suffix(&value) {
188 return Err(ReportFieldError::InvalidPanelSuffix);
189 }
190 Ok(Self(value))
191 }
192}
193
194fn has_html_or_xhtml_suffix(value: &str) -> bool {
195 let lower = value.to_ascii_lowercase();
196 lower.ends_with(".html") || lower.ends_with(".xhtml")
197}
198
199fn has_xhtml_suffix(value: &str) -> bool {
200 value.to_ascii_lowercase().ends_with(".xhtml")
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
208#[serde(transparent)]
209pub struct ReviewComments(String);
210
211impl ReviewComments {
212 pub fn new(value: impl Into<String>) -> Self {
216 Self(value.into())
217 }
218
219 pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
227 let value = value.into();
228 if value.chars().count() > MAX_REVIEW_COMMENTS_CHARS {
229 return Err(ReportFieldError::CommentsTooLong);
230 }
231 Ok(Self(value))
232 }
233
234 pub fn as_str(&self) -> &str {
236 &self.0
237 }
238
239 pub fn into_inner(self) -> String {
241 self.0
242 }
243}
244
245impl Deref for ReviewComments {
246 type Target = str;
247
248 fn deref(&self) -> &Self::Target {
249 &self.0
250 }
251}
252
253impl AsRef<str> for ReviewComments {
254 fn as_ref(&self) -> &str {
255 self.as_str()
256 }
257}
258
259impl fmt::Display for ReviewComments {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 self.0.fmt(f)
262 }
263}
264
265impl PartialEq<str> for ReviewComments {
266 fn eq(&self, other: &str) -> bool {
267 self.0 == other
268 }
269}
270
271impl PartialEq<&str> for ReviewComments {
272 fn eq(&self, other: &&str) -> bool {
273 self.0 == *other
274 }
275}
276
277impl<'de> Deserialize<'de> for ReviewComments {
278 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
279 let value = String::deserialize(deserializer)?;
280 Self::try_new(value).map_err(serde::de::Error::custom)
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
289#[serde(transparent)]
290pub struct PanelLabel(String);
291
292impl PanelLabel {
293 pub fn new(value: impl Into<String>) -> Self {
297 Self(value.into())
298 }
299
300 pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
307 let value = value.into();
308 if value.is_empty() {
309 return Err(ReportFieldError::Empty);
310 }
311 if value.chars().count() > MAX_PANEL_LABEL_CHARS {
312 return Err(ReportFieldError::LabelTooLong);
313 }
314 Ok(Self(value))
315 }
316
317 pub fn as_str(&self) -> &str {
319 &self.0
320 }
321
322 pub fn into_inner(self) -> String {
324 self.0
325 }
326}
327
328impl Deref for PanelLabel {
329 type Target = str;
330
331 fn deref(&self) -> &Self::Target {
332 &self.0
333 }
334}
335
336impl AsRef<str> for PanelLabel {
337 fn as_ref(&self) -> &str {
338 self.as_str()
339 }
340}
341
342impl fmt::Display for PanelLabel {
343 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344 self.0.fmt(f)
345 }
346}
347
348impl From<String> for PanelLabel {
349 fn from(value: String) -> Self {
350 Self::new(value)
351 }
352}
353
354impl From<&str> for PanelLabel {
355 fn from(value: &str) -> Self {
356 Self::new(value)
357 }
358}
359
360impl PartialEq<str> for PanelLabel {
361 fn eq(&self, other: &str) -> bool {
362 self.0 == other
363 }
364}
365
366impl PartialEq<&str> for PanelLabel {
367 fn eq(&self, other: &&str) -> bool {
368 self.0 == *other
369 }
370}
371
372impl<'de> Deserialize<'de> for PanelLabel {
373 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
374 let value = String::deserialize(deserializer)?;
375 Self::try_new(value).map_err(serde::de::Error::custom)
376 }
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(rename_all = "lowercase")]
382pub enum ReportMode {
383 View,
385 Review,
387}
388
389impl ReportMode {
390 pub fn parse(value: &str) -> Option<Self> {
392 match value {
393 "view" => Some(Self::View),
394 "review" => Some(Self::Review),
395 _ => None,
396 }
397 }
398
399 pub fn all_names() -> &'static [&'static str] {
401 &["view", "review"]
402 }
403
404 pub fn as_str(self) -> &'static str {
406 match self {
407 Self::View => "view",
408 Self::Review => "review",
409 }
410 }
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(rename_all = "lowercase")]
416pub enum PanelRole {
417 Failure,
419 Proposal,
421 Info,
423}
424
425impl PanelRole {
426 pub fn parse(value: &str) -> Option<Self> {
428 match value {
429 "failure" => Some(Self::Failure),
430 "proposal" => Some(Self::Proposal),
431 "info" => Some(Self::Info),
432 _ => None,
433 }
434 }
435
436 pub fn all_names() -> &'static [&'static str] {
438 &["failure", "proposal", "info"]
439 }
440
441 pub fn as_str(self) -> &'static str {
443 match self {
444 Self::Failure => "failure",
445 Self::Proposal => "proposal",
446 Self::Info => "info",
447 }
448 }
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453pub struct ReportPanelEntry {
454 pub path: ManifestPanelPath,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub label: Option<PanelLabel>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub role: Option<PanelRole>,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct ReportCommand {
467 pub title: ReportTitle,
469 pub page: ReportPagePath,
471 pub mode: ReportMode,
473 pub panels: Option<Vec<ReportPanelEntry>>,
475 pub width: Option<u32>,
477 pub height: Option<u32>,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(rename_all = "lowercase")]
484pub enum ReportTerminalButton {
485 Dismissed,
487 Finish,
489}
490
491impl ReportTerminalButton {
492 pub fn as_str(self) -> &'static str {
494 match self {
495 Self::Dismissed => "dismissed",
496 Self::Finish => "finish",
497 }
498 }
499}
500
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct ReportFinishData {
504 pub approved: bool,
506 pub comments: ReviewComments,
508 pub panels: Vec<ReportPanelEntry>,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct ReportResult {
515 pub button: ReportTerminalButton,
517 #[serde(skip_serializing_if = "Option::is_none")]
519 pub data: Option<ReportFinishData>,
520}
521
522impl ReportResult {
523 pub fn dismissed() -> Self {
525 Self {
526 button: ReportTerminalButton::Dismissed,
527 data: None,
528 }
529 }
530
531 pub fn finished(data: ReportFinishData) -> Self {
533 Self {
534 button: ReportTerminalButton::Finish,
535 data: Some(data),
536 }
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn review_comments_try_new_enforces_bound() {
546 assert_eq!(ReviewComments::try_new("").unwrap().as_str(), "");
547 assert_eq!(
548 ReviewComments::try_new("x".repeat(MAX_REVIEW_COMMENTS_CHARS + 1)),
549 Err(ReportFieldError::CommentsTooLong)
550 );
551 assert!(ReviewComments::try_new("x".repeat(MAX_REVIEW_COMMENTS_CHARS)).is_ok());
552 }
553
554 #[test]
555 fn panel_label_try_new_rejects_empty_and_enforces_bound() {
556 assert_eq!(PanelLabel::try_new(""), Err(ReportFieldError::Empty));
557 assert_eq!(
558 PanelLabel::try_new("x".repeat(MAX_PANEL_LABEL_CHARS + 1)),
559 Err(ReportFieldError::LabelTooLong)
560 );
561 assert_eq!(PanelLabel::try_new("Fail 1").unwrap().as_str(), "Fail 1");
562 assert!(PanelLabel::try_new("x".repeat(MAX_PANEL_LABEL_CHARS)).is_ok());
563 }
564
565 #[test]
566 fn report_title_try_new_rejects_empty() {
567 assert_eq!(ReportTitle::try_new(""), Err(ReportFieldError::Empty));
568 assert_eq!(ReportTitle::try_new("ok").unwrap().as_str(), "ok");
569 }
570
571 #[test]
572 fn report_page_path_try_new_rejects_empty_and_bad_suffix() {
573 assert_eq!(ReportPagePath::try_new(""), Err(ReportFieldError::Empty));
574 assert_eq!(
575 ReportPagePath::try_new("pages/view.txt"),
576 Err(ReportFieldError::InvalidPageSuffix)
577 );
578 assert_eq!(
579 ReportPagePath::try_new("pages/view.xhtml")
580 .unwrap()
581 .as_str(),
582 "pages/view.xhtml"
583 );
584 assert_eq!(
585 ReportPagePath::try_new("pages/view.HTML").unwrap().as_str(),
586 "pages/view.HTML"
587 );
588 }
589
590 #[test]
591 fn manifest_panel_path_try_new_requires_xhtml_suffix() {
592 assert_eq!(ManifestPanelPath::try_new(""), Err(ReportFieldError::Empty));
593 assert_eq!(
594 ManifestPanelPath::try_new("panels/fail.html"),
595 Err(ReportFieldError::InvalidPanelSuffix)
596 );
597 assert_eq!(
598 ManifestPanelPath::try_new("panels/fail-1.xhtml")
599 .unwrap()
600 .as_str(),
601 "panels/fail-1.xhtml"
602 );
603 assert_eq!(
604 ManifestPanelPath::try_new("panels/fail-1.XHTML")
605 .unwrap()
606 .as_str(),
607 "panels/fail-1.XHTML"
608 );
609 }
610
611 #[test]
612 fn report_mode_parse_round_trip() {
613 for (wire, expected) in [("view", ReportMode::View), ("review", ReportMode::Review)] {
614 assert_eq!(ReportMode::parse(wire), Some(expected));
615 assert_eq!(expected.as_str(), wire);
616 }
617 assert!(ReportMode::parse("wizard").is_none());
618 }
619
620 #[test]
621 fn report_result_dismissed_omits_data() {
622 let json = serde_json::to_string(&ReportResult::dismissed()).expect("serialize");
623 assert_eq!(json, r#"{"button":"dismissed"}"#);
624 }
625
626 #[test]
627 fn report_result_finished_includes_data() {
628 let result = ReportResult::finished(ReportFinishData {
629 approved: false,
630 comments: ReviewComments::new(""),
631 panels: vec![ReportPanelEntry {
632 path: ManifestPanelPath::new("panels/fail.xhtml"),
633 label: Some("Fail 1".into()),
634 role: Some(PanelRole::Failure),
635 }],
636 });
637 let value: serde_json::Value =
638 serde_json::from_str(&serde_json::to_string(&result).expect("serialize"))
639 .expect("json");
640 assert_eq!(value["button"], "finish");
641 assert_eq!(value["data"]["approved"], false);
642 assert_eq!(value["data"]["comments"], "");
643 assert_eq!(value["data"]["panels"][0]["path"], "panels/fail.xhtml");
644 }
645
646 #[test]
647 fn report_terminal_button_wire_names() {
648 assert_eq!(ReportTerminalButton::Dismissed.as_str(), "dismissed");
649 assert_eq!(ReportTerminalButton::Finish.as_str(), "finish");
650 }
651}