wire/preference/tag.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The words a person files sessions under (`crates/wire/spec/Preference.lean`
7//! ยง8-84, wire D21).
8//!
9//! **A tag is the person's classification, not the city's history.** It
10//! changes nothing a run can observe, so it rides in the person's own
11//! preferences beside the chords they rebound, and a phone reaching the
12//! same city reads the same tags from the same answer.
13//!
14//! **A session is named the way the ledger files it**: the city, the room,
15//! and the line its stretch began at (`SessionLine::began`). The city is
16//! part of the name because the person's file is shared by every city
17//! they run, and every city's `hall/mayor` begins near the same line.
18
19use kernel::{Address, AxCode, AxError, Seq};
20use serde::{Deserialize, Serialize};
21
22/// The longest tag, in characters. A tag is drawn as one chip that does
23/// not wrap in the narrow sessions pane; past this it is a sentence, not
24/// a class.
25pub const TAG_MAX: usize = 24;
26
27/// One word a person files sessions under: one to [`TAG_MAX`] letters
28/// of any script, digits, `-` or `_`, and no whitespace, so `/tag <name>`
29/// reads exactly one word.
30///
31/// Case is not part of the grammar. The page folds what a person types
32/// to lower case, so `Bug` typed twice is one tag; a tag written by hand
33/// into the file in upper case is read as written.
34#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
35#[serde(transparent)]
36pub struct Tag(String);
37
38impl Tag {
39 /// Sole constructor.
40 ///
41 /// # Errors
42 /// `E_INVALID_ARGS` for an empty word, a word past [`TAG_MAX`]
43 /// characters, or a character that is not a letter, a digit, `-` or
44 /// `_`.
45 pub fn parse(raw: &str) -> Result<Self, AxError> {
46 let length = raw.chars().count();
47 if length == 0 || length > TAG_MAX || !raw.chars().all(belongs) {
48 return Err(
49 AxError::failure(AxCode::InvalidArgs, "tag a session", raw).with_recovery(
50 "give one word of at most 24 letters, digits, `-` or `_`, with no spaces",
51 ),
52 );
53 }
54 Ok(Tag(raw.to_owned()))
55 }
56
57 #[must_use]
58 pub fn as_str(&self) -> &str {
59 &self.0
60 }
61}
62
63/// The characters a tag may hold. `char::is_alphabetic` is Unicode's
64/// `Alphabetic` and `char::is_numeric` is `\p{N}`, which is what lets
65/// [`TAG_PATTERN`] state the same set to the client.
66fn belongs(c: char) -> bool {
67 c == '-' || c == '_' || c.is_alphabetic() || c.is_numeric()
68}
69
70impl<'de> Deserialize<'de> for Tag {
71 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
72 let raw = String::deserialize(deserializer)?;
73 Tag::parse(&raw).map_err(serde::de::Error::custom)
74 }
75}
76
77/// [`Tag::parse`]'s grammar as the pattern the client reads it through,
78/// counted in code points as the `u` flag counts them.
79#[cfg(feature = "schema")]
80const TAG_PATTERN: &str = r"^[-_\p{Alphabetic}\p{N}]{1,24}$";
81
82#[cfg(feature = "schema")]
83impl schemars::JsonSchema for Tag {
84 fn schema_name() -> std::borrow::Cow<'static, str> {
85 std::borrow::Cow::Borrowed("Tag")
86 }
87 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
88 schemars::json_schema!({
89 "type": "string",
90 "description": "One word the User files sessions under: one to 24 letters of any \
91 script, digits, `-` or `_`, as `wire::Tag::parse` accepts it.",
92 "pattern": TAG_PATTERN,
93 })
94 }
95}
96
97/// One session's tags: the session named by the city, the room and the
98/// line its stretch began at, and the tags the person gave it, in
99/// lexical order without repeats.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103pub struct SessionTags {
104 /// The city, as the handshake's `Welcome::city` names it.
105 pub city: Address,
106 pub room: Address,
107 /// The first line of the stretch, as `SessionLine::began` states it.
108 pub began: Seq,
109 pub tags: Vec<Tag>,
110}
111
112impl SessionTags {
113 /// Whether two entries name the same session.
114 #[must_use]
115 pub fn names(&self, other: &SessionTags) -> bool {
116 self.city == other.city && self.room == other.room && self.began == other.began
117 }
118}
119
120/// The list after one session's tags were replaced by `next`'s: an empty
121/// set removes the entry, and the list stays in `(city, room, began)`
122/// order with each set sorted and without repeats, so two machines that
123/// tagged the same sessions in a different order hold the same file.
124pub(crate) fn retagged(held: &mut Vec<SessionTags>, next: SessionTags) {
125 held.retain(|entry| !entry.names(&next));
126 let mut next = next;
127 next.tags.sort();
128 next.tags.dedup();
129 if !next.tags.is_empty() {
130 held.push(next);
131 held.sort_by(|one, two| {
132 (&one.city, &one.room, one.began).cmp(&(&two.city, &two.room, two.began))
133 });
134 }
135}
136
137#[cfg(test)]
138#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
139mod tests;