use std::fmt;
use std::ops::Deref;
use serde::{Deserialize, Serialize};
use crate::button::ButtonLabel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WizardPageFieldError;
impl fmt::Display for WizardPageFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("wizard page field must be a non-empty string")
}
}
impl std::error::Error for WizardPageFieldError {}
macro_rules! wizard_page_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 try_new(value: impl Into<String>) -> Result<Self, WizardPageFieldError> {
let value = value.into();
if value.is_empty() {
return Err(WizardPageFieldError);
}
Ok(Self(value))
}
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
}
}
};
}
wizard_page_newtype!(WizardPageId, "Validated wizard page id (non-empty).");
wizard_page_newtype!(WizardPageTitle, "Validated wizard page title (non-empty).");
wizard_page_newtype!(
WizardPageHtml,
"Validated wizard page HTML path relative to `--ui-root` (non-empty)."
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WizardPageLayout {
Dialog,
Workspace,
}
impl WizardPageLayout {
pub fn parse(value: &str) -> Option<Self> {
match value {
"dialog" => Some(Self::Dialog),
"workspace" => Some(Self::Workspace),
_ => None,
}
}
pub fn all_names() -> &'static [&'static str] {
&["dialog", "workspace"]
}
pub fn as_str(self) -> &'static str {
match self {
Self::Dialog => "dialog",
Self::Workspace => "workspace",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WizardPageDescriptor {
pub id: WizardPageId,
pub title: WizardPageTitle,
pub html: WizardPageHtml,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout: Option<WizardPageLayout>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WizardStackEntry {
pub page: WizardPageDescriptor,
pub data: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkflowPathError;
impl fmt::Display for WorkflowPathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("workflow path must be a non-empty string")
}
}
impl std::error::Error for WorkflowPathError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct WorkflowPath(String);
impl WorkflowPath {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn try_new(value: impl Into<String>) -> Result<Self, WorkflowPathError> {
let value = value.into();
if value.is_empty() {
return Err(WorkflowPathError);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Deref for WorkflowPath {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<str> for WorkflowPath {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for WorkflowPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl<'de> Deserialize<'de> for WorkflowPath {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Self::try_new(value).map_err(serde::de::Error::custom)
}
}
impl PartialEq<str> for WorkflowPath {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for WorkflowPath {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct WorkflowSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pre: Option<WorkflowPath>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post: Option<WorkflowPath>,
}
fn default_next_wizard_input() -> serde_json::Value {
serde_json::json!({})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NextWizard {
pub path: WorkflowPath,
#[serde(default = "default_next_wizard_input")]
pub input: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_root: Option<WorkflowPath>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WizardCommand {
pub page: WizardPageDescriptor,
pub config: serde_json::Value,
pub width: Option<u32>,
pub height: Option<u32>,
pub workflow: Option<WorkflowSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WizardResult {
pub button: ButtonLabel,
pub data: serde_json::Value,
pub stack: Vec<WizardStackEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_wizard: Option<NextWizard>,
}
impl WizardResult {
pub fn dismissed() -> Self {
Self {
button: ButtonLabel::dismissed(),
data: serde_json::json!({}),
stack: Vec::new(),
next_wizard: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WizardStateResponse {
#[serde(rename = "type")]
pub type_name: &'static str,
pub config: serde_json::Value,
pub page: WizardPageDescriptor,
pub page_data: serde_json::Value,
pub stack: Vec<WizardStackEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
}
impl WizardStateResponse {
pub fn from_snapshot(
config: serde_json::Value,
page: WizardPageDescriptor,
page_data: serde_json::Value,
stack: Vec<WizardStackEntry>,
width: Option<u32>,
height: Option<u32>,
) -> Self {
Self {
type_name: "wizard",
config,
page,
page_data,
stack,
width,
height,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WizardNavAction {
Next,
Back,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct WizardNavigateRequest {
pub action: WizardNavAction,
#[serde(default)]
pub data: serde_json::Value,
#[serde(default)]
pub page_id: Option<WizardPageId>,
#[serde(default)]
pub next: Option<WizardPageDescriptor>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WizardTerminalButton {
Finish,
Cancel,
Dismissed,
}
impl WizardTerminalButton {
pub fn as_str(self) -> &'static str {
match self {
Self::Finish => "finish",
Self::Cancel => "cancel",
Self::Dismissed => "dismissed",
}
}
pub fn to_button_label(self) -> ButtonLabel {
ButtonLabel::new(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WizardNavigateResponse {
pub ok: bool,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct WizardFinishRequest {
pub button: WizardTerminalButton,
pub data: serde_json::Value,
pub stack: Vec<WizardStackEntry>,
#[serde(default)]
pub next_wizard: Option<NextWizard>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wizard_state_response_wire_shape_first_page() {
let resp = WizardStateResponse::from_snapshot(
serde_json::json!({"theme": "dark"}),
WizardPageDescriptor {
id: WizardPageId::new("start"),
title: WizardPageTitle::new("Start"),
html: WizardPageHtml::new("pages/start.html"),
layout: None,
},
serde_json::json!({}),
Vec::new(),
Some(640),
Some(480),
);
let value = serde_json::to_value(&resp).expect("serialize");
assert_eq!(value["type"], "wizard");
assert_eq!(value["config"]["theme"], "dark");
assert_eq!(value["page"]["id"], "start");
assert_eq!(value["page_data"], serde_json::json!({}));
assert_eq!(value["stack"], serde_json::json!([]));
assert_eq!(value["width"], 640);
assert_eq!(value["height"], 480);
}
#[test]
fn page_layout_omitted_when_none() {
let page = WizardPageDescriptor {
id: WizardPageId::new("a"),
title: WizardPageTitle::new("A"),
html: WizardPageHtml::new("a.html"),
layout: None,
};
let value = serde_json::to_value(&page).expect("serialize");
assert!(value.get("layout").is_none());
}
#[test]
fn page_id_try_new_rejects_empty() {
assert_eq!(WizardPageId::try_new(""), Err(WizardPageFieldError));
assert_eq!(WizardPageId::try_new("ok").unwrap().as_str(), "ok");
}
#[test]
fn navigate_request_deserializes_typed_page_id() {
let req: WizardNavigateRequest = serde_json::from_value(serde_json::json!({
"action": "next",
"data": {},
"page_id": "step-2",
"next": {
"id": "step-2",
"title": "Step 2",
"html": "pages/step-2.html"
}
}))
.expect("deserialize");
assert_eq!(
req.page_id.as_ref().map(WizardPageId::as_str),
Some("step-2")
);
assert_eq!(req.next.as_ref().unwrap().id.as_str(), "step-2");
}
#[test]
fn next_wizard_input_defaults_to_empty_object() {
let parsed: NextWizard = serde_json::from_value(serde_json::json!({
"path": "{wyvern_share}/welcome/wizard.json"
}))
.expect("deserialize");
assert_eq!(parsed.path.as_str(), "{wyvern_share}/welcome/wizard.json");
assert_eq!(parsed.input, serde_json::json!({}));
assert!(parsed.ui_root.is_none());
}
#[test]
fn workflow_path_try_new_rejects_empty() {
assert_eq!(WorkflowPath::try_new(""), Err(WorkflowPathError));
assert_eq!(
WorkflowPath::try_new("{wyvern_share}/welcome/wizard.json")
.unwrap()
.as_str(),
"{wyvern_share}/welcome/wizard.json"
);
let err = serde_json::from_value::<WorkflowPath>(serde_json::json!(""));
assert!(err.is_err(), "empty path must fail deserialize");
}
#[test]
fn terminal_button_wire_round_trip() {
for (wire, expected) in [
("finish", WizardTerminalButton::Finish),
("cancel", WizardTerminalButton::Cancel),
("dismissed", WizardTerminalButton::Dismissed),
] {
let parsed: WizardTerminalButton =
serde_json::from_value(serde_json::json!(wire)).expect("parse");
assert_eq!(parsed, expected);
assert_eq!(parsed.as_str(), wire);
assert_eq!(parsed.to_button_label().as_str(), wire);
}
}
}