Skip to main content

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;