Skip to main content

kernel/event/
who.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//! Who a ledger line is attributed to.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::address::Address;
13use crate::error::{AxCode, AxError};
14use crate::event::identity::RunId;
15
16/// The city's own desk, as every ledger line it writes spells it.
17const CITY: &str = "city";
18
19/// A human being, as a ledger line spells them. One word for all of
20/// them: the ledger says an instruction came from outside the machine,
21/// and which person it was is the session's business rather than the
22/// history's.
23const PERSON: &str = "person";
24
25/// The three parties a ledger line can be attributed to.
26///
27/// Every line of every history carries one of these, and until this
28/// type existed each writer spelled its own: six call sites wrote the
29/// literal `"city"` and a resident wrote its address, so a seventh
30/// spelling was one keystroke away and nothing would have refused it.
31///
32/// **The wire form is what the histories already hold.** The city is
33/// `city`, a person is `person`, and a resident is its address, exactly
34/// as before; no ledger on disk has to be re-spelled for this type to
35/// read it.
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum Who {
38    /// The city's desk: genesis, dispatch, truncation, relaying.
39    City,
40    /// A person, through a client.
41    Person,
42    /// A resident of a building, at the address it is known by.
43    Resident(Address),
44}
45
46impl Who {
47    /// A resident's attribution.
48    ///
49    /// # Errors
50    /// `E_INVALID_ARGS` for an address spelled `city` or `person`:
51    /// those two words are the other two parties on this wire, and a
52    /// resident answering to one of them would make a line's author
53    /// unreadable.
54    pub fn resident(addr: Address) -> Result<Who, AxError> {
55        if addr.as_str() == CITY || addr.as_str() == PERSON {
56            return Err(AxError::failure(
57                AxCode::InvalidArgs,
58                "attribute a ledger line",
59                addr.as_str(),
60            )
61            .with_recovery(
62                "give this resident an address other than `city` or `person`: both \
63                 words name another party the ledger attributes lines to",
64            ));
65        }
66        Ok(Who::Resident(addr))
67    }
68
69    /// The word a ledger line carries.
70    #[must_use]
71    pub fn as_str(&self) -> &str {
72        match self {
73            Who::City => CITY,
74            Who::Person => PERSON,
75            Who::Resident(addr) => addr.as_str(),
76        }
77    }
78
79    /// Who handed a piece of work down, in the words a model reads in
80    /// its job file and its opening message (`crates/city/spec/SpineFiles.lean`
81    /// D21).
82    ///
83    /// A resident is named with the run that handed the work down: the
84    /// predecessor when this is a succession, otherwise the delegating
85    /// parent; a knock has neither and names the address alone. Spelled
86    /// here because the city's dispatch writes the job file and the
87    /// runtime writes the opening message, and both must say the same.
88    #[must_use]
89    pub fn handed_down_by(&self, predecessor: Option<RunId>, parent: Option<RunId>) -> String {
90        match self {
91            Who::City => "the city".to_owned(),
92            Who::Person => "the User".to_owned(),
93            Who::Resident(addr) => match predecessor.or(parent) {
94                Some(run) => format!("@{addr}, run {run}", addr = addr.as_str()),
95                None => format!("@{addr}", addr = addr.as_str()),
96            },
97        }
98    }
99
100    /// Reads back what [`Who::as_str`] wrote.
101    ///
102    /// # Errors
103    /// `E_INVALID_ARGS` for a word that is neither reserved nor a
104    /// canonical address.
105    pub fn parse(raw: &str) -> Result<Who, AxError> {
106        match raw {
107            CITY => Ok(Who::City),
108            PERSON => Ok(Who::Person),
109            other => Ok(Who::Resident(Address::parse(other)?)),
110        }
111    }
112}
113
114/// `<`, `>` and `&` as entities, so text written into a marked-up
115/// section holds no angle bracket and cannot close that section or open
116/// another. The one escaper: the job file's sections, a resident's
117/// letter and a rebuilt opening all write through it, so what one
118/// renderer refuses the others refuse too (`crates/city/spec/SpineFiles.lean`
119/// D21, `crates/runtime/spec/Conversation.lean` ยง8-47-2).
120#[must_use]
121pub fn escape_markup(text: &str) -> String {
122    text.chars()
123        .fold(String::with_capacity(text.len()), |mut out, c| {
124            match c {
125                '<' => out.push_str("&lt;"),
126                '>' => out.push_str("&gt;"),
127                '&' => out.push_str("&amp;"),
128                other => out.push(other),
129            }
130            out
131        })
132}
133
134impl fmt::Display for Who {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.write_str(self.as_str())
137    }
138}
139
140impl Serialize for Who {
141    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
142        serializer.serialize_str(self.as_str())
143    }
144}
145
146impl<'de> Deserialize<'de> for Who {
147    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
148        let raw = String::deserialize(deserializer)?;
149        Who::parse(&raw).map_err(serde::de::Error::custom)
150    }
151}
152
153#[cfg(test)]
154#[allow(
155    clippy::unwrap_used,
156    clippy::expect_used,
157    clippy::panic,
158    clippy::indexing_slicing,
159    reason = "test code"
160)]
161mod tests {
162    use super::*;
163    use proptest::prelude::*;
164
165    proptest! {
166        /// Escaped text holds no angle bracket, so it cannot close the
167        /// section it is written into, and reading the three entities
168        /// back gives the text that was written.
169        #[test]
170        fn escaped_text_holds_no_bracket_and_reads_back(text in "[a-z<>&; ]{0,24}") {
171            let escaped = escape_markup(&text);
172            prop_assert!(!escaped.contains(['<', '>']), "{}", escaped);
173            let read = escaped
174                .replace("&lt;", "<")
175                .replace("&gt;", ">")
176                .replace("&amp;", "&");
177            prop_assert_eq!(read, text);
178        }
179    }
180
181    #[test]
182    fn the_three_parties_write_the_words_the_histories_already_hold() {
183        let resident = Who::resident(Address::parse("lobby/scribe.md").unwrap()).unwrap();
184        assert_eq!(Who::City.to_string(), "city");
185        assert_eq!(Who::Person.to_string(), "person");
186        assert_eq!(resident.to_string(), "lobby/scribe.md");
187        for party in [Who::City, Who::Person, resident] {
188            assert_eq!(Who::parse(party.as_str()).unwrap(), party);
189            assert_eq!(
190                serde_json::to_string(&party).unwrap(),
191                format!("\"{party}\"")
192            );
193        }
194    }
195
196    #[test]
197    fn work_handed_down_names_the_run_that_handed_it_down() {
198        let lead = Who::resident(Address::parse("lab/lead").unwrap()).unwrap();
199        let parent = RunId::parse("0198f6a2-7c4a-7bbb-9d1e-000000000001").unwrap();
200        let before = RunId::parse("0198f6a2-7c4a-7bbb-9d1e-000000000002").unwrap();
201        assert_eq!(
202            [
203                Who::Person.handed_down_by(None, None),
204                Who::City.handed_down_by(None, Some(parent)),
205                lead.handed_down_by(None, Some(parent)),
206                lead.handed_down_by(Some(before), Some(parent)),
207                lead.handed_down_by(None, None),
208            ],
209            [
210                "the User".to_owned(),
211                "the city".to_owned(),
212                format!("@lab/lead, run {parent}"),
213                format!("@lab/lead, run {before}"),
214                "@lab/lead".to_owned(),
215            ]
216        );
217    }
218
219    #[test]
220    fn a_resident_may_not_answer_to_either_reserved_word() {
221        for reserved in ["city", "person"] {
222            let err = Who::resident(Address::parse(reserved).unwrap()).unwrap_err();
223            assert_eq!(err.code(), &AxCode::InvalidArgs);
224        }
225    }
226
227    #[test]
228    fn a_word_that_is_not_an_address_is_refused_rather_than_guessed() {
229        let err = Who::parse("../elsewhere").unwrap_err();
230        assert_eq!(err.code(), &AxCode::InvalidArgs);
231    }
232}