use serde::{Deserialize, Serialize};
pub const MAX_DEPTH: usize = 10;
pub const MAX_OPTIONS: usize = 1000;
pub const MAX_GROUPS: usize = 100;
pub const MAX_INPUT_BYTES: usize = 10 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeKind {
Command,
Argument,
Select,
Option,
Group,
Flag,
Input,
}
impl std::fmt::Display for NodeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
NodeKind::Command => "command",
NodeKind::Argument => "argument",
NodeKind::Select => "select",
NodeKind::Option => "option",
NodeKind::Group => "group",
NodeKind::Flag => "flag",
NodeKind::Input => "input",
};
write!(f, "{s}")
}
}
impl std::str::FromStr for NodeKind {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"command" => Ok(NodeKind::Command),
"argument" => Ok(NodeKind::Argument),
"select" => Ok(NodeKind::Select),
"option" => Ok(NodeKind::Option),
"group" => Ok(NodeKind::Group),
"flag" => Ok(NodeKind::Flag),
"input" => Ok(NodeKind::Input),
other => Err(anyhow::anyhow!("Unknown node type: '{other}'")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SelectRender {
#[default]
Auto,
Picklist,
Input,
}
impl std::str::FromStr for SelectRender {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"auto" => Ok(SelectRender::Auto),
"picklist" => Ok(SelectRender::Picklist),
"input" => Ok(SelectRender::Input),
other => Err(anyhow::anyhow!("Unknown render mode: '{other}'")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum InputType {
#[default]
String,
Number,
Email,
Path,
Regex {
pattern: String,
},
}
impl std::str::FromStr for InputType {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
if let Some(pattern) = s.strip_prefix("regex:") {
return Ok(InputType::Regex {
pattern: pattern.to_owned(),
});
}
match s {
"string" => Ok(InputType::String),
"number" => Ok(InputType::Number),
"email" => Ok(InputType::Email),
"path" => Ok(InputType::Path),
other => Err(anyhow::anyhow!("Unknown input type: '{other}'")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeDef {
pub kind: NodeKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub render: Option<SelectRender>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub multiple: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub default_selected: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub disabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub short: Option<char>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_type: Option<InputType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validate_regex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validate_min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validate_max: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_value: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sensitive: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<NodeDef>,
}
impl NodeDef {
pub fn new(kind: NodeKind) -> Self {
NodeDef {
kind,
name: None,
description: None,
message: None,
render: None,
multiple: false,
value: None,
label: None,
default_selected: false,
disabled: false,
short: None,
input_type: None,
validate_regex: None,
validate_min: None,
validate_max: None,
default_value: None,
sensitive: false,
children: Vec::new(),
}
}
pub fn display_name(&self) -> String {
self.name
.clone()
.or_else(|| self.label.clone())
.or_else(|| self.value.clone())
.unwrap_or_else(|| self.kind.to_string())
}
pub fn key_segment(&self, index: usize) -> String {
self.name.clone().unwrap_or_else(|| index.to_string())
}
pub fn direct_option_count(&self) -> usize {
self.children
.iter()
.filter(|c| c.kind == NodeKind::Option)
.count()
}
pub fn group_count(&self) -> usize {
self.children
.iter()
.filter(|c| c.kind == NodeKind::Group)
.count()
}
pub fn total_option_count(&self) -> usize {
self.children
.iter()
.map(|c| match c.kind {
NodeKind::Option => 1,
NodeKind::Group => c.direct_option_count(),
_ => 0,
})
.sum()
}
}
pub fn validate_parent_child(parent: &NodeKind, child: &NodeKind) -> anyhow::Result<()> {
use NodeKind::*;
let valid = match parent {
Command => matches!(child, Argument | Select | Flag | Input | Group),
Argument => matches!(child, Select | Flag | Input),
Select => matches!(child, Option | Group),
Group => matches!(child, Option),
Flag | Input | Option => false,
};
if !valid {
anyhow::bail!(crate::error::RoptError::InvalidNodeContext(format!(
"'{child}' cannot be a child of '{parent}'"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn node_kind_parses_all_valid_variants() {
let cases = [
("command", NodeKind::Command),
("argument", NodeKind::Argument),
("select", NodeKind::Select),
("option", NodeKind::Option),
("group", NodeKind::Group),
("flag", NodeKind::Flag),
("input", NodeKind::Input),
];
for (s, expected) in cases {
assert_eq!(s.parse::<NodeKind>().unwrap(), expected, "parsing '{s}'");
}
}
#[test]
fn node_kind_rejects_unknown_string() {
assert!("widget".parse::<NodeKind>().is_err());
}
#[test]
fn node_kind_display_round_trips() {
let variants = [
NodeKind::Command,
NodeKind::Argument,
NodeKind::Select,
NodeKind::Option,
NodeKind::Group,
NodeKind::Flag,
NodeKind::Input,
];
for kind in variants {
let s = kind.to_string();
assert_eq!(s.parse::<NodeKind>().unwrap(), kind);
}
}
#[test]
fn select_render_parses_all_valid_variants() {
assert_eq!("auto".parse::<SelectRender>().unwrap(), SelectRender::Auto);
assert_eq!(
"picklist".parse::<SelectRender>().unwrap(),
SelectRender::Picklist
);
assert_eq!(
"input".parse::<SelectRender>().unwrap(),
SelectRender::Input
);
}
#[test]
fn select_render_rejects_unknown_string() {
assert!("dropdown".parse::<SelectRender>().is_err());
}
#[test]
fn input_type_parses_plain_variants() {
assert!(matches!(
"string".parse::<InputType>().unwrap(),
InputType::String
));
assert!(matches!(
"number".parse::<InputType>().unwrap(),
InputType::Number
));
assert!(matches!(
"email".parse::<InputType>().unwrap(),
InputType::Email
));
assert!(matches!(
"path".parse::<InputType>().unwrap(),
InputType::Path
));
}
#[test]
fn input_type_parses_regex_prefix() {
let t = "regex:^[a-z]+$".parse::<InputType>().unwrap();
assert!(matches!(t, InputType::Regex { ref pattern } if pattern == "^[a-z]+$"));
}
#[test]
fn input_type_regex_with_empty_pattern() {
let t = "regex:".parse::<InputType>().unwrap();
assert!(matches!(t, InputType::Regex { ref pattern } if pattern.is_empty()));
}
#[test]
fn input_type_rejects_unknown_string() {
assert!("textarea".parse::<InputType>().is_err());
}
#[test]
fn node_key_segment_uses_name_over_index() {
let mut node = NodeDef::new(NodeKind::Select);
node.name = Some("myselect".into());
assert_eq!(node.key_segment(0), "myselect");
}
#[test]
fn node_key_segment_falls_back_to_index() {
let node = NodeDef::new(NodeKind::Select);
assert_eq!(node.key_segment(3), "3");
}
#[test]
fn display_name_prefers_name_over_all() {
let mut node = NodeDef::new(NodeKind::Option);
node.name = Some("myname".into());
node.label = Some("mylabel".into());
node.value = Some("myvalue".into());
assert_eq!(node.display_name(), "myname");
}
#[test]
fn display_name_falls_back_to_label() {
let mut node = NodeDef::new(NodeKind::Option);
node.label = Some("mylabel".into());
node.value = Some("myvalue".into());
assert_eq!(node.display_name(), "mylabel");
}
#[test]
fn display_name_falls_back_to_value() {
let mut node = NodeDef::new(NodeKind::Option);
node.value = Some("myvalue".into());
assert_eq!(node.display_name(), "myvalue");
}
#[test]
fn display_name_falls_back_to_kind_string() {
let node = NodeDef::new(NodeKind::Select);
assert_eq!(node.display_name(), "select");
}
fn make_option(value: &str) -> NodeDef {
let mut n = NodeDef::new(NodeKind::Option);
n.value = Some(value.into());
n
}
fn make_group(options: &[&str]) -> NodeDef {
let mut g = NodeDef::new(NodeKind::Group);
for v in options {
g.children.push(make_option(v));
}
g
}
#[test]
fn direct_option_count_ignores_groups() {
let mut select = NodeDef::new(NodeKind::Select);
select.children.push(make_option("a"));
select.children.push(make_option("b"));
select.children.push(make_group(&["c", "d"])); assert_eq!(select.direct_option_count(), 2);
}
#[test]
fn group_count_ignores_options() {
let mut select = NodeDef::new(NodeKind::Select);
select.children.push(make_option("a"));
select.children.push(make_group(&["b"]));
select.children.push(make_group(&["c"]));
assert_eq!(select.group_count(), 2);
}
#[test]
fn total_option_count_includes_options_inside_groups() {
let mut select = NodeDef::new(NodeKind::Select);
select.children.push(make_option("a")); select.children.push(make_group(&["b", "c"])); assert_eq!(select.total_option_count(), 3);
}
#[test]
fn total_option_count_with_no_children_is_zero() {
let select = NodeDef::new(NodeKind::Select);
assert_eq!(select.total_option_count(), 0);
}
#[test]
fn validate_parent_child_rejects_flag_under_select() {
assert!(validate_parent_child(&NodeKind::Select, &NodeKind::Flag).is_err());
}
#[test]
fn validate_parent_child_accepts_option_under_select() {
assert!(validate_parent_child(&NodeKind::Select, &NodeKind::Option).is_ok());
}
#[test]
fn validate_parent_child_accepts_group_under_select() {
assert!(validate_parent_child(&NodeKind::Select, &NodeKind::Group).is_ok());
}
#[test]
fn validate_parent_child_accepts_option_under_group() {
assert!(validate_parent_child(&NodeKind::Group, &NodeKind::Option).is_ok());
}
#[test]
fn validate_parent_child_rejects_select_under_group() {
assert!(validate_parent_child(&NodeKind::Group, &NodeKind::Select).is_err());
}
#[test]
fn validate_parent_child_accepts_valid_command_children() {
for child in [
NodeKind::Argument,
NodeKind::Select,
NodeKind::Flag,
NodeKind::Input,
] {
assert!(
validate_parent_child(&NodeKind::Command, &child).is_ok(),
"{child} should be valid under Command"
);
}
}
#[test]
fn validate_parent_child_rejects_command_under_command() {
assert!(validate_parent_child(&NodeKind::Command, &NodeKind::Command).is_err());
}
#[test]
fn leaf_nodes_reject_all_children() {
for parent in [NodeKind::Flag, NodeKind::Input, NodeKind::Option] {
for child in [
NodeKind::Select,
NodeKind::Flag,
NodeKind::Input,
NodeKind::Option,
] {
assert!(
validate_parent_child(&parent, &child).is_err(),
"{child} should be invalid under leaf node {parent}"
);
}
}
}
}