Skip to main content

ethos_core/
ids.rs

1/*
2 * Copyright 2026 The Ethos maintainers
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! Deterministic ID formatting/parsing (ids-v1, determinism contract §5).
18//! IDs are functions of canonical order — never random, never time-based.
19
20use crate::error::{ErrorCode, EthosError};
21
22macro_rules! id_kind {
23    ($fmt_fn:ident, $parse_fn:ident, $prefix:literal, $width:literal, $max:literal, $doc:literal) => {
24        #[doc = $doc]
25        ///
26        /// Errors with `internal_error` on width overflow (resource limits bound real
27        /// documents far below these widths).
28        pub fn $fmt_fn(n: u32) -> Result<String, EthosError> {
29            if !(1..=$max).contains(&n) {
30                return Err(EthosError::new(
31                    ErrorCode::InternalError,
32                    concat!("id overflow: ", $prefix, " width ", $width),
33                ));
34            }
35            Ok(format!(concat!($prefix, "{:0", $width, "}"), n))
36        }
37
38        /// Parse the 1-based ordinal out of an id; `None` when malformed.
39        pub fn $parse_fn(id: &str) -> Option<u32> {
40            let digits = id.strip_prefix($prefix)?;
41            if digits.len() != $width || !digits.bytes().all(|b| b.is_ascii_digit()) {
42                return None;
43            }
44            let n: u32 = digits.parse().ok()?;
45            (1..=$max).contains(&n).then_some(n)
46        }
47    };
48}
49
50id_kind!(
51    page_id,
52    parse_page_id,
53    "p",
54    4,
55    9999,
56    "Format a page id (`p%04d`, 1-based original document index)."
57);
58id_kind!(
59    element_id,
60    parse_element_id,
61    "e",
62    6,
63    999_999,
64    "Format an element id (`e%06d`, reading order)."
65);
66id_kind!(
67    span_id,
68    parse_span_id,
69    "s",
70    6,
71    999_999,
72    "Format a span id (`s%06d`, content-stream order)."
73);
74id_kind!(
75    table_id,
76    parse_table_id,
77    "t",
78    4,
79    9999,
80    "Format a table id (`t%04d`, reading-order anchor)."
81);
82id_kind!(
83    chunk_id,
84    parse_chunk_id,
85    "c",
86    6,
87    999_999,
88    "Format a chunk id (`c%06d`, chunker emission order)."
89);
90id_kind!(
91    region_id,
92    parse_region_id,
93    "r",
94    4,
95    9999,
96    "Format a region id (`r%04d`, page/y/x/stream order)."
97);
98id_kind!(
99    warning_id,
100    parse_warning_id,
101    "w",
102    4,
103    9999,
104    "Format a warning id (`w%04d`, sorted emission — contract §5)."
105);
106id_kind!(
107    finding_id,
108    parse_finding_id,
109    "f",
110    4,
111    9999,
112    "Format a security finding id (`f%04d`)."
113);
114id_kind!(
115    check_id,
116    parse_check_id,
117    "v",
118    4,
119    9999,
120    "Format a verification check id (`v%04d`, input order)."
121);
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use proptest::prelude::*;
127
128    #[test]
129    fn formats_match_contract() {
130        assert_eq!(page_id(1).unwrap(), "p0001");
131        assert_eq!(element_id(42).unwrap(), "e000042");
132        assert_eq!(span_id(999_999).unwrap(), "s999999");
133        assert_eq!(warning_id(7).unwrap(), "w0007");
134        assert!(page_id(0).is_err());
135        assert!(page_id(10_000).is_err());
136        assert!(element_id(1_000_000).is_err());
137    }
138
139    #[test]
140    fn parse_rejects_malformed() {
141        assert_eq!(parse_page_id("p0001"), Some(1));
142        assert_eq!(parse_page_id("p001"), None);
143        assert_eq!(parse_page_id("p00001"), None);
144        assert_eq!(parse_page_id("q0001"), None);
145        assert_eq!(parse_page_id("p0000"), None);
146        assert_eq!(parse_element_id("e000042"), Some(42));
147        assert_eq!(parse_element_id("e00004x"), None);
148    }
149
150    proptest! {
151        #[test]
152        fn round_trip(n in 1u32..=9999) {
153            prop_assert_eq!(parse_page_id(&page_id(n).unwrap()), Some(n));
154            prop_assert_eq!(parse_table_id(&table_id(n).unwrap()), Some(n));
155            prop_assert_eq!(parse_warning_id(&warning_id(n).unwrap()), Some(n));
156        }
157    }
158}