Skip to content

Commit fe224dd

Browse files
authored
Merge pull request #96 from ehrtools/add_ctv3
add ctv3
2 parents b0aa00b + c6c8d2c commit fe224dd

8 files changed

Lines changed: 269 additions & 79 deletions

File tree

rust/codelist-rs/src/codelist_factory.rs

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -417,24 +417,9 @@ mod tests {
417417
use tempfile::tempdir;
418418

419419
use super::*;
420-
use crate::metadata::{
421-
categorisation_and_usage::CategorisationAndUsage, metadata_source::Source,
422-
provenance::Provenance, purpose_and_context::PurposeAndContext,
423-
validation_and_review::ValidationAndReview,
424-
};
425-
426-
// Helper function to create test metadata
427-
fn create_test_metadata() -> Metadata {
428-
Metadata {
429-
provenance: Provenance::new(Source::ManuallyCreated, None),
430-
categorisation_and_usage: CategorisationAndUsage::new(None, None, None),
431-
purpose_and_context: PurposeAndContext::new(None, None, None),
432-
validation_and_review: ValidationAndReview::new(None, None, None, None, None),
433-
}
434-
}
435420

436421
fn create_test_codelist_factory() -> CodeListFactory {
437-
let metadata = create_test_metadata();
422+
let metadata = Metadata::default();
438423
let codelist_type = CodeListType::ICD10;
439424
let codelist_options = CodeListOptions::default();
440425
CodeListFactory::new(codelist_options, metadata, codelist_type)
@@ -459,7 +444,7 @@ mod tests {
459444

460445
#[test]
461446
fn test_new_codelist_factory() {
462-
let metadata = create_test_metadata();
447+
let metadata = Metadata::default();
463448
let codelist_type = CodeListType::ICD10;
464449
let codelist_options = CodeListOptions::default();
465450
let metadata_clone = metadata.clone(); // Clone before moving

rust/codelist-rs/src/types.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub enum CodeListType {
1919
ICD10,
2020
SNOMED,
2121
OPCS,
22+
CTV3,
2223
}
2324

2425
impl CodeListType {
@@ -56,6 +57,7 @@ impl FromStr for CodeListType {
5657
"icd10" => Ok(CodeListType::ICD10),
5758
"snomed" => Ok(CodeListType::SNOMED),
5859
"opcs" => Ok(CodeListType::OPCS),
60+
"ctv3" => Ok(CodeListType::CTV3),
5961
invalid_string => Err(CodeListError::invalid_code_list_type(invalid_string)),
6062
}
6163
}
@@ -71,6 +73,7 @@ impl fmt::Display for CodeListType {
7173
CodeListType::ICD10 => "ICD10",
7274
CodeListType::SNOMED => "SNOMED",
7375
CodeListType::OPCS => "OPCS",
76+
CodeListType::CTV3 => "CTV3",
7477
};
7578
write!(f, "{s}")
7679
}
@@ -85,6 +88,7 @@ mod tests {
8588
assert!(matches!(CodeListType::from_str("icd10"), Ok(CodeListType::ICD10)));
8689
assert!(matches!(CodeListType::from_str("snomed"), Ok(CodeListType::SNOMED)));
8790
assert!(matches!(CodeListType::from_str("opcs"), Ok(CodeListType::OPCS)));
91+
assert!(matches!(CodeListType::from_str("ctv3"), Ok(CodeListType::CTV3)));
8892
assert!(matches!(CodeListType::from_str("invalid"),
8993
Err(CodeListError::InvalidCodeListType { name }) if name == "invalid"));
9094
}
@@ -94,12 +98,14 @@ mod tests {
9498
assert!(matches!(CodeListType::from_str("ICD10"), Ok(CodeListType::ICD10)));
9599
assert!(matches!(CodeListType::from_str("SNOMED"), Ok(CodeListType::SNOMED)));
96100
assert!(matches!(CodeListType::from_str("OPCS"), Ok(CodeListType::OPCS)));
101+
assert!(matches!(CodeListType::from_str("CTV3"), Ok(CodeListType::CTV3)));
97102
}
98103

99104
#[test]
100105
fn test_to_string() {
101106
assert_eq!(CodeListType::ICD10.to_string(), "ICD10");
102107
assert_eq!(CodeListType::SNOMED.to_string(), "SNOMED");
103108
assert_eq!(CodeListType::OPCS.to_string(), "OPCS");
109+
assert_eq!(CodeListType::CTV3.to_string(), "CTV3");
104110
}
105111
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
//! CTV3 validator for validating CTV3 codes in a codelist
2+
//!
3+
//! Validation Rules
4+
//! 1. The code must be exactly 5 characters in length.
5+
//! 2. Only alphanumeric characters (a-z, A-Z, 0-9) and dots (.) are allowed.
6+
//! 3. The code starts with 0-5 alphanumeric characters followed by dots to pad to 5 characters.
7+
use std::sync::LazyLock;
8+
9+
use codelist_rs::codelist::CodeList;
10+
use regex::Regex;
11+
12+
use crate::{errors::CodeListValidatorError, validator::CodeValidator};
13+
14+
pub struct Ctv3Validator<'a>(pub &'a CodeList);
15+
16+
static REGEX: LazyLock<Regex> = LazyLock::new(|| {
17+
Regex::new(r"^(?:[a-zA-Z0-9]{5}|[a-zA-Z0-9]{4}\.|[a-zA-Z0-9]{3}\.\.|[a-zA-Z0-9]{2}\.\.\.|[a-zA-Z0-9]{1}\.\.\.\.|\.{5})$").expect("Unable to create regex")
18+
});
19+
20+
impl CodeValidator for Ctv3Validator<'_> {
21+
fn validate_code(&self, code: &str) -> Result<(), CodeListValidatorError> {
22+
if code.len() > 5 {
23+
return Err(CodeListValidatorError::invalid_code_length(
24+
code,
25+
"Code is greater than 5 characters in length",
26+
self.0.codelist_type.to_string(),
27+
));
28+
}
29+
30+
if code.len() < 5 {
31+
return Err(CodeListValidatorError::invalid_code_length(
32+
code,
33+
"Code is less than 5 characters in length",
34+
self.0.codelist_type.to_string(),
35+
));
36+
}
37+
38+
if !REGEX.is_match(code) {
39+
return Err(CodeListValidatorError::invalid_code_contents(
40+
code,
41+
"Code does not match the expected format",
42+
self.0.codelist_type.to_string(),
43+
));
44+
}
45+
46+
Ok(())
47+
}
48+
49+
fn validate_all_code(&self) -> Result<(), CodeListValidatorError> {
50+
let mut reasons = Vec::new();
51+
52+
for (code, _) in self.0.entries.iter() {
53+
if let Err(err) = self.validate_code(code) {
54+
reasons.push(err.to_string());
55+
}
56+
}
57+
58+
if reasons.is_empty() {
59+
Ok(())
60+
} else {
61+
Err(CodeListValidatorError::invalid_codelist(reasons))
62+
}
63+
}
64+
}
65+
66+
#[cfg(test)]
67+
mod tests {
68+
use codelist_rs::{
69+
codelist::CodeList, errors::CodeListError, metadata::Metadata, types::CodeListType,
70+
};
71+
72+
use super::*;
73+
use crate::validator::Validator;
74+
75+
// Helper function to create a test codelist with two entries, default options
76+
// and test metadata
77+
fn create_test_codelist() -> Result<CodeList, CodeListError> {
78+
let codelist = CodeList::new(
79+
"test_codelist".to_string(),
80+
CodeListType::CTV3,
81+
Metadata::default(),
82+
None,
83+
);
84+
Ok(codelist)
85+
}
86+
87+
#[test]
88+
fn test_validate_codelist_with_valid_code() -> Result<(), CodeListError> {
89+
let mut codelist = create_test_codelist()?;
90+
let _ = codelist.add_entry("A9f..".to_string(), None, None);
91+
assert!(codelist.validate_codes().is_ok());
92+
Ok(())
93+
}
94+
95+
#[test]
96+
fn test_validate_code_with_invalid_code_length_too_long() -> Result<(), CodeListError> {
97+
let codelist = create_test_codelist()?;
98+
let validator = Ctv3Validator(&codelist);
99+
let code: &'static str = "A009000000";
100+
let error = validator.validate_code(code).unwrap_err().to_string();
101+
assert_eq!(error, "Code A009000000 is an invalid length for type CTV3. Reason: Code is greater than 5 characters in length");
102+
Ok(())
103+
}
104+
105+
#[test]
106+
fn test_validate_code_with_invalid_code_length_too_short() -> Result<(), CodeListError> {
107+
let codelist = create_test_codelist()?;
108+
let validator = Ctv3Validator(&codelist);
109+
let code = "Af.";
110+
let error = validator.validate_code(code).unwrap_err().to_string();
111+
assert_eq!(error, "Code Af. is an invalid length for type CTV3. Reason: Code is less than 5 characters in length");
112+
Ok(())
113+
}
114+
115+
#[test]
116+
fn test_validate_invalid_code_dot_first_character() -> Result<(), CodeListError> {
117+
let codelist = create_test_codelist()?;
118+
let validator = Ctv3Validator(&codelist);
119+
let code = ".a009";
120+
let error = validator.validate_code(code).unwrap_err().to_string();
121+
assert_eq!(error, "Code .a009 contents is invalid for type CTV3. Reason: Code does not match the expected format");
122+
Ok(())
123+
}
124+
125+
#[test]
126+
fn test_validate_invalid_code_dot_middle_character_between_letters() -> Result<(), CodeListError>
127+
{
128+
let codelist = create_test_codelist()?;
129+
let validator = Ctv3Validator(&codelist);
130+
let code = "10a.f";
131+
let error = validator.validate_code(code).unwrap_err().to_string();
132+
assert_eq!(error, "Code 10a.f contents is invalid for type CTV3. Reason: Code does not match the expected format");
133+
Ok(())
134+
}
135+
136+
#[test]
137+
fn test_validate_invalid_code_invalid_characters() -> Result<(), CodeListError> {
138+
let codelist = create_test_codelist()?;
139+
let validator = Ctv3Validator(&codelist);
140+
let code = "Af!!!";
141+
let error = validator.validate_code(code).unwrap_err().to_string();
142+
assert_eq!(error, "Code Af!!! contents is invalid for type CTV3. Reason: Code does not match the expected format");
143+
Ok(())
144+
}
145+
146+
#[test]
147+
fn test_validate_codelist_with_valid_codes() -> Result<(), CodeListError> {
148+
let mut codelist = create_test_codelist()?;
149+
codelist.add_entry("Af918".to_string(), None, None)?;
150+
codelist.add_entry("ABb..".to_string(), None, None)?;
151+
codelist.add_entry("alkif".to_string(), None, None)?;
152+
codelist.add_entry("F....".to_string(), None, None)?;
153+
codelist.add_entry("bn89.".to_string(), None, None)?;
154+
codelist.add_entry("Me...".to_string(), None, None)?;
155+
codelist.add_entry("99999".to_string(), None, None)?;
156+
codelist.add_entry(".....".to_string(), None, None)?;
157+
assert!(codelist.validate_codes().is_ok());
158+
Ok(())
159+
}
160+
161+
#[test]
162+
fn test_validate_codelist_with_all_invalid_codes() -> Result<(), CodeListError> {
163+
let mut codelist = create_test_codelist()?;
164+
codelist.add_entry("A00900000".to_string(), None, None)?;
165+
codelist.add_entry("10".to_string(), None, None)?;
166+
codelist.add_entry("a.9jb".to_string(), None, None)?;
167+
codelist.add_entry("..9jJ".to_string(), None, None)?;
168+
codelist.add_entry("A00A".to_string(), None, None)?;
169+
codelist.add_entry("*unf.".to_string(), None, None)?;
170+
codelist.add_entry("..j..".to_string(), None, None)?;
171+
codelist.add_entry("9874ji".to_string(), None, None)?;
172+
let error = codelist.validate_codes().unwrap_err();
173+
let error_string = error.to_string();
174+
175+
assert!(error_string.contains("Some codes in the list are invalid. Details:"));
176+
assert!(error_string.contains("Code A00900000 is an invalid length for type CTV3. Reason: Code is greater than 5 characters in length"));
177+
assert!(error_string.contains("Code 10 is an invalid length for type CTV3. Reason: Code is less than 5 characters in length"));
178+
assert!(error_string.contains("Code a.9jb contents is invalid for type CTV3. Reason: Code does not match the expected format"));
179+
assert!(error_string.contains("Code ..9jJ contents is invalid for type CTV3. Reason: Code does not match the expected format"));
180+
assert!(error_string.contains("Code A00A is an invalid length for type CTV3. Reason: Code is less than 5 characters in length"));
181+
assert!(error_string.contains("Code *unf. contents is invalid for type CTV3. Reason: Code does not match the expected format"));
182+
assert!(error_string.contains("Code ..j.. contents is invalid for type CTV3. Reason: Code does not match the expected format"));
183+
assert!(error_string.contains("Code 9874ji is an invalid length for type CTV3. Reason: Code is greater than 5 characters in length"));
184+
185+
assert!(
186+
matches!(error, CodeListValidatorError::InvalidCodelist { reasons } if reasons.len() == 8)
187+
);
188+
Ok(())
189+
}
190+
191+
#[test]
192+
fn test_validate_codelist_with_mixed_invalid_and_valid_codes() -> Result<(), CodeListError> {
193+
let mut codelist = create_test_codelist()?;
194+
codelist.add_entry("A54..".to_string(), None, None)?;
195+
codelist.add_entry("1009.".to_string(), None, None)?;
196+
codelist.add_entry("jk90L".to_string(), None, None)?;
197+
codelist.add_entry("LK...".to_string(), None, None)?;
198+
codelist.add_entry("N40".to_string(), None, None)?;
199+
codelist.add_entry("A00.l".to_string(), None, None)?;
200+
codelist.add_entry("Q90.....".to_string(), None, None)?;
201+
codelist.add_entry("A..9k".to_string(), None, None)?;
202+
let error = codelist.validate_codes().unwrap_err();
203+
let error_string = error.to_string();
204+
205+
assert!(error_string.contains("Some codes in the list are invalid. Details:"));
206+
assert!(error_string.contains("Code N40 is an invalid length for type CTV3. Reason: Code is less than 5 characters in length"));
207+
assert!(error_string.contains("Code A00.l contents is invalid for type CTV3. Reason: Code does not match the expected format"));
208+
assert!(error_string.contains("Code Q90..... is an invalid length for type CTV3. Reason: Code is greater than 5 characters in length"));
209+
assert!(error_string.contains("Code A..9k contents is invalid for type CTV3. Reason: Code does not match the expected format"));
210+
211+
assert!(
212+
matches!(error, CodeListValidatorError::InvalidCodelist { reasons } if reasons.len() == 4)
213+
);
214+
Ok(())
215+
}
216+
}

rust/codelist-validator-rs/src/icd10_validator.rs

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
//! ICD10 validator for validating ICD10 codes in a codelist
2+
//!
3+
//! Validation Rules
4+
//! 1. The code must be 7 characters or less.
5+
//! 2. The first character must be a letter.
6+
//! 3. The second and third characters must be numbers.
7+
//! 4. The fourth character must be a dot, or a number or X.
8+
//! 5. If the fourth character is a dot, there must be at least 1 number after the dot.
9+
//! 6. If the fourth character is a X, there are no further characters.
10+
//! 7. The fifth to seventh characters must be numbers if present.
111
use std::sync::LazyLock;
212

313
use codelist_rs::codelist::CodeList;
@@ -52,36 +62,19 @@ impl CodeValidator for IcdValidator<'_> {
5262
#[cfg(test)]
5363
mod tests {
5464
use codelist_rs::{
55-
codelist::CodeList,
56-
errors::CodeListError,
57-
metadata::{
58-
categorisation_and_usage::CategorisationAndUsage, metadata_source::Source,
59-
provenance::Provenance, purpose_and_context::PurposeAndContext,
60-
validation_and_review::ValidationAndReview, Metadata,
61-
},
62-
types::CodeListType,
65+
codelist::CodeList, errors::CodeListError, metadata::Metadata, types::CodeListType,
6366
};
6467

6568
use super::*;
6669
use crate::validator::Validator;
6770

68-
// Helper function to create test metadata
69-
fn create_test_metadata() -> Metadata {
70-
Metadata::new(
71-
Provenance::new(Source::ManuallyCreated, None),
72-
CategorisationAndUsage::new(None, None, None),
73-
PurposeAndContext::new(None, None, None),
74-
ValidationAndReview::new(None, None, None, None, None),
75-
)
76-
}
77-
7871
// Helper function to create a test codelist with two entries, default options
7972
// and test metadata
8073
fn create_test_codelist() -> Result<CodeList, CodeListError> {
8174
let codelist = CodeList::new(
8275
"test_codelist".to_string(),
8376
CodeListType::ICD10,
84-
create_test_metadata(),
77+
Metadata::default(),
8578
None,
8679
);
8780
Ok(codelist)
@@ -179,6 +172,16 @@ mod tests {
179172
Ok(())
180173
}
181174

175+
#[test]
176+
fn test_validate_invalid_code_lowercase_letter() -> Result<(), CodeListError> {
177+
let codelist = create_test_codelist()?;
178+
let validator = IcdValidator(&codelist);
179+
let code = "a54";
180+
let error = validator.validate_code(code).unwrap_err().to_string();
181+
assert_eq!(error, "Code a54 contents is invalid for type ICD10. Reason: Code does not match the expected format");
182+
Ok(())
183+
}
184+
182185
#[test]
183186
fn test_validate_codelist_with_valid_codes() -> Result<(), CodeListError> {
184187
let mut codelist = create_test_codelist()?;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
extern crate core;
22

3+
pub mod ctv3_validator;
34
pub mod errors;
45
pub mod icd10_validator;
56
pub mod opcs_validator;

0 commit comments

Comments
 (0)