Skip to main content

kernel/event/
scope.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//! What a standing decision applies to, as the ledger spells it.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::address::Address;
13use crate::error::{AxCode, AxError};
14
15/// The whole city, as a `city_halted` or `autonomy_changed` line spells
16/// it.
17const CITY: &str = "city";
18
19/// The word before the address of a building.
20const BUILDING: &str = "building";
21
22/// The word before the address of a workshop.
23const WORKSHOP: &str = "workshop";
24
25/// What separates the kind of a scope from the address it names.
26const AT: char = ':';
27
28/// What a halt, a release or an autonomy change applies to.
29///
30/// **The wire form is what the histories already hold**: `city`,
31/// `building:<addr>`, `workshop:<addr>`. The three words are here, the
32/// parse is here, and both ends of the ledger call this type, so a scope
33/// cannot be written one way and read another.
34///
35/// A word this build does not know is refused rather than read as some
36/// other scope: a city that read an unknown scope as its own would
37/// report itself shut, and one that read it as nothing would dispatch
38/// into a scope a person stopped.
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub enum Scope {
41    /// Everything this city holds.
42    City,
43    /// One building and what is inside it.
44    Building(Address),
45    /// One workshop and what is inside it.
46    Workshop(Address),
47}
48
49impl Scope {
50    /// Whether a standing decision on this scope reaches an address.
51    ///
52    /// The city reaches everything; a building and a workshop reach
53    /// what is within them, by the containment [`Address::is_within`]
54    /// already decides, so "inside" means one thing in this city.
55    #[must_use]
56    pub fn covers(&self, addr: &Address) -> bool {
57        match self {
58            Scope::City => true,
59            Scope::Building(scope) | Scope::Workshop(scope) => addr.is_within(scope),
60        }
61    }
62
63    /// Reads back what [`fmt::Display`] wrote.
64    ///
65    /// # Errors
66    /// `E_WIRE_MISMATCH` for a word that names none of the three
67    /// shapes, and whatever [`Address::parse`] says about an address
68    /// this city cannot hold.
69    pub fn parse(raw: &str) -> Result<Scope, AxError> {
70        if raw == CITY {
71            return Ok(Scope::City);
72        }
73        let unknown = || {
74            AxError::failure(AxCode::WireMismatch, "read which scope a line names", raw)
75                .with_recovery(
76                    "open this city with the build that wrote its history: this one knows \
77                     `city`, `building:<address>` and `workshop:<address>`",
78                )
79        };
80        let (kind, addr) = raw.split_once(AT).ok_or_else(unknown)?;
81        let addr = Address::parse(addr)?;
82        match kind {
83            BUILDING => Ok(Scope::Building(addr)),
84            WORKSHOP => Ok(Scope::Workshop(addr)),
85            _ => Err(unknown()),
86        }
87    }
88}
89
90impl fmt::Display for Scope {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match self {
93            Scope::City => f.write_str(CITY),
94            Scope::Building(addr) => write!(f, "{BUILDING}{AT}{addr}", addr = addr.as_str()),
95            Scope::Workshop(addr) => write!(f, "{WORKSHOP}{AT}{addr}", addr = addr.as_str()),
96        }
97    }
98}
99
100impl Serialize for Scope {
101    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
102        serializer.serialize_str(&self.to_string())
103    }
104}
105
106impl<'de> Deserialize<'de> for Scope {
107    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
108        let raw = String::deserialize(deserializer)?;
109        Scope::parse(&raw).map_err(serde::de::Error::custom)
110    }
111}
112
113#[cfg(test)]
114#[allow(
115    clippy::unwrap_used,
116    clippy::expect_used,
117    clippy::panic,
118    clippy::indexing_slicing,
119    reason = "test code"
120)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn the_three_scopes_write_the_words_the_histories_already_hold() {
126        let building = Scope::Building(Address::parse("lab").unwrap());
127        let workshop = Scope::Workshop(Address::parse("lab/bench").unwrap());
128        assert_eq!(Scope::City.to_string(), "city");
129        assert_eq!(building.to_string(), "building:lab");
130        assert_eq!(workshop.to_string(), "workshop:lab/bench");
131        for scope in [Scope::City, building, workshop] {
132            assert_eq!(Scope::parse(&scope.to_string()).unwrap(), scope);
133            assert_eq!(
134                serde_json::to_string(&scope).unwrap(),
135                format!("\"{scope}\"")
136            );
137        }
138    }
139
140    #[test]
141    fn a_scope_reaches_what_is_inside_it_and_nothing_else() {
142        let inside = Address::parse("lab/bench.md").unwrap();
143        let elsewhere = Address::parse("hall/clerk.md").unwrap();
144        let lab = Scope::Building(Address::parse("lab").unwrap());
145        assert!(Scope::City.covers(&inside));
146        assert!(lab.covers(&inside));
147        assert!(!lab.covers(&elsewhere));
148    }
149
150    #[test]
151    fn a_word_naming_no_scope_is_refused_rather_than_guessed() {
152        for raw in ["lab", "borough:lab", "building:"] {
153            assert!(Scope::parse(raw).is_err(), "{raw} should not read");
154        }
155    }
156}