diff --git a/knot2/crates/knot-cobs/src/label.rs b/knot2/crates/knot-cobs/src/label.rs index 7fd0b70e8..403ac2772 100644 --- a/knot2/crates/knot-cobs/src/label.rs +++ b/knot2/crates/knot-cobs/src/label.rs @@ -1,9 +1,6 @@ -use std::collections::BTreeMap; - -use knot_record::label::{LABEL_DEF_COLLECTION, LABEL_OP_COLLECTION}; use serde::{Deserialize, Serialize}; -pub use knot_record::label::{DefRef, LabelVerb, OperandValue}; +use knot_record::label::LABEL_DEF_COLLECTION; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct LabelDef; @@ -16,55 +13,6 @@ crate::social_cob! { opening = crate::Opening::Ownership, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct LabelOp { - pub operands: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OpOperand { - pub key: DefRef, - pub value: OperandValue, - pub verb: LabelVerb, - pub multiple: bool, -} - -impl LabelOp { - pub fn applications( - record: &knot_record::label::LabelOpRecord, - defs: &BTreeMap, - ) -> Result { - record.vetted_against(defs)?; - let applied = |operand: &knot_record::label::LabelOperand, verb: LabelVerb| OpOperand { - key: operand.key().clone(), - value: operand.value().clone(), - verb, - multiple: defs.get(operand.key()).is_some_and(|def| def.multiple()), - }; - Ok(Self { - operands: record - .adds() - .iter() - .map(|operand| applied(operand, LabelVerb::Add)) - .chain( - record - .deletes() - .iter() - .map(|operand| applied(operand, LabelVerb::Delete)), - ) - .collect(), - }) - } -} - -crate::social_cob! { - body = LabelOp, - change = LabelOpChange, - cob = LabelOpCob, - collection = LABEL_OP_COLLECTION, - opening = crate::Opening::Triage, -} - #[cfg(test)] mod tests { use super::*; @@ -72,164 +20,24 @@ mod tests { use crate::social::seeded; use knot_cob::ChangePayload; use knot_record::label::label_def_collection; - use knot_types::{ - AccountDid, AtUri, LabelKey, RecordAddress, RecordCid, UnixSeconds, dag_cbor_cid, - }; - - fn def_address(key: &str) -> RecordAddress { - RecordAddress::new( - label_def_collection(), - knot_types::RecordRkey::composed(LabelKey::new(key).unwrap()), - ) - } - - fn open(key: &str) -> SocialChange { - SocialChange::Open { - creation: seeded(def_address(key), None), - body: LabelDef, - } - } - - fn operand(key: &str, value: &str) -> knot_record::label::LabelOperand { - knot_record::label::LabelOperand::new( - DefRef::parse( - &AtUri::new_owned(format!( - "at://did:plc:system/sh.tangled.label.definition/{key}" - )) - .unwrap(), - ) - .unwrap(), - knot_record::label::OperandValue::new(value).unwrap(), - ) - } - - fn defined(key: &str, multiple: bool) -> (DefRef, knot_record::label::Def) { - ( - DefRef::parse( - &AtUri::new_owned(format!( - "at://did:plc:system/sh.tangled.label.definition/{key}" - )) - .unwrap(), - ) - .unwrap(), - knot_record::label::Def::new( - knot_record::label::LabelName::new(key).unwrap(), - knot_record::label::LabelValue::Text(knot_record::label::TextConstraint::free( - knot_record::label::LabelFormat::Any, - )), - vec![knot_record::issue::issue_collection()], - None, - multiple, - ) - .unwrap(), - ) - } - - fn op_record( - repo: &str, - subject: &str, - adds: Vec, - deletes: Vec, - ) -> knot_record::label::LabelOpRecord { - knot_record::label::LabelOpRecord::new( - knot_types::RepoDid::new(repo).unwrap(), - RecordAddress::parse_at_uri(&AtUri::new_owned(subject).unwrap()) - .unwrap() - .1, - adds, - deletes, - UnixSeconds::new(2), - AccountDid::new("did:plc:nel").unwrap(), - ) - .unwrap() - } - - #[test] - fn def_opens_on_the_slug_the_owner_composed() { - let change = open("wont-fix.2"); - assert_eq!( - change.address().as_ref(), - Some(&def_address("wont-fix.2")), - "Def's address is the collection and the slug" - ); - assert_eq!( - LabelDefChange::decode(&change.encode().unwrap()).unwrap(), - change, - "A def change round trips" - ); - } + use knot_types::{LabelKey, RecordAddress, RecordRkey}; #[test] - fn op_lists_the_operands_in_application_order() { - let record = op_record( - "did:plc:system", - "at://did:plc:system/sh.tangled.repo.issue/3lubrptx57d33", - vec![ - operand("wontfix", "null"), - operand("assignee", "did:plc:nel"), - ], - vec![operand("wontfix", "null")], - ); - let defs: BTreeMap<_, _> = [defined("wontfix", false), defined("assignee", true)].into(); + fn label_def_change_round_trips() { let change = SocialChange::Open { creation: seeded( RecordAddress::new( - knot_record::label::label_op_collection(), - knot_types::RecordRkey::new("3lubrptx57d22").unwrap(), + label_def_collection(), + RecordRkey::composed(LabelKey::new("wontfix").unwrap()), ), - Some(RecordAddress::new( - knot_record::issue::issue_collection(), - knot_types::RecordRkey::new("3lubrptx57d33").unwrap(), - )), + None, ), - body: LabelOp::applications(&record, &defs).unwrap(), - }; - let SocialChange::Open { body: op, .. } = &change else { - panic!("The change opens a label op"); + body: LabelDef, }; assert_eq!( - op.operands, - vec![ - OpOperand { - key: operand("wontfix", "x").key().clone(), - value: knot_record::label::OperandValue::new("null").unwrap(), - verb: LabelVerb::Add, - multiple: false, - }, - OpOperand { - key: operand("assignee", "x").key().clone(), - value: knot_record::label::OperandValue::new("did:plc:nel").unwrap(), - verb: LabelVerb::Add, - multiple: true, - }, - OpOperand { - key: operand("wontfix", "x").key().clone(), - value: knot_record::label::OperandValue::new("null").unwrap(), - verb: LabelVerb::Delete, - multiple: false, - }, - ], - "Adds stand before deletes, and each operand's multiple comes from the cited def" - ); - assert_eq!( - LabelOpChange::decode(&change.encode().unwrap()).unwrap(), + LabelDefChange::decode(&change.encode().unwrap()).unwrap(), change, - "An op change round trips with every operand" - ); - } - - #[test] - fn erased_op_round_trips_like_every_social_change() { - let erased = SocialChange::Erase { - erasure: crate::Erasure { - author: AccountDid::new("did:plc:nel").unwrap(), - from: RecordCid::new(dag_cbor_cid(b"kelp")).unwrap(), - erased_at: knot_types::UnixSeconds::new(2), - }, - }; - assert_eq!( - LabelOpChange::decode(&erased.encode().unwrap()).unwrap(), - erased + "a label def change round trips through its own encoding" ); } } diff --git a/knot2/crates/knot-record/src/label.rs b/knot2/crates/knot-record/src/label.rs index ec785e687..d65c0882b 100644 --- a/knot2/crates/knot-record/src/label.rs +++ b/knot2/crates/knot-record/src/label.rs @@ -1,176 +1,44 @@ -use std::collections::BTreeMap; use std::fmt; use std::str::FromStr; use crate::{CreatedAt, RecordError, encoded}; use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; -use knot_types::{ - AccountDid, AtUri, RecordAddress, RecordBody, RecordBodyError, RecordCollection, RecordRkey, - RepoDid, UnixSeconds, -}; +use knot_types::{AccountDid, LabelKey, RecordBody, RecordBodyError, UnixSeconds}; use serde::{Deserialize, Serialize, Serializer}; -pub const LABEL_DEF_COLLECTION: &str = "sh.tangled.label.definition"; +pub const LABEL_DEF_COLLECTION: &str = "org.tangled.label.definition"; -pub const LABEL_OP_COLLECTION: &str = "sh.tangled.label.op"; +collection!( + label_def_collection, + LABEL_DEF_COLLECTION, + "Label def collection is valid NSID" +); -pub fn label_def_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(LABEL_DEF_COLLECTION) - .expect("Label def collection is valid NSID") -} - -pub fn label_op_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(LABEL_OP_COLLECTION) - .expect("Label op collection is valid NSID") -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct DefRef { - repo: RepoDid, - rkey: RecordRkey, -} - -impl DefRef { - pub fn parse(uri: &AtUri) -> Result { - let (repo, address) = - RecordAddress::parse_at_uri(uri).map_err(|error| LabelError::DefUri { - at_uri: uri.as_str().to_owned(), - reason: error.to_string(), - })?; - if address.collection() != &label_def_collection() { - return Err(LabelError::NotADef { - at_uri: uri.as_str().to_owned(), - collection: address.collection().to_string(), - }); - } - Ok(Self { - repo, - rkey: address.rkey().clone(), - }) - } - - pub fn repo(&self) -> &RepoDid { - &self.repo - } - - pub fn rkey(&self) -> &RecordRkey { - &self.rkey - } - - pub fn at_uri(&self) -> AtUri { - RecordAddress::new(label_def_collection(), self.rkey.clone()).at_uri(&self.repo) - } -} - -impl fmt::Display for DefRef { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.pad(self.at_uri().as_str()) - } -} - -impl Serialize for DefRef { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(self.at_uri().as_str()) - } -} - -impl<'de> Deserialize<'de> for DefRef { - fn deserialize>(deserializer: D) -> Result { - let uri = AtUri::::deserialize(deserializer)?; - Self::parse(&uri).map_err(serde::de::Error::custom) - } -} - -impl std::str::FromStr for DefRef { - type Err = LabelError; - - fn from_str(s: &str) -> Result { - let uri = AtUri::new_owned(s).map_err(|error| LabelError::DefUri { - at_uri: s.to_owned(), - reason: error.to_string(), - })?; - Self::parse(&uri) - } -} #[derive(Debug, thiserror::Error)] pub enum LabelError { #[error("Label name is empty")] EmptyName, #[error("Label name of {0} graphemes is over the {MAX_NAME_GRAPHEMES} grapheme limit")] NameTooLong(usize), - #[error("A declared enum lists at least one value")] - EmptyEnum, - #[error("A null-typed label def doesn't take an enum of values")] - NullEnum, - #[error("A null-typed label def can't be multiple; there is one null")] - NullMultiple, - #[error("A null-typed label def takes format any alone")] - NullFormat, - #[error("An enum of values declares format any, since the enum is the constraint")] - EnumFormat, - #[error("Format constrains string-typed values alone, and this def is {0}")] - FormatOnNonString(&'static str), - #[error("A def doesn't decode: {0}")] - Decode(String), - #[error("A def's $type is {spelled}, and this collection is {LABEL_DEF_COLLECTION}")] - Kind { spelled: String }, - #[error("An operand cites a label def by at-uri, and {at_uri} doesn't parse: {reason}")] - DefUri { at_uri: String, reason: String }, - #[error("An operand cites a label def by at-uri, and {at_uri} cites {collection}")] - NotADef { at_uri: String, collection: String }, - #[error("A declared enum value is empty")] - EnumValue, - #[error("{0} values are over the {MAX_ENUM_VALUES} value limit")] - TooManyEnumValues(usize), - #[error("Scope is empty; a def states which collections to label")] - EmptyScope, + #[error("Label name of {0} bytes is over the {MAX_NAME_BYTES} byte limit")] + NameTooWide(usize), #[error("Color must read as a hex value like #79ffe1, of three, four, six or eight digits")] Color, - #[error("{0} operands are over the {MAX_OPERANDS} operand limit")] - TooManyOperands(usize), - #[error("Operand value is empty; a delete states the value to remove")] - EmptyValue, - #[error("Operand value of {0} bytes is over the {MAX_VALUE_BYTES} byte limit")] - ValueTooLong(usize), - #[error("This def doesn't take value {value}: {reason}")] - ValueRefused { value: String, reason: String }, - #[error("The subject's collection {collection} is outside this def's scope")] - OutOfScope { collection: String }, - #[error("performedAt is unreadable: {0}")] - At(String), - #[error("a label op adds or deletes at least one label, and this op applies neither")] - EmptyOp, - #[error("a def write declares the field {0}, and that isn't a field a def takes")] - UnknownField(String), - #[error( - "the op on {subject} cites the def at {def}, which didn't arrive with the \ - write" - )] - Unbacked { subject: String, def: String }, - #[error( - "the op on {subject} cites the def at {def}, which rejected the operand: \ - {reason}" - )] - OperandRefused { - subject: String, - def: String, - reason: Box, - }, } -pub const MAX_NAME_GRAPHEMES: usize = 40; - -pub const MAX_ENUM_VALUES: usize = 64; - -pub const MAX_OPERANDS: usize = 64; +pub const MAX_NAME_GRAPHEMES: usize = 64; -pub const MAX_VALUE_BYTES: usize = 512; +pub const MAX_NAME_BYTES: usize = 640; fn parse_name(name: impl Into) -> Result { let name = name.into(); - match UnicodeSegmentation::graphemes(name.as_str(), true).count() { - 0 => Err(LabelError::EmptyName), - counted if counted > MAX_NAME_GRAPHEMES => Err(LabelError::NameTooLong(counted)), + match ( + UnicodeSegmentation::graphemes(name.as_str(), true).count(), + name.len(), + ) { + (0, _) => Err(LabelError::EmptyName), + (_, wide) if wide > MAX_NAME_BYTES => Err(LabelError::NameTooWide(wide)), + (many, _) if many > MAX_NAME_GRAPHEMES => Err(LabelError::NameTooLong(many)), _ => Ok(LabelName(name)), } } @@ -190,319 +58,39 @@ fn parse_color(color: impl Into) -> Result { knot_types::string_id!(LabelColor, try parse_color, err LabelError); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum LabelFormat { - #[serde(rename = "any")] - Any, - #[serde(rename = "did")] - Did, - #[serde(rename = "nsid")] - Nsid, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ConcreteType { - Null, - Boolean, - Integer, - String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum LabelValue { - Null, - Boolean, - Integer, - Text(TextConstraint), -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct TextConstraint { - format: LabelFormat, - values: Option>, -} - -impl TextConstraint { - fn new(format: LabelFormat, values: Option>) -> Result { - let listed = values.as_ref().map_or(0, Vec::len); - match ( - listed > MAX_ENUM_VALUES, - values.as_ref().is_some_and(Vec::is_empty), - format != LabelFormat::Any && values.is_some(), - ) { - (true, _, _) => Err(LabelError::TooManyEnumValues(listed)), - (_, true, _) => Err(LabelError::EmptyEnum), - (_, _, true) => Err(LabelError::EnumFormat), - (false, false, false) => Ok(Self { - format, - values: values - .map(|listed| { - listed - .into_iter() - .map(|value| { - OperandValue::new(value).map_err(|error| match error { - LabelError::ValueTooLong(bytes) => { - LabelError::ValueTooLong(bytes) - } - _ => LabelError::EnumValue, - }) - }) - .collect::, _>>() - }) - .transpose()?, - }), - } - } - - pub fn free(format: LabelFormat) -> Self { - Self { - format, - values: None, - } - } - - pub fn one_of(values: Vec) -> Result { - Self::new(LabelFormat::Any, Some(values)) - } - - pub fn format(&self) -> LabelFormat { - self.format - } - - pub fn values(&self) -> Option<&[OperandValue]> { - self.values.as_deref() - } -} - -impl LabelValue { - fn declared( - concrete: ConcreteType, - format: Option, - values: Option>, - ) -> Result { - let declared = format.unwrap_or(LabelFormat::Any); - match (concrete, declared) { - (ConcreteType::Null, _) if values.is_some() => Err(LabelError::NullEnum), - (ConcreteType::Null, LabelFormat::Any) => Ok(Self::Null), - (ConcreteType::Null, _) => Err(LabelError::NullFormat), - (ConcreteType::Boolean, LabelFormat::Any) => Ok(Self::Boolean), - (ConcreteType::Boolean, _) => Err(LabelError::FormatOnNonString("boolean-typed")), - (ConcreteType::Integer, LabelFormat::Any) => Ok(Self::Integer), - (ConcreteType::Integer, _) => Err(LabelError::FormatOnNonString("integer-typed")), - (ConcreteType::String, format) => Ok(Self::Text(TextConstraint::new(format, values)?)), - } - } - - pub fn check(&self, value: &OperandValue) -> Result<(), LabelError> { - let refused = |reason: String| LabelError::ValueRefused { - value: value.as_str().to_owned(), - reason, - }; - match self { - Self::Null => { - if value.as_str() == "null" { - Ok(()) - } else { - Err(refused( - "a null-typed label's value is the literal null".to_owned(), - )) - } - } - Self::Boolean => match value.as_str() { - "true" | "false" => Ok(()), - other => Err(refused(format!( - "A boolean-typed label's value is true or false, not {other}" - ))), - }, - Self::Integer => match value.as_str().parse::() { - Ok(_) => Ok(()), - Err(_) => Err(refused( - "An integer-typed label's value is an integer".to_owned(), - )), - }, - Self::Text(text) => { - if let Some(values) = text.values() - && !values - .iter() - .any(|listed| listed.as_str() == value.as_str()) - { - return Err(refused(format!( - "This def takes a declared value, not {}", - value.as_str() - ))); - } - match text.format() { - LabelFormat::Any => Ok(()), - LabelFormat::Did => AccountDid::new(value.as_str()) - .map(|_| ()) - .map_err(|_| refused("A did-typed label's value is a DID".to_owned())), - LabelFormat::Nsid => knot_types::TypeName::new(value.as_str()) - .map(|_| ()) - .map_err(|_| refused("An nsid-typed label's value is an NSID".to_owned())), - } - } - } - } -} - -fn parse_operand_value(value: impl Into) -> Result { - let value = value.into(); - match (value.trim().is_empty(), value.len()) { - (true, _) => Err(LabelError::EmptyValue), - (false, len) if len > MAX_VALUE_BYTES => Err(LabelError::ValueTooLong(len)), - (false, _) => Ok(OperandValue(value)), - } -} - -knot_types::string_id!(OperandValue, try parse_operand_value, err LabelError); - #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Def { +pub struct LabelDef { name: LabelName, - value: LabelValue, - scope: Vec, color: Option, - multiple: bool, } -impl Def { - pub fn new( - name: LabelName, - value: LabelValue, - scope: Vec, - color: Option, - multiple: bool, - ) -> Result { - match ( - scope.is_empty(), - matches!(value, LabelValue::Null) && multiple, - ) { - (true, _) => Err(LabelError::EmptyScope), - (_, true) => Err(LabelError::NullMultiple), - (false, false) => Ok(Self { - name, - value, - scope, - color, - multiple, - }), - } +impl LabelDef { + pub fn new(name: LabelName, color: Option) -> Self { + Self { name, color } } pub fn name(&self) -> &LabelName { &self.name } - pub fn value(&self) -> &LabelValue { - &self.value - } - - pub const fn multiple(&self) -> bool { - self.multiple + pub fn color(&self) -> Option<&LabelColor> { + self.color.as_ref() } - - pub fn scopes(&self, collection: &RecordCollection) -> Result<(), LabelError> { - self.scope - .contains(collection) - .then_some(()) - .ok_or_else(|| LabelError::OutOfScope { - collection: collection.to_string(), - }) - } - - pub fn decode(bytes: &[u8]) -> Result { - serde_ipld_dagcbor::from_slice(bytes) - .map_err(|error| LabelError::Decode(error.to_string())) - .and_then(draft_def) - } - - pub fn parse(value: serde_json::Value) -> Result { - serde_json::from_value::(value) - .map_err(|error| LabelError::Decode(error.to_string())) - .and_then(|draft| match draft.kind.as_ref() { - Some(kind) if kind.as_str() != LABEL_DEF_COLLECTION => Err(LabelError::Kind { - spelled: kind.to_string(), - }), - _ => draft_def(draft), - }) - } - - pub fn parse_written(value: serde_json::Value) -> Result { - let foreign = value.as_object().and_then(|fields| { - fields - .keys() - .find(|field| !WRITTEN_FIELDS.contains(&field.as_str())) - }); - match foreign { - Some(foreign) => Err(LabelError::UnknownField(foreign.clone())), - None => Self::parse(value), - } - } -} - -const WRITTEN_FIELDS: [&str; 8] = [ - "$type", - "name", - "valueType", - "scope", - "color", - "multiple", - "createdAt", - "x-tngl-editor", -]; - -#[derive(Deserialize)] -struct LabelDefDraft { - #[serde(rename = "$type", default)] - kind: Option, - name: LabelName, - #[serde(rename = "valueType")] - value_type: DeclaredValue, - #[serde(default)] - scope: Vec, - #[serde(default)] - color: Option, - #[serde(default)] - multiple: Option, - #[serde(rename = "createdAt", default)] - _created_at: serde::de::IgnoredAny, - #[serde(rename = "x-tngl-editor", default)] - _editor: serde::de::IgnoredAny, -} - -fn draft_def(draft: LabelDefDraft) -> Result { - Def::new( - draft.name, - LabelValue::declared( - draft.value_type.concrete, - draft.value_type.format, - draft.value_type.values, - )?, - draft.scope, - draft.color, - draft.multiple.unwrap_or(false), - ) -} - -#[derive(Deserialize)] -struct DeclaredValue { - #[serde(rename = "type")] - concrete: ConcreteType, - format: Option, - #[serde(rename = "enum")] - values: Option>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct LabelDefRecord { - def: Def, + def: LabelDef, created_at: CreatedAt, editor: AccountDid, } impl LabelDefRecord { - pub fn new(def: Def, created_at: UnixSeconds, editor: AccountDid) -> Result { + pub fn new( + def: LabelDef, + created_at: UnixSeconds, + editor: AccountDid, + ) -> Result { Ok(Self { def, created_at: CreatedAt::at(created_at)?, @@ -510,7 +98,7 @@ impl LabelDefRecord { }) } - pub fn def(&self) -> &Def { + pub fn def(&self) -> &LabelDef { &self.def } @@ -519,26 +107,13 @@ impl LabelDefRecord { } } -#[derive(Serialize)] -struct ValueTypeEncoding<'a> { - #[serde(rename = "type")] - concrete: ConcreteType, - format: LabelFormat, - #[serde(skip_serializing_if = "Option::is_none", rename = "enum")] - values: Option<&'a [OperandValue]>, -} - #[derive(Serialize)] struct LabelDefEncoding<'a> { name: &'a LabelName, #[serde(skip_serializing_if = "Option::is_none")] color: &'a Option, - scope: &'a [RecordCollection], #[serde(rename = "$type")] record_type: knot_types::RecordCollection, - multiple: bool, - #[serde(rename = "valueType")] - value_type: ValueTypeEncoding<'a>, #[serde(rename = "createdAt")] created_at: &'a CreatedAt, #[serde(rename = "x-tngl-editor")] @@ -547,27 +122,10 @@ struct LabelDefEncoding<'a> { impl Serialize for LabelDefRecord { fn serialize(&self, serializer: S) -> Result { - let text = match &self.def.value { - LabelValue::Text(text) => Some(text), - _ => None, - }; - let concrete = match &self.def.value { - LabelValue::Null => ConcreteType::Null, - LabelValue::Boolean => ConcreteType::Boolean, - LabelValue::Integer => ConcreteType::Integer, - LabelValue::Text(_) => ConcreteType::String, - }; LabelDefEncoding { name: &self.def.name, color: &self.def.color, - scope: &self.def.scope, record_type: label_def_collection(), - multiple: self.def.multiple, - value_type: ValueTypeEncoding { - concrete, - format: text.map_or(LabelFormat::Any, TextConstraint::format), - values: text.and_then(TextConstraint::values), - }, created_at: &self.created_at, editor: &self.editor, } @@ -576,484 +134,67 @@ impl Serialize for LabelDefRecord { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct LabelOperand { - key: DefRef, - value: OperandValue, +pub struct LabelToken { + key: LabelKey, } -impl LabelOperand { - pub fn new(key: DefRef, value: OperandValue) -> Self { - Self { key, value } +impl LabelToken { + pub fn new(key: LabelKey) -> Self { + Self { key } } - pub fn key(&self) -> &DefRef { + pub fn key(&self) -> &LabelKey { &self.key } - - pub fn value(&self) -> &OperandValue { - &self.value - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum LabelVerb { - Add, - Delete, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LabelOpRecord { - repo: RepoDid, - subject: RecordAddress, - adds: Vec, - deletes: Vec, - performed_at: CreatedAt, - editor: AccountDid, -} - -impl LabelOpRecord { - pub fn new( - repo: RepoDid, - subject: RecordAddress, - adds: Vec, - deletes: Vec, - performed_at: UnixSeconds, - editor: AccountDid, - ) -> Result { - match ( - adds.len() + deletes.len(), - adds.is_empty() && deletes.is_empty(), - ) { - (operands, _) if operands > MAX_OPERANDS => Err(LabelError::TooManyOperands(operands)), - (_, true) => Err(LabelError::EmptyOp), - (_, false) => Ok(Self { - repo, - subject, - adds, - deletes, - performed_at: CreatedAt::at(performed_at) - .map_err(|error| LabelError::At(error.to_string()))?, - editor, - }), - } - } - - pub fn vetted_against(&self, defs: &BTreeMap) -> Result<(), LabelError> { - let subject = self.subject.at_uri(&self.repo).as_str().to_owned(); - self.adds - .iter() - .chain(self.deletes.iter()) - .try_for_each(|operand| { - let Some(def) = defs.get(operand.key()) else { - return Err(LabelError::Unbacked { - subject: subject.clone(), - def: operand.key().to_string(), - }); - }; - def.scopes(self.subject.collection()) - .and_then(|()| def.value().check(operand.value())) - .map_err(|reason| LabelError::OperandRefused { - subject: subject.clone(), - def: operand.key().to_string(), - reason: Box::new(reason), - }) - }) - } - - pub fn subject(&self) -> &RecordAddress { - &self.subject - } - - pub fn adds(&self) -> &[LabelOperand] { - &self.adds - } - - pub fn deletes(&self) -> &[LabelOperand] { - &self.deletes - } - - pub fn body(&self) -> Result { - encoded(self) - } -} - -#[derive(Serialize)] -struct LabelOpEncoding<'a> { - add: &'a [LabelOperand], - #[serde(rename = "$type")] - record_type: knot_types::RecordCollection, - delete: &'a [LabelOperand], - subject: AtUri, - #[serde(rename = "performedAt")] - performed_at: &'a CreatedAt, - #[serde(rename = "x-tngl-editor")] - editor: &'a AccountDid, -} - -impl Serialize for LabelOpRecord { - fn serialize(&self, serializer: S) -> Result { - LabelOpEncoding { - add: &self.adds, - record_type: label_op_collection(), - delete: &self.deletes, - subject: self.subject.at_uri(&self.repo), - performed_at: &self.performed_at, - editor: &self.editor, - } - .serialize(serializer) - } } #[cfg(test)] mod tests { use super::*; - use crate::frozen; - use knot_types::RecordRkey; - - const DEF: &str = "a7646e616d6567776f6e74666978652474797065781b73682e74616e676c65642e6c6162656c2e646566696e6974696f6e6573636f7065817573682e74616e676c65642e7265706f2e6973737565686d756c7469706c65f46963726561746564417474313937302d30312d30315430303a30303a30315a6976616c756554797065a26474797065646e756c6c66666f726d617463616e796d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; - - const OP: &str = "a66361646481a2636b6579783661743a2f2f6469643a706c633a73717569642f73682e74616e676c65642e6c6162656c2e646566696e6974696f6e2f776f6e746669786576616c7565646e756c6c6524747970657373682e74616e676c65642e6c6162656c2e6f706664656c65746580677375626a656374783661743a2f2f6469643a706c633a73717569642f73682e74616e676c65642e7265706f2e69737375652f336c75627270747835376432326b706572666f726d6564417474313937302d30312d30315430303a30303a30315a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; + use knot_types::UnixSeconds; fn editor() -> AccountDid { AccountDid::new("did:plc:3fwecdnvtcscjnrx2p4n7alz").unwrap() } - fn squid() -> RepoDid { - RepoDid::new("did:plc:squid").unwrap() - } - - fn null_def() -> Def { - Def::new( - LabelName::new("wontfix").unwrap(), - LabelValue::Null, - vec![issue_scope()], - None, - false, - ) - .unwrap() - } - - fn issue_scope() -> RecordCollection { - crate::issue::issue_collection() - } - - fn pull_scope() -> RecordCollection { - RecordCollection::new("sh.tangled.repo.pull").unwrap() - } - - fn subject() -> RecordAddress { - RecordAddress::new( - crate::issue::issue_collection(), - RecordRkey::new("3lubrptx57d22").unwrap(), - ) - } - - fn wontfix() -> DefRef { - DefRef::parse( - &AtUri::new_owned("at://did:plc:squid/sh.tangled.label.definition/wontfix").unwrap(), - ) - .unwrap() - } - - #[test] - fn def_record_bytes_frozen() { - let record = LabelDefRecord::new(null_def(), UnixSeconds::new(1), editor()).unwrap(); - assert_eq!( - record.body().unwrap().as_bytes(), - frozen(DEF).as_slice(), - "the first emitted label def freezes these bytes: absent optionals omitted, \ - seconds precision, utc z, and the declared format always present" - ); - } - - #[test] - fn empty_delete_list_stays_in_the_op_record_bytes() { - let record = LabelOpRecord::new( - squid(), - subject(), - vec![LabelOperand::new( - wontfix(), - OperandValue::new("null").unwrap(), - )], - Vec::new(), - UnixSeconds::new(1), - editor(), - ) - .unwrap(); - assert_eq!( - record.body().unwrap().as_bytes(), - frozen(OP).as_slice(), - "The lexicon declares add and delete required, so a sent empty list stays" - ); - } - #[test] - fn bent_defs_are_refused_at_construction() { - assert!(matches!( - TextConstraint::new(LabelFormat::Any, Some(Vec::new())), - Err(LabelError::EmptyEnum) - )); - assert!(matches!( - TextConstraint::new(LabelFormat::Did, Some(vec!["a".to_owned()])), - Err(LabelError::EnumFormat) - )); - assert!(matches!( - Def::new( - LabelName::new("wontfix").unwrap(), - LabelValue::Null, - vec![issue_scope()], - None, - true, - ), - Err(LabelError::NullMultiple) - )); - assert!(matches!( - Def::new( + fn label_def_record_embeds_name_color_and_editor() { + let record = LabelDefRecord::new( + LabelDef::new( LabelName::new("wontfix").unwrap(), - LabelValue::Null, - Vec::new(), - None, - false, + Some(LabelColor::new("#79ffe1").unwrap()), ), - Err(LabelError::EmptyScope) - )); - } - - #[test] - fn value_check_takes_what_each_type_declares() { - let refused = - |value: &str, def: &LabelValue| def.check(&OperandValue::new(value).unwrap()).is_err(); - let null = LabelValue::Null; - assert!(!refused("null", &null)); - assert!(refused("kelp", &null)); - assert!( - OperandValue::new("").is_err(), - "An empty value is refused at the boundary, before the def checks run" - ); - assert!(!refused("true", &LabelValue::Boolean)); - assert!(refused("kelp", &LabelValue::Boolean)); - assert!(!refused("7", &LabelValue::Integer)); - assert!(refused("7.5", &LabelValue::Integer)); - let did = LabelValue::Text(TextConstraint::free(LabelFormat::Did)); - assert!(!refused("did:plc:squid", &did)); - assert!(refused("squid", &did)); - let nsid = LabelValue::Text(TextConstraint::free(LabelFormat::Nsid)); - assert!(!refused("sh.tangled.repo.issue", &nsid)); - assert!(refused("not an nsid", &nsid)); - let listed = LabelValue::Text( - TextConstraint::one_of(vec!["kelp".to_owned(), "kelp".to_owned()]).unwrap(), - ); - assert!(!refused("kelp", &listed)); - assert!(refused("urchin", &listed)); - } - - #[test] - fn def_scopes_only_the_declared_collections() { - let def = Def::new( - LabelName::new("assignee").unwrap(), - LabelValue::Text(TextConstraint::free(LabelFormat::Did)), - vec![issue_scope(), pull_scope()], - None, - true, - ) - .unwrap(); - assert!(def.scopes(&crate::issue::issue_collection()).is_ok()); - assert!( - def.scopes(&knot_types::RecordCollection::new("sh.tangled.repo.pull").unwrap()) - .is_ok() - ); - assert!(def.scopes(&crate::comment::comment_collection()).is_err()); - } - - #[test] - fn def_decodes_from_the_bytes_the_edit_wrote() { - let record = LabelDefRecord::new( - Def::new( - LabelName::new("assignee").unwrap(), - LabelValue::Text(TextConstraint::free(LabelFormat::Did)), - vec![issue_scope(), pull_scope()], - Some(LabelColor::new("#10b981").unwrap()), - true, - ) - .unwrap(), - UnixSeconds::new(1), + UnixSeconds::new(1_755_090_096), editor(), ) .unwrap(); - let decoded = Def::decode(record.body().unwrap().as_bytes()).unwrap(); - assert_eq!(decoded, *record.def()); - } - - #[test] - fn def_rejects_a_format_outside_the_lexicon() { - let bytes = serde_ipld_dagcbor::to_vec(&serde_json::json!({ - "name": "tracker", - "scope": ["sh.tangled.repo.issue"], - "multiple": false, - "valueType": {"type": "string", "format": "uri"} - })) - .unwrap(); - assert!( - Def::decode(&bytes).is_err(), - "This knot only ever emits the formats any, DID and NSID, so decode rejects a foreign \ - format instead of widening to accept every value" - ); - } - - #[test] - fn label_name_takes_the_graphemes_the_lexicon_declares() { - assert!(LabelName::new("needs triage").is_ok()); - assert!(LabelName::new("高優先度").is_ok()); - assert!(LabelName::new("優".repeat(40)).is_ok()); - assert!(LabelName::new("a".repeat(41)).is_err()); + let value: serde_json::Value = + serde_ipld_dagcbor::from_slice(record.body().unwrap().as_bytes()).unwrap(); + assert_eq!(value["$type"], LABEL_DEF_COLLECTION); + assert_eq!(value["name"], "wontfix"); + assert_eq!(value["color"], "#79FFE1"); + assert_eq!(value["x-tngl-editor"], editor().as_str()); } #[test] - fn unknown_scope_parses_and_scopes_out_local_collections() { - let parsed = Def::parse(serde_json::json!({ - "name": "tracker", - "scope": ["sh.tangled.repo.discussion"], - "multiple": false, - "valueType": {"type": "null", "format": "any"}, - })) - .expect("A future scope NSID belongs to another knot, and parse takes the NSID"); - assert!( - parsed.scopes(&crate::issue::issue_collection()).is_err(), - "Issue collection is outside the declared scope" + fn label_token_serializes_as_a_key() { + let token = LabelToken::new(LabelKey::new("wont-fix.2").unwrap()); + assert_eq!( + serde_json::to_value(token).unwrap(), + serde_json::json!({ "key": "wont-fix.2" }) ); } #[test] - fn color_takes_the_widths_css_spells() { - assert_eq!(LabelColor::new("#abc").unwrap().as_str(), "#ABC"); - assert_eq!(LabelColor::new("#abcd").unwrap().as_str(), "#ABCD"); - assert_eq!(LabelColor::new("#10b981").unwrap().as_str(), "#10B981"); - assert_eq!(LabelColor::new("#10b98188").unwrap().as_str(), "#10B98188"); - assert!(LabelColor::new("#10b9818").is_err()); - assert!(LabelColor::new("10b981").is_err()); - assert!(LabelColor::new("#ggg").is_err()); - } - - #[test] - fn op_without_adds_or_deletes_is_refused_at_the_record() { - assert!(matches!( - LabelOpRecord::new( - squid(), - subject(), - Vec::new(), - Vec::new(), - UnixSeconds::new(1), - editor(), - ), - Err(LabelError::EmptyOp) - )); - } - - #[test] - fn op_record_vets_the_operands_against_their_defs() { - let mut defs = BTreeMap::new(); - defs.insert( - wontfix(), - Def::new( - LabelName::new("wontfix").unwrap(), - LabelValue::Null, - vec![issue_scope()], - None, - false, - ) - .unwrap(), - ); - let in_scope = LabelOpRecord::new( - squid(), - subject(), - vec![LabelOperand::new( - wontfix(), - OperandValue::new("null").unwrap(), - )], - Vec::new(), - UnixSeconds::new(1), - editor(), - ) - .unwrap(); - assert!(in_scope.vetted_against(&defs).is_ok()); - let off_subject = LabelOpRecord::new( - squid(), - RecordAddress::new( - crate::comment::comment_collection(), - RecordRkey::new("3lubrptx57d22").unwrap(), - ), - vec![LabelOperand::new( - wontfix(), - OperandValue::new("null").unwrap(), - )], - Vec::new(), - UnixSeconds::new(1), - editor(), - ) - .unwrap(); - assert!( - off_subject - .vetted_against(&defs) - .is_err_and(|error| matches!(error, LabelError::OperandRefused { .. })), - "an operand outside the def's scope is refused, with the cited def \ - in the error" - ); - let unbacked = LabelOpRecord::new( - RepoDid::new("did:plc:urchin").unwrap(), - subject(), - Vec::new(), - vec![LabelOperand::new( - wontfix(), - OperandValue::new("null").unwrap(), - )], - UnixSeconds::new(1), - editor(), - ) - .unwrap(); + fn label_name_must_say_something_and_not_too_much() { + assert!(matches!(LabelName::new(""), Err(LabelError::EmptyName))); assert!(matches!( - unbacked.vetted_against(&BTreeMap::new()), - Err(LabelError::Unbacked { .. }) + LabelName::new("k".repeat(MAX_NAME_GRAPHEMES + 1)), + Err(LabelError::NameTooLong(_)) )); - } - - #[test] - fn def_writes_refuse_unknown_fields_and_reads_tolerate_them() { - let mut written = serde_json::json!({ - "name": "tracker", - "scope": ["sh.tangled.repo.issue"], - "multiple": false, - "valueType": {"type": "null", "format": "any"}, - }); - written["priority"] = serde_json::json!(3); assert!(matches!( - Def::parse_written(written.clone()), - Err(LabelError::UnknownField(field)) if field == "priority" + LabelName::new("👨‍👩‍👧‍👦".repeat(MAX_NAME_BYTES / 25 + 1)), + Err(LabelError::NameTooWide(_)) )); - assert!( - Def::parse(written).is_ok(), - "a remote PDS writes fields this knot doesn't know, and parse still accepts the record" - ); - Def::parse_written(serde_json::json!({ - "name": "tracker", - "scope": ["sh.tangled.repo.issue"], - "multiple": false, - "createdAt": "2026-09-01T00:00:00Z", - "x-tngl-editor": "did:plc:3fwecdnvtcscjnrx2p4n7alz", - "valueType": {"type": "null", "format": "any"}, - })) - .expect("A client may echo the server's own fields back"); - } - - #[test] - fn def_ref_takes_whatever_rkey_the_remote_writes() { - use std::str::FromStr; - assert!(DefRef::from_str("at://did:plc:squid/sh.tangled.label.definition/wontfix").is_ok()); - assert!( - DefRef::from_str("at://did:plc:squid/sh.tangled.label.definition/3lubrptx57d22") - .is_ok(), - "this knot's own defs go by slugs, and a foreign PDS can key a def with \ - a TID, so the ref takes slugs and TIDs" - ); } }