use std::fmt;
use std::ops::Deref;
use serde::{Deserialize, Deserializer, Serialize};
pub const MAX_REVIEW_COMMENTS_CHARS: usize = 32_768;
pub const MAX_REPORT_PANELS: usize = 32;
pub const MAX_PANEL_LABEL_CHARS: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReportFieldError {
Empty,
InvalidPageSuffix,
InvalidPanelSuffix,
CommentsTooLong,
LabelTooLong,
}
impl fmt::Display for ReportFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("report field must be a non-empty string"),
Self::InvalidPageSuffix => {
f.write_str("report page path must end with .html or .xhtml")
}
Self::InvalidPanelSuffix => f.write_str("manifest panel path must end with .xhtml"),
Self::CommentsTooLong => write!(
f,
"review comments must be at most {MAX_REVIEW_COMMENTS_CHARS} characters"
),
Self::LabelTooLong => write!(
f,
"panel label must be at most {MAX_PANEL_LABEL_CHARS} characters"
),
}
}
}
impl std::error::Error for ReportFieldError {}
macro_rules! report_newtype {
($(#[$meta:meta])* $name:ident, $doc:literal) => {
$(#[$meta])*
#[doc = $doc]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Deref for $name {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl PartialEq<str> for $name {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for $name {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
};
}
report_newtype!(
ReportPagePath,
"Validated report page path relative to `--ui-root` (`.html` or `.xhtml`)."
);
report_newtype!(ReportTitle, "Validated report window title (non-empty).");
report_newtype!(
ManifestPanelPath,
"Validated manifest panel path (non-empty `.xhtml` relative path)."
);
impl ReportTitle {
pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
let value = value.into();
if value.is_empty() {
return Err(ReportFieldError::Empty);
}
Ok(Self(value))
}
}
impl ReportPagePath {
pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
let value = value.into();
if value.is_empty() {
return Err(ReportFieldError::Empty);
}
if !has_html_or_xhtml_suffix(&value) {
return Err(ReportFieldError::InvalidPageSuffix);
}
Ok(Self(value))
}
}
impl ManifestPanelPath {
pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
let value = value.into();
if value.is_empty() {
return Err(ReportFieldError::Empty);
}
if !has_xhtml_suffix(&value) {
return Err(ReportFieldError::InvalidPanelSuffix);
}
Ok(Self(value))
}
}
fn has_html_or_xhtml_suffix(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.ends_with(".html") || lower.ends_with(".xhtml")
}
fn has_xhtml_suffix(value: &str) -> bool {
value.to_ascii_lowercase().ends_with(".xhtml")
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct ReviewComments(String);
impl ReviewComments {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
let value = value.into();
if value.chars().count() > MAX_REVIEW_COMMENTS_CHARS {
return Err(ReportFieldError::CommentsTooLong);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Deref for ReviewComments {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<str> for ReviewComments {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for ReviewComments {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl PartialEq<str> for ReviewComments {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for ReviewComments {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl<'de> Deserialize<'de> for ReviewComments {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Self::try_new(value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct PanelLabel(String);
impl PanelLabel {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn try_new(value: impl Into<String>) -> Result<Self, ReportFieldError> {
let value = value.into();
if value.is_empty() {
return Err(ReportFieldError::Empty);
}
if value.chars().count() > MAX_PANEL_LABEL_CHARS {
return Err(ReportFieldError::LabelTooLong);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Deref for PanelLabel {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<str> for PanelLabel {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for PanelLabel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl From<String> for PanelLabel {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for PanelLabel {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl PartialEq<str> for PanelLabel {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for PanelLabel {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl<'de> Deserialize<'de> for PanelLabel {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Self::try_new(value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReportMode {
View,
Review,
}
impl ReportMode {
pub fn parse(value: &str) -> Option<Self> {
match value {
"view" => Some(Self::View),
"review" => Some(Self::Review),
_ => None,
}
}
pub fn all_names() -> &'static [&'static str] {
&["view", "review"]
}
pub fn as_str(self) -> &'static str {
match self {
Self::View => "view",
Self::Review => "review",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PanelRole {
Failure,
Proposal,
Info,
}
impl PanelRole {
pub fn parse(value: &str) -> Option<Self> {
match value {
"failure" => Some(Self::Failure),
"proposal" => Some(Self::Proposal),
"info" => Some(Self::Info),
_ => None,
}
}
pub fn all_names() -> &'static [&'static str] {
&["failure", "proposal", "info"]
}
pub fn as_str(self) -> &'static str {
match self {
Self::Failure => "failure",
Self::Proposal => "proposal",
Self::Info => "info",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReportPanelEntry {
pub path: ManifestPanelPath,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<PanelLabel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<PanelRole>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReportCommand {
pub title: ReportTitle,
pub page: ReportPagePath,
pub mode: ReportMode,
pub panels: Option<Vec<ReportPanelEntry>>,
pub width: Option<u32>,
pub height: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReportTerminalButton {
Dismissed,
Finish,
}
impl ReportTerminalButton {
pub fn as_str(self) -> &'static str {
match self {
Self::Dismissed => "dismissed",
Self::Finish => "finish",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReportFinishData {
pub approved: bool,
pub comments: ReviewComments,
pub panels: Vec<ReportPanelEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReportResult {
pub button: ReportTerminalButton,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<ReportFinishData>,
}
impl ReportResult {
pub fn dismissed() -> Self {
Self {
button: ReportTerminalButton::Dismissed,
data: None,
}
}
pub fn finished(data: ReportFinishData) -> Self {
Self {
button: ReportTerminalButton::Finish,
data: Some(data),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn review_comments_try_new_enforces_bound() {
assert_eq!(ReviewComments::try_new("").unwrap().as_str(), "");
assert_eq!(
ReviewComments::try_new("x".repeat(MAX_REVIEW_COMMENTS_CHARS + 1)),
Err(ReportFieldError::CommentsTooLong)
);
assert!(ReviewComments::try_new("x".repeat(MAX_REVIEW_COMMENTS_CHARS)).is_ok());
}
#[test]
fn panel_label_try_new_rejects_empty_and_enforces_bound() {
assert_eq!(PanelLabel::try_new(""), Err(ReportFieldError::Empty));
assert_eq!(
PanelLabel::try_new("x".repeat(MAX_PANEL_LABEL_CHARS + 1)),
Err(ReportFieldError::LabelTooLong)
);
assert_eq!(PanelLabel::try_new("Fail 1").unwrap().as_str(), "Fail 1");
assert!(PanelLabel::try_new("x".repeat(MAX_PANEL_LABEL_CHARS)).is_ok());
}
#[test]
fn report_title_try_new_rejects_empty() {
assert_eq!(ReportTitle::try_new(""), Err(ReportFieldError::Empty));
assert_eq!(ReportTitle::try_new("ok").unwrap().as_str(), "ok");
}
#[test]
fn report_page_path_try_new_rejects_empty_and_bad_suffix() {
assert_eq!(ReportPagePath::try_new(""), Err(ReportFieldError::Empty));
assert_eq!(
ReportPagePath::try_new("pages/view.txt"),
Err(ReportFieldError::InvalidPageSuffix)
);
assert_eq!(
ReportPagePath::try_new("pages/view.xhtml")
.unwrap()
.as_str(),
"pages/view.xhtml"
);
assert_eq!(
ReportPagePath::try_new("pages/view.HTML").unwrap().as_str(),
"pages/view.HTML"
);
}
#[test]
fn manifest_panel_path_try_new_requires_xhtml_suffix() {
assert_eq!(ManifestPanelPath::try_new(""), Err(ReportFieldError::Empty));
assert_eq!(
ManifestPanelPath::try_new("panels/fail.html"),
Err(ReportFieldError::InvalidPanelSuffix)
);
assert_eq!(
ManifestPanelPath::try_new("panels/fail-1.xhtml")
.unwrap()
.as_str(),
"panels/fail-1.xhtml"
);
assert_eq!(
ManifestPanelPath::try_new("panels/fail-1.XHTML")
.unwrap()
.as_str(),
"panels/fail-1.XHTML"
);
}
#[test]
fn report_mode_parse_round_trip() {
for (wire, expected) in [("view", ReportMode::View), ("review", ReportMode::Review)] {
assert_eq!(ReportMode::parse(wire), Some(expected));
assert_eq!(expected.as_str(), wire);
}
assert!(ReportMode::parse("wizard").is_none());
}
#[test]
fn report_result_dismissed_omits_data() {
let json = serde_json::to_string(&ReportResult::dismissed()).expect("serialize");
assert_eq!(json, r#"{"button":"dismissed"}"#);
}
#[test]
fn report_result_finished_includes_data() {
let result = ReportResult::finished(ReportFinishData {
approved: false,
comments: ReviewComments::new(""),
panels: vec![ReportPanelEntry {
path: ManifestPanelPath::new("panels/fail.xhtml"),
label: Some("Fail 1".into()),
role: Some(PanelRole::Failure),
}],
});
let value: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&result).expect("serialize"))
.expect("json");
assert_eq!(value["button"], "finish");
assert_eq!(value["data"]["approved"], false);
assert_eq!(value["data"]["comments"], "");
assert_eq!(value["data"]["panels"][0]["path"], "panels/fail.xhtml");
}
#[test]
fn report_terminal_button_wire_names() {
assert_eq!(ReportTerminalButton::Dismissed.as_str(), "dismissed");
assert_eq!(ReportTerminalButton::Finish.as_str(), "finish");
}
}