Skip to main content

ito_common/id/
mod.rs

1//! Identifier parsing and lightweight ID heuristics.
2
3mod change_id;
4mod error;
5mod module_id;
6mod spec_id;
7pub(crate) mod sub_module_id;
8
9pub use change_id::parse_change_id;
10pub use change_id::{ChangeId, ParsedChangeId};
11pub use error::IdParseError;
12pub use module_id::parse_module_id;
13pub use module_id::{ModuleId, ParsedModuleId};
14pub use spec_id::parse_spec_id;
15pub use spec_id::{ParsedSpecId, SpecId};
16pub use sub_module_id::parse_sub_module_id;
17pub use sub_module_id::{ParsedSubModuleId, SubModuleId};
18
19/// Returns `true` when `s` is non-empty and contains only ASCII digits.
20///
21/// Used by the ID parsers to validate numeric segments without allocating.
22pub(crate) fn is_all_ascii_digits(s: &str) -> bool {
23    if s.is_empty() {
24        return false;
25    }
26    for c in s.chars() {
27        if !c.is_ascii_digit() {
28            return false;
29        }
30    }
31    true
32}
33
34/// The kind of an Ito identifier, as determined by [`classify_id`].
35///
36/// Use this when you need to route an opaque user-supplied string to the
37/// correct parser without attempting a full parse first.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ItoIdKind {
40    /// A module identifier: `NNN` or `NNN_name` (e.g., `"005"`, `"005_dev-tooling"`).
41    ModuleId,
42    /// A sub-module identifier: `NNN.SS` or `NNN.SS_name` (e.g., `"005.01"`).
43    SubModuleId,
44    /// A change identifier in the legacy module format: `NNN-NN_name` (e.g., `"005-01_my-change"`).
45    ModuleChangeId,
46    /// A change identifier in the sub-module format: `NNN.SS-NN_name` (e.g., `"005.01-03_my-change"`).
47    SubModuleChangeId,
48}
49
50/// Classify an opaque identifier string into one of the four [`ItoIdKind`] variants.
51///
52/// This is a lightweight structural heuristic. It does **not** validate the
53/// identifier; use the appropriate `parse_*` function for full validation.
54///
55/// The classification inspects the portion of the string **before** the first
56/// `_` (the name separator), so hyphens inside name suffixes (e.g.,
57/// `005_dev-tooling`) do not affect the result.
58///
59/// | Prefix structure         | Kind                |
60/// |--------------------------|---------------------|
61/// | `NNN.SS-NN`              | `SubModuleChangeId` |
62/// | `NNN.SS`                 | `SubModuleId`       |
63/// | `NNN-NN`                 | `ModuleChangeId`    |
64/// | `NNN`                    | `ModuleId`          |
65pub fn classify_id(input: &str) -> ItoIdKind {
66    // Inspect only the prefix before the first `_` so that hyphens inside
67    // name suffixes (e.g., "005_dev-tooling") do not affect classification.
68    let prefix = match input.split_once('_') {
69        Some((left, _)) => left,
70        None => input,
71    };
72
73    let has_dot = prefix.contains('.');
74    let has_hyphen = prefix.contains('-');
75
76    if has_dot && has_hyphen {
77        ItoIdKind::SubModuleChangeId
78    } else if has_dot {
79        ItoIdKind::SubModuleId
80    } else if has_hyphen {
81        ItoIdKind::ModuleChangeId
82    } else {
83        ItoIdKind::ModuleId
84    }
85}
86
87/// Quick heuristic used by CLI prompts to detect a likely change id.
88///
89/// Returns `true` for both legacy `NNN-NN_name` and sub-module `NNN.SS-NN_name`
90/// formats.
91pub fn looks_like_change_id(input: &str) -> bool {
92    let input = input.trim();
93    if input.is_empty() {
94        return false;
95    }
96
97    let mut digit_prefix_len = 0usize;
98    let mut has_hyphen = false;
99    let mut has_underscore = false;
100
101    for ch in input.chars() {
102        if ch.is_ascii_digit() && digit_prefix_len == 0 {
103            digit_prefix_len = 1;
104            continue;
105        }
106
107        if ch.is_ascii_digit() && digit_prefix_len > 0 {
108            digit_prefix_len += 1;
109            continue;
110        }
111
112        if digit_prefix_len == 0 {
113            break;
114        }
115
116        match ch {
117            '-' => has_hyphen = true,
118            '_' => has_underscore = true,
119            '.' => {}
120            _ => {}
121        }
122    }
123
124    digit_prefix_len > 0 && has_hyphen && has_underscore
125}
126
127/// Quick heuristic used by CLI prompts to detect a likely module id.
128pub fn looks_like_module_id(input: &str) -> bool {
129    let input = input.trim();
130    let Some(first) = input.chars().next() else {
131        return false;
132    };
133    first.is_ascii_digit()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn looks_like_change_id_requires_digits_hyphen_and_underscore() {
142        assert!(looks_like_change_id("001-02_hello"));
143        assert!(!looks_like_change_id("-02_hello"));
144        assert!(!looks_like_change_id("001_hello"));
145        assert!(!looks_like_change_id("001-02hello"));
146        assert!(!looks_like_change_id("abc-02_hello"));
147    }
148
149    #[test]
150    fn looks_like_change_id_recognizes_sub_module_format() {
151        assert!(looks_like_change_id("005.01-03_my-change"));
152        assert!(looks_like_change_id("5.1-2_foo"));
153    }
154
155    #[test]
156    fn looks_like_module_id_is_digit_prefixed() {
157        assert!(looks_like_module_id("001"));
158        assert!(looks_like_module_id("001_demo"));
159        assert!(looks_like_module_id(" 001_demo "));
160        assert!(!looks_like_module_id(""));
161        assert!(!looks_like_module_id("demo"));
162        assert!(!looks_like_module_id("_001_demo"));
163    }
164
165    #[test]
166    fn classify_id_module_change_id() {
167        assert_eq!(classify_id("005-01_my-change"), ItoIdKind::ModuleChangeId);
168        assert_eq!(classify_id("1-2_foo"), ItoIdKind::ModuleChangeId);
169    }
170
171    #[test]
172    fn classify_id_sub_module_change_id() {
173        assert_eq!(
174            classify_id("005.01-03_my-change"),
175            ItoIdKind::SubModuleChangeId
176        );
177        assert_eq!(classify_id("5.1-2_foo"), ItoIdKind::SubModuleChangeId);
178    }
179
180    #[test]
181    fn classify_id_sub_module_id() {
182        assert_eq!(classify_id("005.01"), ItoIdKind::SubModuleId);
183        assert_eq!(classify_id("005.01_core-api"), ItoIdKind::SubModuleId);
184    }
185
186    #[test]
187    fn classify_id_module_id() {
188        assert_eq!(classify_id("005"), ItoIdKind::ModuleId);
189        assert_eq!(classify_id("005_dev-tooling"), ItoIdKind::ModuleId);
190        assert_eq!(classify_id("1"), ItoIdKind::ModuleId);
191    }
192
193    #[test]
194    fn classify_id_hyphen_without_underscore_is_module_change_id() {
195        // "005-01" has a hyphen in the prefix → classified as ModuleChangeId.
196        // It is not a *valid* change id (missing name), but structurally it
197        // looks like one. Full validation is left to parse_change_id.
198        assert_eq!(classify_id("005-01"), ItoIdKind::ModuleChangeId);
199    }
200}