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}