Skip to main content

kernel/
node_id.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//! A plan node's address: the dotted path that says where it hangs.
7//! The part `crates/kernel/spec/NodeId.lean` specifies this module.
8//!
9//! **A value, and that is why it is not in `kernel::plan`.** The tree
10//! decides — what may be started, what a branch is worth, which of the
11//! two exits a held node takes. This only says what a well-formed
12//! address is, and refuses everything else at the one construction
13//! point, so nothing downstream has to ask again. `plan` is shape 1;
14//! this is shape 2 (ARCHITECTURE.md section 9).
15//!
16//! The hand-written `Deserialize` is the load-bearing part. A derived
17//! one would accept any string off the wire and hand back a `NodeId`
18//! that never passed `parse`, which is the whole of what this type is
19//! for.
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::{AxCode, AxError};
24
25/// The longest branch a plan may grow. Ten levels of `1.1.1.…` is a
26/// depth no plan has ever needed and a length past which the index
27/// column stops being readable; refusing it also bounds every walk in
28/// this module.
29pub const NODE_DEPTH_MAX: usize = 10;
30
31/// A node's place in the plan, as the first column spells it: `2`,
32/// `2.3`, `2.3.1`. The parent is the prefix, so the tree needs no second
33/// field to say where a node hangs.
34///
35/// Ordering is the reading order of the table — `1`, `1.1`, `1.2`, `2` —
36/// because segment-wise comparison is exactly that order.
37#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
38#[serde(transparent)]
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40pub struct NodeId(String);
41
42impl NodeId {
43    /// Reads an index cell.
44    ///
45    /// # Errors
46    /// Refuses an empty cell, a segment that is not a number, a zero
47    /// segment (the table is one-based, and `0` next to `1` reads as a
48    /// different node than it is), a leading zero (`01` and `1` would be
49    /// two spellings of one node), and a branch deeper than
50    /// [`NODE_DEPTH_MAX`].
51    pub fn parse(raw: &str) -> Result<NodeId, AxError> {
52        let text = raw.trim();
53        let refuse = |why: String| {
54            AxError::failure(AxCode::InvalidArgs, "read a plan index", why)
55                .with_recovery("write the index as dotted numbers from one, such as `2.3.1`")
56        };
57        if text.is_empty() {
58            return Err(refuse("the cell is empty".to_owned()));
59        }
60        let segments: Vec<&str> = text.split('.').collect();
61        if segments.len() > NODE_DEPTH_MAX {
62            return Err(refuse(format!(
63                "{} levels deep, the plan holds {NODE_DEPTH_MAX}",
64                segments.len()
65            )));
66        }
67        for segment in &segments {
68            if segment.is_empty() || !segment.bytes().all(|byte| byte.is_ascii_digit()) {
69                return Err(refuse(format!("`{segment}` is not a number")));
70            }
71            if segment.starts_with('0') {
72                return Err(refuse(format!("`{segment}` has a leading zero")));
73            }
74            if segment.parse::<u32>().is_err() {
75                return Err(refuse(format!("`{segment}` does not fit a plan index")));
76            }
77        }
78        Ok(NodeId(segments.join(".")))
79    }
80
81    /// The node this one hangs under, or `None` for a top-level node.
82    #[must_use]
83    pub fn parent(&self) -> Option<NodeId> {
84        self.0
85            .rsplit_once('.')
86            .map(|(head, _)| NodeId(head.to_owned()))
87    }
88
89    /// The last segment: which child of its parent this is.
90    #[must_use]
91    pub fn ordinal(&self) -> u32 {
92        self.0
93            .rsplit_once('.')
94            .map_or(self.0.as_str(), |(_, tail)| tail)
95            .parse()
96            .unwrap_or(0)
97    }
98
99    /// How many levels down this node sits; a top-level node is 1.
100    #[must_use]
101    pub fn depth(&self) -> usize {
102        self.0.split('.').count()
103    }
104
105    /// Whether `other` hangs below this node, at any depth. A node is
106    /// not its own ancestor.
107    #[must_use]
108    pub fn is_ancestor_of(&self, other: &NodeId) -> bool {
109        other.0.len() > self.0.len()
110            && other.0.starts_with(&self.0)
111            && other.0.as_bytes().get(self.0.len()) == Some(&b'.')
112    }
113
114    /// Every node between the root and this one, closest last.
115    #[must_use]
116    pub fn ancestors(&self) -> Vec<NodeId> {
117        let mut found = Vec::new();
118        let mut walking = self.parent();
119        while let Some(held) = walking {
120            walking = held.parent();
121            found.push(held);
122        }
123        found.reverse();
124        found
125    }
126
127    /// This node's `n`-th child.
128    ///
129    /// # Errors
130    /// Refuses an ordinal of zero and a child past [`NODE_DEPTH_MAX`].
131    pub fn child(&self, ordinal: u32) -> Result<NodeId, AxError> {
132        NodeId::parse(&format!("{}.{ordinal}", self.0))
133    }
134
135    #[must_use]
136    pub fn as_str(&self) -> &str {
137        &self.0
138    }
139}
140
141impl std::fmt::Display for NodeId {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.write_str(&self.0)
144    }
145}
146
147impl<'de> Deserialize<'de> for NodeId {
148    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
149        let raw = String::deserialize(deserializer)?;
150        NodeId::parse(&raw).map_err(serde::de::Error::custom)
151    }
152}