regit_identifiers/valor.rs
1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! VALOR — Valorennummer (SIX Financial Information).
5//!
6//! A VALOR is the Swiss national securities-identifying number. It is a
7//! purely numeric identifier of variable length, from 1 to 9 digits:
8//!
9//! ```text
10//! 1 2 1 3 8 5 3
11//! └──────┬──────┘
12//! └ 1 to 9 digits [0-9] no internal structure, no check digit
13//! ```
14//!
15//! - The VALOR carries **no internal segments**: it is a single run of
16//! between 1 and 9 ASCII decimal digits.
17//! - There is **no check digit** — validation is purely structural.
18//! - A Swiss ISIN embeds the VALOR: `CH` + the VALOR left-padded with zeros
19//! to nine digits + the ISIN check digit. For example, VALOR `1213853`
20//! becomes ISIN `CH0012138530`.
21//!
22//! [`Valor::parse`] enforces every rule: a length of 1 to 9 characters and a
23//! charset of ASCII digits only.
24//!
25//! # References
26//!
27//! - SIX Financial Information, *Valorennummer* — the Swiss national
28//! securities-identification scheme.
29
30use crate::errors::ValidationError;
31
32/// A validated Valorennummer (SIX Financial Information).
33///
34/// A `Valor` can only be created by [`Valor::parse`] (or the explicitly
35/// unchecked [`Valor::from_bytes_unchecked`]), so a value of this type is a
36/// proof that it holds between 1 and 9 ASCII decimal digits. It stores the
37/// identifier inline as `[u8; 9]` plus a length, is `Copy`, and allocates
38/// nothing.
39///
40/// Because the VALOR is variable-length, the unused tail bytes of the array
41/// are always kept zeroed, so the derived `PartialEq`, `Eq`, and `Hash` are
42/// correct: two `Valor` values compare equal exactly when their digits do.
43///
44/// # Examples
45///
46/// ```
47/// use regit_identifiers::Valor;
48///
49/// let valor = Valor::parse("1213853").unwrap();
50/// assert_eq!(valor.as_str(), "1213853");
51/// assert_eq!(valor.len(), 7);
52/// assert_eq!(valor.as_u64(), 1_213_853);
53/// ```
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub struct Valor {
56 /// The validated ASCII digit bytes, left-aligned; unused tail is zeroed.
57 bytes: [u8; Self::MAX_LENGTH],
58 /// The number of significant bytes in `bytes`, always `1..=9`.
59 len: u8,
60}
61
62impl Valor {
63 /// The minimum number of digits in a VALOR.
64 pub const MIN_LENGTH: usize = 1;
65
66 /// The maximum number of digits in a VALOR.
67 pub const MAX_LENGTH: usize = 9;
68
69 /// Parses and fully validates a VALOR.
70 ///
71 /// Validation is strict and, in order: the input must be 1 to 9
72 /// characters; every character must be an ASCII decimal digit. There is
73 /// no check digit.
74 ///
75 /// # Errors
76 ///
77 /// - [`ValidationError::Structure`] with rule `"VALOR must be 1 to 9
78 /// digits"` if the input is empty or longer than nine characters.
79 /// - [`ValidationError::InvalidCharacter`] if a character is not an ASCII
80 /// digit (this also rejects any non-ASCII character).
81 ///
82 /// # Examples
83 ///
84 /// ```
85 /// use regit_identifiers::Valor;
86 /// use regit_identifiers::errors::ValidationError;
87 ///
88 /// assert!(Valor::parse("1213853").is_ok());
89 ///
90 /// // A non-digit character is rejected, not silently accepted.
91 /// assert_eq!(
92 /// Valor::parse("1213853A"),
93 /// Err(ValidationError::InvalidCharacter { position: 8, found: 'A' }),
94 /// );
95 /// ```
96 pub fn parse(s: &str) -> Result<Self, ValidationError> {
97 // A VALOR is 1 to 9 characters; any other length is a structural
98 // violation rather than a per-position length mismatch.
99 let found = s.chars().count();
100 if !(Self::MIN_LENGTH..=Self::MAX_LENGTH).contains(&found) {
101 return Err(ValidationError::Structure {
102 rule: "VALOR must be 1 to 9 digits",
103 });
104 }
105 // Every character must be an ASCII digit. A non-ASCII character fails
106 // the predicate and is rejected here.
107 for (i, ch) in s.chars().enumerate() {
108 if !ch.is_ascii_digit() {
109 return Err(ValidationError::InvalidCharacter {
110 position: i + 1,
111 found: ch,
112 });
113 }
114 }
115 // Every character is ASCII, so the string is `found` ASCII bytes that
116 // fit the array; the unused tail stays zeroed.
117 let mut bytes = [0u8; Self::MAX_LENGTH];
118 if let Some(slot) = bytes.get_mut(..found) {
119 slot.copy_from_slice(s.as_bytes());
120 }
121 Ok(Self {
122 bytes,
123 len: u8::try_from(found).unwrap_or(0),
124 })
125 }
126
127 /// Validates a VALOR without constructing one.
128 ///
129 /// Equivalent to `Valor::parse(s).map(|_| ())`; use it when only the
130 /// verdict is needed.
131 ///
132 /// # Errors
133 ///
134 /// Returns the same [`ValidationError`] variants as [`Valor::parse`].
135 ///
136 /// # Examples
137 ///
138 /// ```
139 /// use regit_identifiers::Valor;
140 ///
141 /// assert!(Valor::validate("908440").is_ok());
142 /// assert!(Valor::validate("1234567890").is_err());
143 /// ```
144 pub fn validate(s: &str) -> Result<(), ValidationError> {
145 Self::parse(s).map(|_| ())
146 }
147
148 /// Wraps raw bytes as a `Valor` without any validation.
149 ///
150 /// The caller asserts that the first `len` bytes of `bytes` are ASCII
151 /// decimal digits, that `len` is in `1..=9`, and that the remaining tail
152 /// bytes are zero. This exists for reconstructing a `Valor` from bytes
153 /// that were validated earlier; prefer [`Valor::parse`] for any untrusted
154 /// input.
155 ///
156 /// # Examples
157 ///
158 /// ```
159 /// use regit_identifiers::Valor;
160 ///
161 /// let valor = Valor::from_bytes_unchecked(*b"908440\0\0\0", 6);
162 /// assert_eq!(valor.as_str(), "908440");
163 /// ```
164 #[must_use]
165 pub const fn from_bytes_unchecked(bytes: [u8; Self::MAX_LENGTH], len: u8) -> Self {
166 Self { bytes, len }
167 }
168
169 /// Returns the VALOR as a string slice.
170 ///
171 /// # Examples
172 ///
173 /// ```
174 /// use regit_identifiers::Valor;
175 ///
176 /// assert_eq!(Valor::parse("24476758").unwrap().as_str(), "24476758");
177 /// ```
178 #[must_use]
179 #[inline]
180 pub fn as_str(&self) -> &str {
181 core::str::from_utf8(self.as_bytes()).unwrap_or("")
182 }
183
184 /// Returns the VALOR as its raw ASCII digit bytes.
185 ///
186 /// The slice has [`Valor::len`] elements; the zeroed tail of the backing
187 /// array is not included.
188 ///
189 /// # Examples
190 ///
191 /// ```
192 /// use regit_identifiers::Valor;
193 ///
194 /// assert_eq!(Valor::parse("908440").unwrap().as_bytes(), b"908440");
195 /// ```
196 #[must_use]
197 #[inline]
198 pub fn as_bytes(&self) -> &[u8] {
199 self.bytes.get(..self.len as usize).unwrap_or(&[])
200 }
201
202 /// Returns the number of digits in the VALOR, always in `1..=9`.
203 ///
204 /// # Examples
205 ///
206 /// ```
207 /// use regit_identifiers::Valor;
208 ///
209 /// assert_eq!(Valor::parse("1213853").unwrap().len(), 7);
210 /// ```
211 #[must_use]
212 #[inline]
213 pub fn len(&self) -> usize {
214 self.len as usize
215 }
216
217 /// Always `false` — a valid VALOR is never empty (it has 1 to 9
218 /// digits). Provided for API consistency with [`Valor::len`].
219 ///
220 /// # Examples
221 ///
222 /// ```
223 /// use regit_identifiers::Valor;
224 ///
225 /// assert!(!Valor::parse("1213853").unwrap().is_empty());
226 /// ```
227 #[must_use]
228 #[inline]
229 pub fn is_empty(&self) -> bool {
230 self.len == 0
231 }
232
233 /// Returns the VALOR's numeric value.
234 ///
235 /// A VALOR is at most nine decimal digits, so its value always fits in a
236 /// `u64` (and indeed in a `u32`); leading zeros are absorbed.
237 ///
238 /// # Examples
239 ///
240 /// ```
241 /// use regit_identifiers::Valor;
242 ///
243 /// assert_eq!(Valor::parse("1213853").unwrap().as_u64(), 1_213_853);
244 /// assert_eq!(Valor::parse("000123").unwrap().as_u64(), 123);
245 /// ```
246 #[must_use]
247 #[inline]
248 pub fn as_u64(&self) -> u64 {
249 let mut value: u64 = 0;
250 for &b in self.as_bytes() {
251 value = value * 10 + u64::from(b.wrapping_sub(b'0'));
252 }
253 value
254 }
255
256 /// Returns the VALOR's numeric value as a `u32`.
257 ///
258 /// A nine-digit decimal fits comfortably in a `u32` (the maximum
259 /// `999_999_999` is well under `2^32 ≈ 4.29 × 10^9`); this is the
260 /// narrower companion of [`Valor::as_u64`].
261 ///
262 /// # Examples
263 ///
264 /// ```
265 /// use regit_identifiers::Valor;
266 ///
267 /// assert_eq!(Valor::parse("1213853").unwrap().as_u32(), 1_213_853);
268 /// assert_eq!(Valor::parse("999999999").unwrap().as_u32(), 999_999_999);
269 /// ```
270 #[must_use]
271 #[inline]
272 pub fn as_u32(&self) -> u32 {
273 // The maximum 9-digit decimal (999_999_999) is strictly below `2^32`,
274 // so the conversion can never truncate.
275 u32::try_from(self.as_u64()).unwrap_or(0)
276 }
277}
278
279impl core::fmt::Display for Valor {
280 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
281 f.write_str(self.as_str())
282 }
283}
284
285impl core::str::FromStr for Valor {
286 type Err = ValidationError;
287
288 fn from_str(s: &str) -> Result<Self, Self::Err> {
289 Self::parse(s)
290 }
291}
292
293impl AsRef<str> for Valor {
294 fn as_ref(&self) -> &str {
295 self.as_str()
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::test_support::display;
303 use core::str::FromStr;
304
305 /// Real, well-known VALORs used as regression anchors.
306 const GOLDEN: &[&str] = &["1213853", "908440", "24476758"];
307
308 #[test]
309 fn parses_golden_valors() {
310 for &s in GOLDEN {
311 let valor = Valor::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
312 assert_eq!(valor.as_str(), s);
313 }
314 }
315
316 #[test]
317 fn accepts_minimum_and_maximum_length() {
318 let one = Valor::parse("7").unwrap();
319 assert_eq!(one.len(), 1);
320 assert_eq!(one.as_str(), "7");
321
322 let nine = Valor::parse("123456789").unwrap();
323 assert_eq!(nine.len(), 9);
324 assert_eq!(nine.as_str(), "123456789");
325 assert_eq!(Valor::MIN_LENGTH, 1);
326 assert_eq!(Valor::MAX_LENGTH, 9);
327 }
328
329 #[test]
330 fn accessors() {
331 let valor = Valor::parse("24476758").unwrap();
332 assert_eq!(valor.as_str(), "24476758");
333 assert_eq!(valor.as_bytes(), b"24476758");
334 assert_eq!(valor.len(), 8);
335 assert_eq!(valor.as_u64(), 24_476_758);
336 }
337
338 #[test]
339 fn as_u64_computes_numeric_value() {
340 assert_eq!(Valor::parse("1213853").unwrap().as_u64(), 1_213_853);
341 assert_eq!(Valor::parse("908440").unwrap().as_u64(), 908_440);
342 assert_eq!(Valor::parse("0").unwrap().as_u64(), 0);
343 // Leading zeros are absorbed into the numeric value.
344 assert_eq!(Valor::parse("000123").unwrap().as_u64(), 123);
345 // The largest possible VALOR still fits in a u64.
346 assert_eq!(Valor::parse("999999999").unwrap().as_u64(), 999_999_999);
347 }
348
349 #[test]
350 fn as_u32_agrees_with_as_u64() {
351 for s in ["0", "1213853", "908440", "999999999", "000123"] {
352 let v = Valor::parse(s).unwrap();
353 assert_eq!(u64::from(v.as_u32()), v.as_u64(), "value {s}");
354 }
355 assert_eq!(Valor::parse("999999999").unwrap().as_u32(), 999_999_999);
356 }
357
358 #[test]
359 fn rejects_empty_input() {
360 assert_eq!(
361 Valor::parse(""),
362 Err(ValidationError::Structure {
363 rule: "VALOR must be 1 to 9 digits",
364 })
365 );
366 }
367
368 #[test]
369 fn rejects_too_long() {
370 assert_eq!(
371 Valor::parse("1234567890"),
372 Err(ValidationError::Structure {
373 rule: "VALOR must be 1 to 9 digits",
374 })
375 );
376 }
377
378 #[test]
379 fn rejects_non_digit_character() {
380 assert_eq!(
381 Valor::parse("1213853A"),
382 Err(ValidationError::InvalidCharacter {
383 position: 8,
384 found: 'A',
385 })
386 );
387 }
388
389 #[test]
390 fn rejects_non_digit_at_first_position() {
391 assert!(matches!(
392 Valor::parse("X12345"),
393 Err(ValidationError::InvalidCharacter { position: 1, .. })
394 ));
395 }
396
397 #[test]
398 fn rejects_non_ascii_without_panic() {
399 // A multi-byte character must be rejected cleanly.
400 assert!(Valor::parse("12345é").is_err());
401 assert!(Valor::parse("é").is_err());
402 }
403
404 #[test]
405 fn validate_matches_parse() {
406 assert!(Valor::validate("908440").is_ok());
407 assert!(Valor::validate("").is_err());
408 assert!(Valor::validate("1234567890").is_err());
409 }
410
411 #[test]
412 fn round_trips_through_str() {
413 for &s in GOLDEN {
414 assert_eq!(Valor::parse(s).unwrap().as_str(), s);
415 }
416 }
417
418 #[test]
419 fn from_str_matches_parse() {
420 assert_eq!(Valor::from_str("1213853"), Valor::parse("1213853"));
421 assert!(Valor::from_str("nonsense").is_err());
422 }
423
424 #[test]
425 fn display_renders_identifier() {
426 let valor = Valor::parse("1213853").unwrap();
427 assert_eq!(display(valor).as_str(), "1213853");
428 }
429
430 #[test]
431 fn as_ref_str() {
432 let valor = Valor::parse("908440").unwrap();
433 let s: &str = valor.as_ref();
434 assert_eq!(s, "908440");
435 }
436
437 #[test]
438 fn from_bytes_unchecked_round_trip() {
439 let valor = Valor::from_bytes_unchecked(*b"908440\0\0\0", 6);
440 assert_eq!(valor, Valor::parse("908440").unwrap());
441 }
442
443 #[test]
444 fn unused_tail_is_zeroed_for_eq_and_hash() {
445 // Two values of differing length must never compare equal, and a
446 // value parsed twice must compare equal — the zeroed tail guarantees
447 // the derived PartialEq/Eq/Hash are correct.
448 let a = Valor::parse("12345").unwrap();
449 let b = Valor::parse("12345").unwrap();
450 assert_eq!(a, b);
451 assert_ne!(a, Valor::parse("123456").unwrap());
452 assert_ne!(a, Valor::parse("1234").unwrap());
453 }
454
455 #[test]
456 fn is_copy_and_eq_and_hashable() {
457 let a = Valor::parse("1213853").unwrap();
458 let b = a; // Copy
459 assert_eq!(a, b);
460 assert_ne!(a, Valor::parse("908440").unwrap());
461 // Usable as a map key (Eq + Hash) — checked by constructing a slice.
462 let keys = [a, b];
463 assert_eq!(keys[0], keys[1]);
464 }
465}