use content_addressable::{canonical, ContentAddressable, ContentError};
use serde::{Deserialize, Serialize};
use crate::error::ProtocolError;
use crate::ids::{ControlId, DefinitionId, OptionId, Revision};
pub const DEFINITION_SCHEMA_V1: &str = "newt.interaction.definition/v1";
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum InteractionKind {
Choice,
Prompt,
Confirm,
Form,
Notice,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum SemanticRole {
Allow,
Deny,
Cancel,
Exit,
Value,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Requirement {
Required,
Optional,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChoiceOption {
pub id: OptionId,
pub role: SemanticRole,
pub label: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub key: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum TemporalPrecision {
Date,
Time,
DateTime,
Month,
Week,
}
impl TemporalPrecision {
#[must_use]
pub fn pattern(self) -> &'static str {
match self {
Self::Date => "YYYY-MM-DD",
Self::Time => "HH:MM[:SS]",
Self::DateTime => "YYYY-MM-DDTHH:MM[:SS]",
Self::Month => "YYYY-MM",
Self::Week => "YYYY-Www",
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum PathKind {
File,
Directory,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[non_exhaustive]
pub enum ControlKind {
Choice {
options: Vec<ChoiceOption>,
},
Text,
Toggle,
Secret,
Number {
#[serde(default, skip_serializing_if = "Option::is_none")]
min: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
step: Option<i64>,
},
Range {
min: i64,
max: i64,
step: i64,
},
Temporal {
precision: TemporalPrecision,
},
Color,
Path {
kind: PathKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
accept: Vec<String>,
},
}
impl ControlKind {
pub fn check_text(&self, text: &str) -> Result<(), String> {
let text = text.trim();
match self {
Self::Choice { .. } | Self::Toggle | Self::Secret => Ok(()),
Self::Text => Ok(()),
Self::Number { min, max, step } => {
check_integer(text, *min, *max, *step, &describe_number(*min, *max, *step))
}
Self::Range { min, max, step } => check_integer(
text,
Some(*min),
Some(*max),
Some(*step),
&describe_number(Some(*min), Some(*max), Some(*step)),
),
Self::Temporal { precision } => check_temporal(text, *precision),
Self::Color => check_color(text),
Self::Path { .. } => (!text.is_empty())
.then_some(())
.ok_or_else(|| "a path".to_string()),
}
}
#[must_use]
pub fn hint(&self) -> String {
match self {
Self::Choice { .. } => String::new(),
Self::Text => String::new(),
Self::Toggle => " [y/n]".to_string(),
Self::Secret => " (secret, not echoed)".to_string(),
Self::Number { min, max, step } => match (min, max) {
(None, None) => " [number]".to_string(),
_ => format!(" [{}]", describe_number(*min, *max, *step)),
},
Self::Range { min, max, .. } => format!(" [{min}..={max}]"),
Self::Temporal { precision } => format!(" [{}]", precision.pattern()),
Self::Color => " [#rrggbb]".to_string(),
Self::Path { kind, .. } => match kind {
PathKind::File => " [file path]".to_string(),
PathKind::Directory => " [directory path]".to_string(),
},
}
}
#[must_use]
pub fn travels_as_text(&self) -> bool {
matches!(
self,
Self::Text
| Self::Number { .. }
| Self::Range { .. }
| Self::Temporal { .. }
| Self::Color
| Self::Path { .. }
)
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Control {
pub id: ControlId,
pub kind: ControlKind,
pub label: String,
pub requirement: Requirement,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct SurfaceFeature(String);
#[cfg(feature = "schema")]
crate::string_scalar_schema!(SurfaceFeature, None::<&str>);
impl From<SurfaceFeature> for String {
fn from(value: SurfaceFeature) -> Self {
value.0
}
}
impl TryFrom<String> for SurfaceFeature {
type Error = ProtocolError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl SurfaceFeature {
pub const SECRET_INPUT: &'static str = "secret-input";
pub const DIAGRAMS: &'static str = "diagrams";
pub const MULTI_CONTROL: &'static str = "multi-control";
pub const KNOWN: &'static [&'static str] =
&[Self::SECRET_INPUT, Self::DIAGRAMS, Self::MULTI_CONTROL];
pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
let name = name.into();
if name.is_empty() {
return Err(ProtocolError::InvalidId {
kind: "surface feature",
reason: "must not be empty".to_string(),
});
}
Ok(Self(name))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_known(&self) -> bool {
Self::KNOWN.contains(&self.0.as_str())
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureDemand {
pub feature: SurfaceFeature,
pub requirement: Requirement,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InteractionDefinition {
pub schema: crate::tag::DefinitionTag,
pub kind: InteractionKind,
pub revision: Revision,
pub markdown: String,
pub controls: Vec<Control>,
pub features: Vec<FeatureDemand>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[must_use]
pub fn controls_are_decision_shaped(controls: &[Control]) -> bool {
let [control] = controls else {
return false;
};
let ControlKind::Choice { options } = &control.kind else {
return false;
};
let [a, b] = options.as_slice() else {
return false;
};
let decides = |r: SemanticRole| matches!(r, SemanticRole::Deny | SemanticRole::Cancel);
(a.role == SemanticRole::Allow && decides(b.role))
|| (b.role == SemanticRole::Allow && decides(a.role))
}
impl InteractionDefinition {
#[must_use]
pub fn is_decision_shaped(&self) -> bool {
controls_are_decision_shaped(&self.controls)
}
#[must_use]
fn is_lone_toggle(&self) -> bool {
matches!(self.controls.as_slice(), [c] if matches!(c.kind, ControlKind::Toggle))
}
#[must_use]
pub fn new(kind: InteractionKind, markdown: impl Into<String>, controls: Vec<Control>) -> Self {
let built = Self {
schema: crate::tag::DefinitionTag,
kind,
revision: Revision::FIRST,
markdown: markdown.into(),
controls,
features: Vec::new(),
note: None,
};
debug_assert!(
!built.is_decision_shaped() || kind == InteractionKind::Confirm,
"a binary decision — one Toggle, or one Choice control offering \
two options where one grants and the other refuses — is \
InteractionKind::Confirm. See that variant's doc. Got kind \
{kind:?} for controls {:?}",
built.controls
);
debug_assert!(
kind != InteractionKind::Confirm
|| built.controls.is_empty()
|| built.is_decision_shaped()
|| built.is_lone_toggle(),
"InteractionKind::Confirm carries a binary decision (a Toggle, or \
two options one of which grants) or no controls at all; got \
controls {:?}",
built.controls
);
built
}
pub fn definition_id(&self) -> Result<DefinitionId, ProtocolError> {
Ok(DefinitionId::from_content_id(self.content_id()?))
}
#[must_use]
pub fn schema_tag(&self) -> &'static str {
self.schema.as_str()
}
}
impl ContentAddressable for InteractionDefinition {
fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
canonical::to_canonical_dagcbor(self)
}
}
fn describe_number(min: Option<i64>, max: Option<i64>, step: Option<i64>) -> String {
let range = match (min, max) {
(Some(min), Some(max)) => format!("an integer in {min}..={max}"),
(Some(min), None) => format!("an integer {min} or greater"),
(None, Some(max)) => format!("an integer {max} or less"),
(None, None) => "an integer".to_string(),
};
match step.filter(|s| *s > 1) {
Some(step) => format!("{range}, in steps of {step}"),
None => range,
}
}
fn check_integer(
text: &str,
min: Option<i64>,
max: Option<i64>,
step: Option<i64>,
expected: &str,
) -> Result<(), String> {
let value: i64 = text.parse().map_err(|_| expected.to_string())?;
if min.is_some_and(|min| value < min) || max.is_some_and(|max| value > max) {
return Err(expected.to_string());
}
if let Some(step) = step.filter(|s| *s > 1) {
let base = i128::from(min.unwrap_or(0));
let offset = i128::from(value) - base;
if offset.rem_euclid(i128::from(step)) != 0 {
return Err(expected.to_string());
}
}
Ok(())
}
fn check_color(text: &str) -> Result<(), String> {
let expected = || "#rrggbb".to_string();
let hex = text.strip_prefix('#').ok_or_else(expected)?;
(hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()))
.then_some(())
.ok_or_else(expected)
}
fn check_temporal(text: &str, precision: TemporalPrecision) -> Result<(), String> {
let expected = || precision.pattern().to_string();
let (date, time) = match precision {
TemporalPrecision::DateTime => {
let (d, t) = text.split_once('T').ok_or_else(expected)?;
(Some(d), Some(t))
}
TemporalPrecision::Time => (None, Some(text)),
_ => (Some(text), None),
};
if let Some(date) = date {
let mut parts = date.split('-');
let year = parts.next().ok_or_else(expected)?;
if year.len() != 4 || !year.bytes().all(|b| b.is_ascii_digit()) {
return Err(expected());
}
match precision {
TemporalPrecision::Week => {
let week = parts.next().ok_or_else(expected)?;
let n = week.strip_prefix('W').ok_or_else(expected)?;
number_in(n, 2, 1, 53).ok_or_else(expected)?;
}
TemporalPrecision::Month => {
number_in(parts.next().ok_or_else(expected)?, 2, 1, 12).ok_or_else(expected)?;
}
_ => {
number_in(parts.next().ok_or_else(expected)?, 2, 1, 12).ok_or_else(expected)?;
number_in(parts.next().ok_or_else(expected)?, 2, 1, 31).ok_or_else(expected)?;
}
}
if parts.next().is_some() {
return Err(expected());
}
}
if let Some(time) = time {
let mut parts = time.split(':');
number_in(parts.next().ok_or_else(expected)?, 2, 0, 23).ok_or_else(expected)?;
number_in(parts.next().ok_or_else(expected)?, 2, 0, 59).ok_or_else(expected)?;
if let Some(seconds) = parts.next() {
number_in(seconds, 2, 0, 60).ok_or_else(expected)?;
}
if parts.next().is_some() {
return Err(expected());
}
}
Ok(())
}
fn number_in(field: &str, width: usize, lo: u32, hi: u32) -> Option<u32> {
if field.len() != width || !field.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
field.parse::<u32>().ok().filter(|n| (lo..=hi).contains(n))
}