use sofie_core::{Constraint, Instant};
use crate::{Currency, Email, Money, Secret};
pub trait Present {
const INPUT: Input;
const SEARCH: Search;
fn csv(&self) -> String;
#[cfg(feature = "web")]
fn write_localized(&self, out: &mut String, _conventions: &dyn Conventions) {
out.push_str(&self.csv());
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Search {
Text,
Exact,
Number,
Time,
Skipped,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Control {
Input,
Password,
Textarea,
Checkbox,
Select,
Money,
Percentage,
Parts(&'static [Part]),
File,
}
#[must_use]
pub fn percent_text(basis_points: i64) -> String {
crate::decimal::scaled_text(basis_points, 2)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Part {
pub name: &'static str,
pub attributes: &'static [(&'static str, &'static str)],
pub options: &'static [&'static str],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Input {
pub control: Control,
pub attributes: &'static [(&'static str, &'static str)],
pub options: &'static [&'static str],
}
impl Input {
#[must_use]
pub const fn new(control: Control) -> Self {
Self {
control,
attributes: &[],
options: &[],
}
}
#[must_use]
pub const fn typed(attributes: &'static [(&'static str, &'static str)]) -> Self {
Self {
attributes,
..Self::new(Control::Input)
}
}
#[must_use]
pub const fn select(options: &'static [&'static str]) -> Self {
Self {
options,
..Self::new(Control::Select)
}
}
}
#[cfg(feature = "web")]
pub trait Conventions {
fn write_number(&self, out: &mut String, plain: &str);
fn write_money(&self, out: &mut String, amount: Money);
fn write_date(&self, out: &mut String, iso: &str);
fn write_variant(&self, out: &mut String, enumeration: &str, variant: &str);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FormField {
pub name: &'static str,
pub input: Input,
pub required: bool,
pub constraints: &'static [Constraint],
}
pub trait FormFields {
const FIELDS: &'static [FormField];
}
pub(crate) const TEXT: Input = Input::typed(&[("type", "text")]);
pub(crate) const INTEGER: Input = Input::typed(&[("type", "number"), ("step", "1")]);
impl Present for bool {
const INPUT: Input = Input::new(Control::Checkbox);
const SEARCH: Search = Search::Exact;
fn csv(&self) -> String {
self.to_string()
}
}
macro_rules! present_integer {
($($integer:ty),+) => {$(
impl Present for $integer {
const INPUT: Input = INTEGER;
const SEARCH: Search = Search::Number;
fn csv(&self) -> String {
self.to_string()
}
#[cfg(feature = "web")]
fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
conventions.write_number(out, &self.to_string());
}
}
)+};
}
present_integer!(i16, i32, i64, u16, u32);
impl<T: Present> Present for Option<T> {
const INPUT: Input = T::INPUT;
const SEARCH: Search = T::SEARCH;
fn csv(&self) -> String {
self.as_ref().map(Present::csv).unwrap_or_default()
}
#[cfg(feature = "web")]
fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
if let Some(value) = self {
value.write_localized(out, conventions);
}
}
}
impl Present for Money {
const INPUT: Input = Input::new(Control::Money);
const SEARCH: Search = Search::Number;
fn csv(&self) -> String {
format!("{} {}", self.format_major(), self.currency())
}
#[cfg(feature = "web")]
fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
conventions.write_money(out, *self);
}
}
impl<T> Present for Secret<T> {
const INPUT: Input = Input::new(Control::Password);
const SEARCH: Search = Search::Skipped;
fn csv(&self) -> String {
"***".to_owned()
}
}
impl Present for Instant {
const INPUT: Input = Input::typed(&[("type", "datetime-local")]);
const SEARCH: Search = Search::Time;
fn csv(&self) -> String {
self.to_string()
}
#[cfg(feature = "web")]
fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
conventions.write_date(out, &self.to_string());
}
}
pub trait SurfacesFromInner: sofie_core::Value<Inner: Present> {
const SENSITIVE: bool = false;
}
impl<V: SurfacesFromInner> Present for V {
const INPUT: Input = if <V as crate::SurfacesFromInner>::SENSITIVE {
Input::new(Control::Password)
} else {
V::Inner::INPUT
};
const SEARCH: Search = if <V as crate::SurfacesFromInner>::SENSITIVE {
Search::Skipped
} else {
V::Inner::SEARCH
};
fn csv(&self) -> String {
self.inner().csv()
}
#[cfg(feature = "web")]
fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
self.inner().write_localized(out, conventions);
}
}
macro_rules! present_text {
($value:ty, $input:expr, $search:ident) => {
$crate::present::present_text!($value, $input, $search, |value| value.as_str().to_owned());
};
($value:ty, $input:expr, $search:ident, |$self_:ident| $text:expr) => {
impl $crate::Present for $value {
const INPUT: $crate::Input = $input;
const SEARCH: $crate::Search = $crate::Search::$search;
fn csv(&self) -> String {
let $self_ = self;
$text
}
}
};
}
pub(crate) use present_text;
present_text!(
Currency,
Input::typed(&[("type", "text"), ("maxlength", "3")]),
Exact
);
present_text!(
Email,
Input::typed(&[("type", "email"), ("autocomplete", "email")]),
Exact
);
present_text!(String, TEXT, Text);
present_text!(
sofie_core::Uuid,
Input::typed(&[("type", "hidden")]),
Exact,
|id| id.to_string()
);
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::euros;
#[cfg(feature = "web")]
struct Marked;
#[cfg(feature = "web")]
impl Conventions for Marked {
fn write_number(&self, out: &mut String, plain: &str) {
out.push_str("number:");
out.push_str(plain);
}
fn write_money(&self, out: &mut String, amount: Money) {
out.push_str("money:");
out.push_str(&amount.csv());
}
fn write_date(&self, out: &mut String, iso: &str) {
out.push_str("date:");
out.push_str(iso);
}
fn write_variant(&self, out: &mut String, enumeration: &str, variant: &str) {
out.push_str(enumeration);
out.push(':');
out.push_str(variant);
}
}
#[cfg(feature = "web")]
fn localized(value: &impl Present) -> String {
let mut out = String::new();
value.write_localized(&mut out, &Marked);
out
}
#[test]
fn money_is_typed_shown_and_exported_as_an_amount() {
assert_eq!(Money::INPUT.control, Control::Money);
assert_eq!(euros(14990).csv(), "149.90 EUR");
#[cfg(feature = "web")]
assert_eq!(localized(&euros(14990)), "money:149.90 EUR");
}
#[test]
fn an_option_is_presented_like_its_value() {
assert_eq!(<Option<Money>>::INPUT, Money::INPUT);
assert_eq!(None::<i64>.csv(), "");
#[cfg(feature = "web")]
assert_eq!(localized(&Some(7_i64)), "number:7");
}
#[test]
fn a_secret_never_shows_its_value() {
let secret = Secret::new("hunter22".to_owned());
assert_eq!(<Secret<String>>::INPUT.control, Control::Password);
assert_eq!(secret.csv(), "***");
#[cfg(feature = "web")]
assert_eq!(localized(&secret), "***");
}
#[test]
fn text_values_are_typed_by_their_attributes() {
assert_eq!(
Email::INPUT.attributes,
&[("type", "email"), ("autocomplete", "email")]
);
assert_eq!(bool::INPUT.control, Control::Checkbox);
let email = Email::parse("Jane@Example.com").unwrap();
assert_eq!(email.csv(), "jane@example.com");
}
#[derive(Debug)]
struct Quantity(i64);
impl sofie_core::Value for Quantity {
type Inner = i64;
fn inner(&self) -> &i64 {
&self.0
}
fn try_from_inner(inner: i64) -> Result<Self, impl std::fmt::Display> {
Ok::<_, std::convert::Infallible>(Self(inner))
}
}
impl SurfacesFromInner for Quantity {}
#[test]
fn search_reads_words_of_text_and_never_a_secret() {
assert_eq!(String::SEARCH, Search::Text);
assert_eq!(Email::SEARCH, Search::Exact);
assert_eq!(<Option<Money>>::SEARCH, Search::Number);
assert_eq!(Instant::SEARCH, Search::Time);
assert_eq!(<Secret<String>>::SEARCH, Search::Skipped);
assert_eq!(Quantity::SEARCH, Search::Number);
}
#[test]
fn a_derived_value_is_presented_as_the_value_it_wraps() {
assert_eq!(Quantity::INPUT, i64::INPUT);
#[cfg(feature = "web")]
assert_eq!(localized(&Quantity(3)), "number:3");
}
#[cfg(all(feature = "web", feature = "time"))]
#[test]
fn a_time_formats_as_a_date_and_a_weekday_as_its_enum_s_label() {
assert_eq!(
localized(&crate::TimeOfDay::constant("09:30")),
"date:09:30"
);
assert_eq!(localized(&crate::Weekday::Monday), "weekday:MONDAY");
}
#[cfg(feature = "web")]
#[test]
fn instants_format_as_dates() {
let instant = Instant::parse("2026-10-11T09:00:00+03:00").unwrap();
assert_eq!(localized(&instant), "date:2026-10-11T06:00:00Z");
}
}