//! The types a value is stored as, and how each is shown: as JSON (every type has a //! JSON form), and natively, as a media type of its own, where it is a scalar or a text //! (`docs/api.md`, Types). use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use serde_json::Value as JsonValue; use automerge::ScalarValue; /// What a value is stored as. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Kind { Map, List, /// An Automerge text (characters that merge one by one), shown as a string. Text, String, Bytes, Int, Uint, Float, Boolean, Null, Counter, Timestamp, /// A scalar of a later Automerge version. Unknown, } pub(crate) const OCTET_STREAM: &str = "application/octet-stream"; pub(crate) const TEXT: &str = "text/plain"; pub(crate) const INT: &str = "application/vnd.continuum.int"; pub(crate) const UINT: &str = "application/vnd.continuum.uint"; pub(crate) const FLOAT: &str = "application/vnd.continuum.float"; pub(crate) const BOOLEAN: &str = "application/vnd.continuum.boolean"; pub(crate) const COUNTER: &str = "application/vnd.continuum.counter"; pub(crate) const TIMESTAMP: &str = "application/vnd.continuum.timestamp"; /// JSON whose scalars keep their types (`Val::from_typed_json`, `scalar_to_json`). pub(crate) const TYPED_JSON: &str = "application/vnd.continuum.typed+json"; impl Kind { pub(crate) fn of(scalar: &ScalarValue) -> Self { match scalar { ScalarValue::Str(_) => Kind::String, ScalarValue::Bytes(_) => Kind::Bytes, ScalarValue::Int(_) => Kind::Int, ScalarValue::Uint(_) => Kind::Uint, ScalarValue::F64(_) => Kind::Float, ScalarValue::Boolean(_) => Kind::Boolean, ScalarValue::Null => Kind::Null, ScalarValue::Counter(_) => Kind::Counter, ScalarValue::Timestamp(_) => Kind::Timestamp, ScalarValue::Unknown { .. } => Kind::Unknown, } } /// As `x-continuum-type` and shallow reads name it. pub(crate) fn name(self) -> &'static str { match self { Kind::Map => "map", Kind::List => "list", Kind::Text => "text", Kind::String => "string", Kind::Bytes => "bytes", Kind::Int => "int", Kind::Uint => "uint", Kind::Float => "float", Kind::Boolean => "boolean", Kind::Null => "null", Kind::Counter => "counter", Kind::Timestamp => "timestamp", Kind::Unknown => "unknown", } } /// The media type a value of this kind is shown as natively, if any. pub(crate) fn media_type(self) -> Option<&'static str> { Some(match self { Kind::Text | Kind::String => TEXT, Kind::Bytes => OCTET_STREAM, Kind::Int => INT, Kind::Uint => UINT, Kind::Float => FLOAT, Kind::Boolean => BOOLEAN, Kind::Counter => COUNTER, Kind::Timestamp => TIMESTAMP, Kind::Map | Kind::List | Kind::Null | Kind::Unknown => return None, }) } } /// How a timestamp is written out: as text natively (`format=`), and in JSON /// (`timestamp-format=`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum TimestampFormat { /// `2026-11-01T21:50:20.345Z` (a string in JSON). #[default] Rfc3339, /// Seconds since 1970, with milliseconds as a fraction (a number in JSON). Unix, /// Milliseconds since 1970 (an integer in JSON). UnixMs, /// `Sun, 01 Nov 2026 21:50:20 GMT` (RFC 9110; whole seconds; a string in JSON). HttpDate, } impl TimestampFormat { pub(crate) fn named(name: &str) -> Option { Some(match name { "rfc3339" => Self::Rfc3339, "unix" => Self::Unix, "unix-ms" => Self::UnixMs, "http-date" => Self::HttpDate, _ => return None, }) } pub(crate) fn name(self) -> &'static str { match self { Self::Rfc3339 => "rfc3339", Self::Unix => "unix", Self::UnixMs => "unix-ms", Self::HttpDate => "http-date", } } /// `ms` (milliseconds since 1970) as text. pub(crate) fn text(self, ms: i64) -> String { match self { Self::UnixMs => ms.to_string(), Self::Unix => { let sign = if ms < 0 { "-" } else { "" }; let (seconds, millis) = (ms.unsigned_abs() / 1000, ms.unsigned_abs() % 1000); if millis == 0 { format!("{sign}{seconds}") } else { format!("{sign}{seconds}.{millis:03}") .trim_end_matches('0') .to_owned() } } Self::Rfc3339 | Self::HttpDate => match jiff::Timestamp::from_millisecond(ms) { Ok(at) if self == Self::Rfc3339 => at.to_string(), Ok(at) => jiff::fmt::rfc2822::DateTimePrinter::new() .timestamp_to_rfc9110_string(&at) .unwrap_or_else(|_| ms.to_string()), // Beyond what a date shows: the number itself. Err(_) => ms.to_string(), }, } } /// `ms` in JSON: a string, or for the Unix formats a number. pub(crate) fn json(self, ms: i64) -> JsonValue { match self { Self::UnixMs => JsonValue::from(ms), Self::Unix => serde_json::Number::from_f64(ms as f64 / 1000.0) .map_or(JsonValue::from(ms / 1000), JsonValue::Number), Self::Rfc3339 | Self::HttpDate => JsonValue::String(self.text(ms)), } } /// Milliseconds since 1970 from `text` in this format; None if it isn't one, or is /// finer than a millisecond. pub(crate) fn read(self, text: &str) -> Option { let text = text.trim(); match self { Self::UnixMs => text.parse().ok(), Self::Unix => { let (seconds, fraction) = text.split_once('.').unwrap_or((text, "")); if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { return None; } let seconds: i64 = seconds.parse().ok()?; let millis: i64 = format!("{fraction:0<3}").parse().ok()?; let millis = if text.starts_with('-') { -millis } else { millis }; seconds.checked_mul(1000)?.checked_add(millis) } Self::Rfc3339 => { let at: jiff::Timestamp = text.parse().ok()?; (at.subsec_nanosecond() % 1_000_000 == 0).then(|| at.as_millisecond()) } Self::HttpDate => jiff::fmt::rfc2822::DateTimeParser::new() .parse_timestamp(text) .ok() .map(|at| at.as_millisecond()), } } } /// How values are shown in JSON. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct JsonForm { pub(crate) timestamps: TimestampFormat, /// Typed JSON (`TYPED_JSON`): what plain JSON doesn't tell apart is tagged, so it /// reads back as it is stored. pub(crate) typed: bool, } // Typed JSON is plain JSON but for what plain JSON shows as another type: a single-key // object whose key is a tag names it. `{"$uint": 5}`, `{"$counter": 5}`, // `{"$timestamp": "2026-11-01T21:50:20.345Z"}` (in the timestamp format asked), // `{"$bytes": ""}`, `{"$float": 2}` (a float without a fraction, which JSON // numbers don't keep apart from integers in every language), `{"$text": "…"}` (an // Automerge text). A map whose only key starts with `$` is `{"$map": {…}}`, so that // nothing else reads as a tag. /// A map's single key that is a tag, or would be taken for one. pub(crate) fn tag_like(map: &serde_json::Map) -> bool { map.len() == 1 && map.keys().all(|key| key.starts_with('$')) } fn tagged(tag: &str, value: JsonValue) -> JsonValue { let mut map = serde_json::Map::new(); map.insert(tag.to_owned(), value); JsonValue::Object(map) } /// A map in `form` (`$map` around one that would read as a tag in typed JSON). pub(crate) fn map_to_json(map: serde_json::Map, form: JsonForm) -> JsonValue { if form.typed && tag_like(&map) { tagged("$map", JsonValue::Object(map)) } else { JsonValue::Object(map) } } /// A text in `form`. pub(crate) fn text_to_json(text: String, form: JsonForm) -> JsonValue { if form.typed { tagged("$text", JsonValue::String(text)) } else { JsonValue::String(text) } } /// `scalar` in JSON: integers, floats and counters as numbers, bytes as base64, /// timestamps as `form` says; in typed JSON, tagged where plain JSON would show it as /// another type. pub(crate) fn scalar_to_json(scalar: &ScalarValue, form: JsonForm) -> Option { if form.typed { match scalar { ScalarValue::Uint(u) => return Some(tagged("$uint", (*u).into())), ScalarValue::Counter(c) => return Some(tagged("$counter", i64::from(c).into())), ScalarValue::Timestamp(t) => { return Some(tagged("$timestamp", form.timestamps.json(*t))); } ScalarValue::Bytes(b) => return Some(tagged("$bytes", BASE64.encode(b).into())), ScalarValue::F64(f) if f.fract() == 0.0 => { return Some(tagged("$float", serde_json::Number::from_f64(*f)?.into())); } _ => {} } } Some(match scalar { ScalarValue::Null => JsonValue::Null, ScalarValue::Boolean(b) => JsonValue::Bool(*b), ScalarValue::Int(i) => JsonValue::Number((*i).into()), ScalarValue::Uint(u) => JsonValue::Number((*u).into()), ScalarValue::F64(f) => JsonValue::Number(serde_json::Number::from_f64(*f)?), ScalarValue::Str(s) => JsonValue::String(s.to_string()), ScalarValue::Bytes(b) => JsonValue::String(BASE64.encode(b)), ScalarValue::Counter(c) => JsonValue::Number(i64::from(c).into()), ScalarValue::Timestamp(t) => form.timestamps.json(*t), ScalarValue::Unknown { .. } => return None, }) } /// `scalar` shown natively: its media type (with the timestamp format) and body; None /// for a null or an unknown scalar, which have none. pub(crate) fn native( scalar: &ScalarValue, timestamps: TimestampFormat, ) -> Option<(String, Vec)> { let media = Kind::of(scalar).media_type()?; let body = match scalar { ScalarValue::Str(text) => { return Some((format!("{TEXT}; charset=utf-8"), text.as_bytes().to_vec())); } ScalarValue::Bytes(bytes) => bytes.clone(), ScalarValue::Int(i) => i.to_string().into_bytes(), ScalarValue::Uint(u) => u.to_string().into_bytes(), ScalarValue::F64(f) => f.to_string().into_bytes(), ScalarValue::Boolean(b) => b.to_string().into_bytes(), ScalarValue::Counter(c) => i64::from(c).to_string().into_bytes(), ScalarValue::Timestamp(t) => { return Some(( format!("{TIMESTAMP}; format={}", timestamps.name()), timestamps.text(*t).into_bytes(), )); } ScalarValue::Null | ScalarValue::Unknown { .. } => return None, }; Some((media.to_owned(), body)) } /// The value of media type `media` (with its `params`) that `body` holds, for a write; /// None if `media` isn't one of the native types (`text/plain` is a JSON string's, and /// read as one by the caller). An error says what's wrong with the body. pub(crate) fn from_native( media: &str, params: &str, body: &[u8], ) -> Option> { let text = || { std::str::from_utf8(body) .map(str::trim) .map_err(|_| "not UTF-8".to_owned()) }; Some(match media { OCTET_STREAM => Ok(ScalarValue::Bytes(body.to_vec())), INT => text().and_then(|text| { text.parse() .map(ScalarValue::Int) .map_err(|_| format!("not an integer: {text}")) }), UINT => text().and_then(|text| { text.parse() .map(ScalarValue::Uint) .map_err(|_| format!("not an unsigned integer: {text}")) }), FLOAT => text().and_then(|text| { text.parse::() .ok() .filter(|f| f.is_finite()) .map(ScalarValue::F64) .ok_or_else(|| format!("not a finite number: {text}")) }), BOOLEAN => text().and_then(|text| match text { "true" => Ok(ScalarValue::Boolean(true)), "false" => Ok(ScalarValue::Boolean(false)), other => Err(format!("not true or false: {other}")), }), COUNTER => text().and_then(|text| { text.parse::() .map(ScalarValue::counter) .map_err(|_| format!("not an integer: {text}")) }), TIMESTAMP => { let format = match param(params, "format") { None => TimestampFormat::default(), Some(name) => match TimestampFormat::named(&name) { Some(format) => format, None => return Some(Err(format!("unknown timestamp format: {name}"))), }, }; text().and_then(|text| { format .read(text) .map(ScalarValue::Timestamp) .ok_or_else(|| { format!( "not a {} timestamp (to the millisecond): {text}", format.name() ) }) }) } _ => return None, }) } /// The value of parameter `name` among a media type's `params` (`; a=b; c="d"`). pub(crate) fn param(params: &str, name: &str) -> Option { params.split(';').find_map(|param| { let (key, value) = param.split_once('=')?; key.trim() .eq_ignore_ascii_case(name) .then(|| value.trim().trim_matches('"').to_owned()) }) } /// A value to write (`store::write_val_path`), as Automerge stores it. #[derive(Clone, Debug, PartialEq)] pub(crate) enum Val { /// Its keys in order. Map(Vec<(String, Val)>), List(Vec), /// An Automerge text. Text(String), Scalar(ScalarValue), } /// What a plain JSON `value` is stored as, if it is a scalar: an integer where it fits /// (signed, else unsigned), a float otherwise. pub(crate) fn json_scalar(value: &JsonValue) -> Result, String> { Ok(Some(match value { JsonValue::String(value) => ScalarValue::Str(value.as_str().into()), JsonValue::Bool(value) => ScalarValue::Boolean(*value), JsonValue::Number(value) => { if let Some(value) = value.as_i64() { ScalarValue::Int(value) } else if let Some(value) = value.as_u64() { ScalarValue::Uint(value) } else if let Some(value) = value.as_f64() { ScalarValue::F64(value) } else { return Err(format!("unsupported JSON number {value}")); } } JsonValue::Null => ScalarValue::Null, JsonValue::Object(_) | JsonValue::Array(_) => return Ok(None), })) } impl Val { /// Plain JSON, as it is stored: maps, lists and scalars (`json_scalar`). pub(crate) fn from_json(value: &JsonValue) -> Result { Ok(match value { JsonValue::Object(map) => Val::Map( map.iter() .map(|(key, value)| Ok((key.clone(), Val::from_json(value)?))) .collect::>()?, ), JsonValue::Array(items) => { Val::List(items.iter().map(Val::from_json).collect::>()?) } scalar => Val::Scalar(json_scalar(scalar)?.expect("a scalar")), }) } /// Typed JSON (`TYPED_JSON`): plain JSON, but tagged values as their tags say, /// timestamps in `timestamps`. An object of one key that starts with `$` and is no /// tag is an error. pub(crate) fn from_typed_json( value: &JsonValue, timestamps: TimestampFormat, ) -> Result { let JsonValue::Object(map) = value else { return match value { JsonValue::Array(items) => Ok(Val::List( items .iter() .map(|item| Val::from_typed_json(item, timestamps)) .collect::>()?, )), scalar => Val::from_json(scalar), }; }; if !tag_like(map) { return Ok(Val::Map( map.iter() .map(|(key, value)| Ok((key.clone(), Val::from_typed_json(value, timestamps)?))) .collect::>()?, )); } let (tag, value) = map.iter().next().expect("one key"); let integer = |value: &JsonValue| { value .as_i64() .ok_or_else(|| format!("{tag}: not an integer: {value}")) }; Ok(match tag.as_str() { "$map" => match value { JsonValue::Object(map) => Val::Map( map.iter() .map(|(key, value)| { Ok((key.clone(), Val::from_typed_json(value, timestamps)?)) }) .collect::>()?, ), other => return Err(format!("$map: not an object: {other}")), }, "$text" => Val::Text( value .as_str() .ok_or_else(|| format!("$text: not a string: {value}"))? .to_owned(), ), "$bytes" => Val::Scalar(ScalarValue::Bytes( value .as_str() .and_then(|text| BASE64.decode(text).ok()) .ok_or_else(|| format!("$bytes: not base64: {value}"))?, )), "$uint" => { Val::Scalar(ScalarValue::Uint(value.as_u64().ok_or_else(|| { format!("$uint: not an unsigned integer: {value}") })?)) } "$counter" => Val::Scalar(ScalarValue::counter(integer(value)?)), "$float" => Val::Scalar(ScalarValue::F64( value .as_f64() .ok_or_else(|| format!("$float: not a number: {value}"))?, )), "$timestamp" => { let text = match value { JsonValue::String(text) => text.clone(), JsonValue::Number(number) => number.to_string(), other => { return Err(format!( "$timestamp: neither a string nor a number: {other}" )); } }; Val::Scalar(ScalarValue::Timestamp(timestamps.read(&text).ok_or_else( || format!("$timestamp: not a {} timestamp: {text}", timestamps.name()), )?)) } other => { return Err(format!( "no tag {other} (a map whose only key starts with $ is written as {{\"$map\": …}})" )); } }) } /// What it is stored as. pub(crate) fn kind(&self) -> Kind { match self { Val::Map(_) => Kind::Map, Val::List(_) => Kind::List, Val::Text(_) => Kind::Text, Val::Scalar(scalar) => Kind::of(scalar), } } }