Skip to content

Commit 6f1b5a4

Browse files
Feat/split system and validator (#109)
* Move SNOMED syntax rules into codelist-systems-rs * Move OPCS syntax rules into codelist-systems-rs * Move CTV3 syntax rules into codelist-systems-rs * Move coding-system tests inline * Add proptest properties to coding systems
1 parent e5fdc07 commit 6f1b5a4

9 files changed

Lines changed: 709 additions & 90 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc b8fd1ddd503d1efb257d26464f3b83c1cda33850554b076891a2cb0eedd14b9d # shrinks to prefix = "A꘠𜳰", illegal = "#"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 210afe1c3c37afae7f591bc2d085980e1b2fcfbdd7f60a0a026b00c204949e86 # shrinks to prefix = "A٠୦", trail = "!"
8+
cc 8608455b24a86ddb96b487e0f62dea79e29e5f8a5081f34e991271198f340204 # shrinks to s = "A00.00"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 72b50e46f8988e0f128a3c3245e0edc9bc82b82a2356438e8430077315aa6e98 # shrinks to s = "٠"
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//! CTV3.
2+
//!
3+
//! Rules (preserved from the legacy `ctv3_validator`):
4+
//! 1. Exactly 5 characters.
5+
//! 2. Allowed characters: `a-z`, `A-Z`, `0-9`, and `.`.
6+
//! 3. N alphanumeric characters followed by `5 - N` trailing dots (N = 0..=5).
7+
//!
8+
//! CTV3 is case-sensitive, so `normalize` trims whitespace only —
9+
//! it does not fold case the way ICD10 and OPCS do.
10+
11+
use std::sync::LazyLock;
12+
13+
use codelist_rs::types::{Code, CodeSystemId, NormalizedCode};
14+
use regex::Regex;
15+
16+
use crate::{
17+
core::CodingSystem,
18+
errors::{SystemError, ValidationError},
19+
};
20+
21+
/// CTV3 coding system marker.
22+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23+
pub struct Ctv3;
24+
25+
static CTV3_REGEX: LazyLock<Regex> = LazyLock::new(|| {
26+
Regex::new(
27+
r"^(?:[a-zA-Z0-9]{5}|[a-zA-Z0-9]{4}\.|[a-zA-Z0-9]{3}\.{2}|[a-zA-Z0-9]{2}\.{3}|[a-zA-Z0-9]\.{4}|\.{5})$",
28+
)
29+
.expect("CTV3 regex compiles")
30+
});
31+
32+
impl CodingSystem for Ctv3 {
33+
const ID: CodeSystemId = CodeSystemId("CTV3");
34+
35+
fn normalize(code: &Code) -> Result<NormalizedCode, SystemError> {
36+
// CTV3 is case-sensitive — no case folding, trim only.
37+
let s = code.as_str().trim().to_string();
38+
if s.is_empty() {
39+
return Err(SystemError::normalisation("CTV3", "empty after trim"));
40+
}
41+
Ok(NormalizedCode::from(s))
42+
}
43+
44+
fn validate_syntax(code: &NormalizedCode) -> Result<(), ValidationError> {
45+
let len = code.as_str().len();
46+
if len != 5 {
47+
return Err(ValidationError::invalid_length(
48+
code.as_str().to_string(),
49+
"CTV3".to_string(),
50+
format!("length must be exactly 5 (got {len})"),
51+
));
52+
}
53+
if !CTV3_REGEX.is_match(code.as_str()) {
54+
return Err(ValidationError::invalid_contents(
55+
code.as_str().to_string(),
56+
"CTV3".to_string(),
57+
"does not match expected format".to_string(),
58+
));
59+
}
60+
Ok(())
61+
}
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use codelist_rs::types::Code;
67+
use proptest::prelude::*;
68+
69+
use super::*;
70+
71+
#[test]
72+
fn valid_ctv3_codes_pass_syntax() {
73+
for ok in ["Af918", "ABb..", "alkif", "F....", "bn89.", "Me...", "99999", "....."] {
74+
let c = Code::from(ok);
75+
let n = Ctv3::normalize(&c).unwrap();
76+
Ctv3::validate_syntax(&n).unwrap_or_else(|_| panic!("{ok} should pass"));
77+
}
78+
}
79+
80+
#[test]
81+
fn too_short_ctv3_codes_fail_with_invalid_length() {
82+
for bad in ["Af.", "A00A", "10"] {
83+
let c = Code::from(bad);
84+
let n = Ctv3::normalize(&c).unwrap();
85+
let err = Ctv3::validate_syntax(&n).unwrap_err();
86+
assert!(
87+
matches!(err, crate::errors::ValidationError::InvalidLength { .. }),
88+
"{bad} should fail with InvalidLength"
89+
);
90+
}
91+
}
92+
93+
#[test]
94+
fn too_long_ctv3_codes_fail_with_invalid_length() {
95+
for bad in ["A009000000", "9874ji", "Q90....."] {
96+
let c = Code::from(bad);
97+
let n = Ctv3::normalize(&c).unwrap();
98+
let err = Ctv3::validate_syntax(&n).unwrap_err();
99+
assert!(
100+
matches!(err, crate::errors::ValidationError::InvalidLength { .. }),
101+
"{bad} should fail with InvalidLength"
102+
);
103+
}
104+
}
105+
106+
#[test]
107+
fn bad_content_ctv3_codes_fail_with_invalid_contents() {
108+
for bad in [".a009", "10a.f", "Af!!!", "A..9k", "..9jJ", "A00.l"] {
109+
let c = Code::from(bad);
110+
let n = Ctv3::normalize(&c).unwrap();
111+
let err = Ctv3::validate_syntax(&n).unwrap_err();
112+
assert!(
113+
matches!(err, crate::errors::ValidationError::InvalidContents { .. }),
114+
"{bad} should fail with InvalidContents"
115+
);
116+
}
117+
}
118+
119+
#[test]
120+
fn ctv3_normalize_preserves_case() {
121+
// CTV3 is case-sensitive: normalize must not fold to uppercase.
122+
// "Af918" must remain "Af918", not become "AF918" as ICD10/OPCS would.
123+
let c = Code::from("Af918");
124+
let n = Ctv3::normalize(&c).unwrap();
125+
assert_eq!(n.as_str(), "Af918");
126+
}
127+
128+
#[test]
129+
fn ctv3_normalize_trims_whitespace() {
130+
let c = Code::from(" Af918 ");
131+
let n = Ctv3::normalize(&c).unwrap();
132+
assert_eq!(n.as_str(), "Af918");
133+
Ctv3::validate_syntax(&n).unwrap();
134+
}
135+
136+
#[test]
137+
fn ctv3_normalize_rejects_empty_after_trim() {
138+
let c = Code::from(" ");
139+
assert!(Ctv3::normalize(&c).is_err());
140+
}
141+
142+
fn valid_ctv3() -> impl Strategy<Value = String> {
143+
(0u32..=5).prop_flat_map(|n| {
144+
proptest::string::string_regex(&format!("[a-zA-Z0-9]{{{n}}}"))
145+
.unwrap()
146+
.prop_map(move |s| format!("{s}{}", ".".repeat((5 - n) as usize)))
147+
})
148+
}
149+
150+
proptest! {
151+
#[test]
152+
fn valid_shape_ctv3_validates_ok(s in valid_ctv3()) {
153+
let c = Code::from(s.as_str());
154+
let n = Ctv3::normalize(&c).unwrap();
155+
prop_assert!(Ctv3::validate_syntax(&n).is_ok());
156+
}
157+
158+
#[test]
159+
fn ctv3_disallowed_chars_fail_invalid_contents(
160+
illegal in r"[!@#$%]",
161+
suffix in r"[a-zA-Z0-9.]{4}",
162+
) {
163+
let s = format!("{illegal}{suffix}");
164+
let c = Code::from(s.as_str());
165+
let n = Ctv3::normalize(&c).unwrap();
166+
let err = Ctv3::validate_syntax(&n).unwrap_err();
167+
let is_invalid_contents = matches!(err, ValidationError::InvalidContents { .. });
168+
prop_assert!(is_invalid_contents);
169+
}
170+
171+
#[test]
172+
fn ctv3_out_of_range_length_fails_invalid_length(
173+
s in prop_oneof![r"[a-zA-Z0-9.]{1,4}", r"[a-zA-Z0-9.]{6,12}"],
174+
) {
175+
let c = Code::from(s.as_str());
176+
let n = Ctv3::normalize(&c).unwrap();
177+
let err = Ctv3::validate_syntax(&n).unwrap_err();
178+
let is_invalid_length = matches!(err, ValidationError::InvalidLength { .. });
179+
prop_assert!(is_invalid_length);
180+
}
181+
182+
#[test]
183+
fn ctv3_trim_idempotent(
184+
s in valid_ctv3(),
185+
left in 0usize..5,
186+
right in 0usize..5,
187+
) {
188+
let padded = format!("{}{s}{}", " ".repeat(left), " ".repeat(right));
189+
let base = Ctv3::normalize(&Code::from(s.as_str())).unwrap();
190+
let pad = Ctv3::normalize(&Code::from(padded.as_str())).unwrap();
191+
prop_assert_eq!(base.as_str(), pad.as_str());
192+
}
193+
}
194+
}

rust/codelist-systems-rs/src/icd10.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,140 @@ impl XExtensible for Icd10 {
7878
NormalizedCode::from(format!("{}X", code.as_str()))
7979
}
8080
}
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use codelist_rs::types::Code;
85+
use proptest::prelude::*;
86+
87+
use super::*;
88+
89+
#[test]
90+
fn valid_icd10_codes_pass_syntax() {
91+
for ok in ["A54", "A37", "A05", "B74.0", "N40", "M10", "Q90", "K02"] {
92+
let c = Code::from(ok);
93+
let n = Icd10::normalize(&c).unwrap();
94+
Icd10::validate_syntax(&n).unwrap_or_else(|_| panic!("{ok} should pass"));
95+
}
96+
}
97+
98+
#[test]
99+
fn invalid_icd10_codes_fail_syntax() {
100+
// Previously-invalid codes from the existing ICD10 validator test-suite.
101+
// Note: "a54" was invalid in the old validator (rejected lowercase), but is
102+
// now valid because `normalize` uppercases before `validate_syntax` runs.
103+
// That's the intended design change. Hence it's not in this list.
104+
for bad in ["A009000000", "1009", "AA09", "A0A9", "A00A", "A00.A", "A00X12", "A00.4AA"] {
105+
let c = Code::from(bad);
106+
let n = Icd10::normalize(&c).unwrap();
107+
assert!(Icd10::validate_syntax(&n).is_err(), "{bad} should fail");
108+
}
109+
}
110+
111+
#[test]
112+
fn too_long_icd10_codes_report_length_error() {
113+
let c = Code::from("A009000000");
114+
let n = Icd10::normalize(&c).unwrap();
115+
let err = Icd10::validate_syntax(&n).unwrap_err();
116+
assert!(matches!(err, crate::errors::ValidationError::InvalidLength { .. }));
117+
}
118+
119+
#[test]
120+
fn icd10_normalize_upper_cases_and_trims() {
121+
let c = Code::from(" a54 ");
122+
let n = Icd10::normalize(&c).unwrap();
123+
assert_eq!(n.as_str(), "A54");
124+
Icd10::validate_syntax(&n).unwrap();
125+
}
126+
127+
#[test]
128+
fn icd10_normalize_rejects_empty_code() {
129+
let c = Code::from(" ");
130+
assert!(Icd10::normalize(&c).is_err());
131+
}
132+
133+
fn valid_icd10() -> &'static str {
134+
r"[A-Z][0-9]{2}(X|(\.[0-9]{1,3})?|[0-9]{1,4})?"
135+
}
136+
137+
proptest! {
138+
#[test]
139+
fn arbitrary_strings_match_regex_iff_validate_ok(s in "[A-Za-z0-9. X]{0,10}") {
140+
let c = Code::from(s.as_str());
141+
let Ok(n) = Icd10::normalize(&c) else { return Ok(()); };
142+
let regex_ok = regex::Regex::new(r"^[A-Z]\d{2}(X|(\.\d{1,3})?|\d{1,4})?$")
143+
.unwrap()
144+
.is_match(n.as_str()) && n.as_str().len() <= 7;
145+
let validate_ok = Icd10::validate_syntax(&n).is_ok();
146+
prop_assert_eq!(regex_ok, validate_ok);
147+
}
148+
149+
#[test]
150+
fn valid_shape_icd10_validates_ok(s in valid_icd10()) {
151+
let c = Code::from(s.as_str());
152+
let n = Icd10::normalize(&c).unwrap();
153+
prop_assert!(Icd10::validate_syntax(&n).is_ok());
154+
}
155+
156+
#[test]
157+
fn icd10_disallowed_chars_fail_invalid_contents(
158+
prefix in r"[A-Z][0-9]{2}",
159+
illegal in r"[!@#$%]",
160+
) {
161+
let s = format!("{prefix}{illegal}");
162+
let c = Code::from(s.as_str());
163+
let n = Icd10::normalize(&c).unwrap();
164+
let err = Icd10::validate_syntax(&n).unwrap_err();
165+
let is_invalid_contents = matches!(err, ValidationError::InvalidContents { .. });
166+
prop_assert!(is_invalid_contents);
167+
}
168+
169+
#[test]
170+
fn icd10_out_of_range_length_fails_invalid_length(s in r"[A-Z0-9.X]{8,15}") {
171+
let c = Code::from(s.as_str());
172+
let n = Icd10::normalize(&c).unwrap();
173+
let err = Icd10::validate_syntax(&n).unwrap_err();
174+
let is_invalid_length = matches!(err, ValidationError::InvalidLength { .. });
175+
prop_assert!(is_invalid_length);
176+
}
177+
178+
#[test]
179+
fn icd10_trim_idempotent(
180+
s in valid_icd10(),
181+
left in 0usize..5,
182+
right in 0usize..5,
183+
) {
184+
let padded = format!("{}{s}{}", " ".repeat(left), " ".repeat(right));
185+
let base = Icd10::normalize(&Code::from(s.as_str())).unwrap();
186+
let pad = Icd10::normalize(&Code::from(padded.as_str())).unwrap();
187+
prop_assert_eq!(base.as_str(), pad.as_str());
188+
}
189+
}
190+
191+
#[test]
192+
fn icd10_is_truncatable_when_longer_than_three() {
193+
let n = Icd10::normalize(&Code::from("A00.4")).unwrap();
194+
assert!(Icd10::is_truncatable(&n));
195+
let short = Icd10::normalize(&Code::from("A00")).unwrap();
196+
assert!(!Icd10::is_truncatable(&short));
197+
}
198+
199+
#[test]
200+
fn icd10_truncate_to_three_chars() {
201+
let n = Icd10::normalize(&Code::from("A00.4")).unwrap();
202+
let t = Icd10::truncate(&n);
203+
assert_eq!(t.as_str(), "A00");
204+
}
205+
206+
#[test]
207+
fn icd10_is_x_addable_for_three_char_codes() {
208+
let n = Icd10::normalize(&Code::from("A00")).unwrap();
209+
assert!(Icd10::is_x_addable(&n));
210+
}
211+
212+
#[test]
213+
fn icd10_add_x_appends_x() {
214+
let n = Icd10::normalize(&Code::from("A00")).unwrap();
215+
assert_eq!(Icd10::add_x(&n).as_str(), "A00X");
216+
}
217+
}

rust/codelist-systems-rs/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
77
pub mod capabilities;
88
pub mod core;
9+
pub mod ctv3;
910
pub mod errors;
1011
pub mod icd10;
12+
pub mod opcs;
13+
pub mod snomed;
1114

1215
pub use crate::{
1316
capabilities::{Truncatable, XExtensible},

0 commit comments

Comments
 (0)