Skip to content

Commit 763386c

Browse files
Tidy up (#107)
* Add .claude/ to .gitignore * cargo fmt * Fix test-python: use maturin develop so tests see current Rust code maturin build produces a wheel but does not install it into the active venv, so tests were running against whatever was last manually installed. Use maturin develop instead, which builds and installs into the active venv. Requires sourcing bindings/python/.venv/bin/activate before just ci. * Add Code newtype to types module Newtype wraps String with compile-time distinction only — no content validation at this layer. serde(transparent) keeps the wire format as a plain JSON string. Canonical conversions via From<String>, From<&str>, and From<Code> for String. * Add NormalizedCode, Term, CodeSystemId, Contributor, CodeListId newtypes * Migrate Provenance.contributors to IndexSet<Contributor> CodeListError::ContributorNotFound now carries a Contributor (not String), so remove_contributor takes Contributor by value and moves it into the error on the not-found path — no as_str().to_string() dance. Python binding's add_contributor, remove_contributor, and the Provenance::new call site in PyCodeList::new wrap incoming strings with Contributor::from at the FFI boundary. Python-facing signatures remain String; the binding exposes contributors as native strings via as_str().
1 parent 7d581f3 commit 763386c

13 files changed

Lines changed: 430 additions & 75 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ Cargo.lock
2626
.DS_Store
2727
todo.txt
2828

29+
# Claude Code
30+
.claude/
31+
2932
## Python ##
3033
__pycache__/
3134
venv/

Justfile

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ prettier-check:
2121
# CI task: check formatting and linting and all tests
2222
ci: fmt-check clippy prettier-check test-python test-rust
2323

24-
# Run python tests
24+
# Run python tests.
25+
# Requires an active Python venv (run `source bindings/python/.venv/bin/activate`
26+
# first). `maturin develop` builds the extension and installs it into the
27+
# active venv so the tests exercise the current Rust code, not a stale wheel.
2528
test-python:
26-
echo "Build python library"
27-
maturin build --manifest-path bindings/python/Cargo.toml
28-
echo "Run python tests"
29+
maturin develop --manifest-path bindings/python/Cargo.toml
2930
sh bindings/python/tests/run.sh
3031

3132
# Run rust tests

bindings/python/src/codelist.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use codelist_rs::{
1010
CategorisationAndUsage, Metadata, Provenance, PurposeAndContext, Source,
1111
ValidationAndReview,
1212
},
13-
types::CodeListType,
13+
types::{CodeListType, Contributor},
1414
};
1515
use codelist_validator_rs::validator::Validator;
1616
use indexmap::IndexSet;
@@ -63,7 +63,9 @@ impl PyCodeList {
6363
})?;
6464
// convert authors vec to IndexSet
6565
let authors_set = authors
66-
.map(|authors| authors.into_iter().collect::<IndexSet<String>>())
66+
.map(|authors| {
67+
authors.into_iter().map(Contributor::from).collect::<IndexSet<Contributor>>()
68+
})
6769
.unwrap_or_default();
6870
let provenance = Provenance::new(source, Some(authors_set));
6971
let categorisation_and_usage = CategorisationAndUsage::new(None, None, None);
@@ -114,7 +116,7 @@ impl PyCodeList {
114116

115117
/// Add a contributor to the codelist's provenance
116118
fn add_contributor(&mut self, contributor: String) -> PyResult<()> {
117-
self.inner.metadata.provenance.add_contributor(contributor);
119+
self.inner.metadata.provenance.add_contributor(Contributor::from(contributor));
118120
Ok(())
119121
}
120122

@@ -123,7 +125,7 @@ impl PyCodeList {
123125
self.inner
124126
.metadata
125127
.provenance
126-
.remove_contributor(contributor)
128+
.remove_contributor(Contributor::from(contributor))
127129
.map_err(|e| PyValueError::new_err(e.to_string()))?;
128130
Ok(())
129131
}
@@ -132,7 +134,7 @@ impl PyCodeList {
132134
fn contributors(&self, py: Python) -> PyResult<PyObject> {
133135
let py_set = PySet::new(py, &[] as &[String])?;
134136
for contributor in &self.inner.metadata.provenance.contributors {
135-
py_set.add(contributor)?;
137+
py_set.add(contributor.as_str())?;
136138
}
137139
Ok(py_set.into())
138140
}

rust/codelist-builder-rs/src/snomed_usage_data.rs

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,47 +4,65 @@
44
//! Components of the file:
55
//!
66
//! SNOMED_Concept_ID:
7-
//! SNOMED concepts which have been added to a patient record in a general practice system during the reporting period.
7+
//! SNOMED concepts which have been added to a patient record in a general
8+
//! practice system during the reporting period.
89
//!
910
//! Description:
10-
//! The fully specified name associated with the SNOMED_Concept_ID on the final day of the reporting period (31 July).
11+
//! The fully specified name associated with the SNOMED_Concept_ID on the final
12+
//! day of the reporting period (31 July).
1113
//!
1214
//! Usage:
13-
//! The number of times that the SNOMED_Concept_ID was added into any patient record within the reporting period, rounded to the nearerst 10. Usage of 1 to 4 is displayed as *. SNOMED concepts with no code usage are not included.
15+
//! The number of times that the SNOMED_Concept_ID was added into any patient
16+
//! record within the reporting period, rounded to the nearerst 10. Usage of 1
17+
//! to 4 is displayed as *. SNOMED concepts with no code usage are not included.
1418
//! Important notes:
15-
//! - Data prior to 2019 was originally submitted mostly in READ V2 or CTV3, but in the usage files, these codes have been mapped to corresponding SNOMED codes using final 2020 version of the mapping tables published by NHS England.
16-
//! - The usage does not show how many patients had each code added to their record - each addition regardless of whether it is the same patient increments the count by 1. Therefore it is not possible to infer the number of individual patients with a particular code.
17-
//! - For the 2011-12 to 2017-18 data, it is stated that "Current maximum value is approximately 250,000,000" - no such maximum is stated for the 2018-19 onwards data.
19+
//! - Data prior to 2019 was originally submitted mostly in READ V2 or CTV3, but
20+
//! in the usage files, these codes have been mapped to corresponding SNOMED
21+
//! codes using final 2020 version of the mapping tables published by NHS
22+
//! England.
23+
//! - The usage does not show how many patients had each code added to their
24+
//! record - each addition regardless of whether it is the same patient
25+
//! increments the count by 1. Therefore it is not possible to infer the
26+
//! number of individual patients with a particular code.
27+
//! - For the 2011-12 to 2017-18 data, it is stated that "Current maximum value
28+
//! is approximately 250,000,000" - no such maximum is stated for the 2018-19
29+
//! onwards data.
1830
//!
1931
//! Active_at_Start:
20-
//! Active status of the SNOMED_Concept_ID on the first day of the reporting period. This is taken from the most recent UK clinical extension, or associated International extention, which was published up to the start of the reporting year (1 August).
21-
//! 1 = SNOMED concept was published and was active.
22-
//! 0 = SNOMED concept was either not yet available or was inactive.
32+
//! Active status of the SNOMED_Concept_ID on the first day of the reporting
33+
//! period. This is taken from the most recent UK clinical extension, or
34+
//! associated International extention, which was published up to the start of
35+
//! the reporting year (1 August). 1 = SNOMED concept was published and was
36+
//! active. 0 = SNOMED concept was either not yet available or was inactive.
2337
//!
2438
//! Active_at_End:
25-
//! Active status of the SNOMED_Concept_ID on the last day of the reporting period. This is taken from the most recent UK clinical extension, or associated International extention, which was published up to the end of the reporting year (31 July).
26-
//! 1 = SNOMED concept was published and was active.
39+
//! Active status of the SNOMED_Concept_ID on the last day of the reporting
40+
//! period. This is taken from the most recent UK clinical extension, or
41+
//! associated International extention, which was published up to the end of the
42+
//! reporting year (31 July). 1 = SNOMED concept was published and was active.
2743
//! 0 = SNOMED concept was either not yet available or was inactive.
2844
29-
use std::fs;
30-
31-
// Internal imports
32-
use crate::errors::CodeListBuilderError;
45+
use std::{collections::HashMap, fs};
3346

3447
// External imports
3548
use csv;
3649
use reqwest;
3750
use serde::{Deserialize, Serialize};
38-
use std::collections::HashMap;
51+
52+
// Internal imports
53+
use crate::errors::CodeListBuilderError;
3954

4055
/// Struct to represent a snomed usage data entry
4156
///
4257
/// # Fields
4358
/// * `snomed_concept_id` - The snomed concept id
4459
/// * `description` - The description
45-
/// * `usage` - The usage. A count of 1-4 is denoted by a *. Counts above 4 are denoted by a number rounded to the nearest 10.
46-
/// * `active_at_start` - Whether the concept was active at the start of the usage period
47-
/// * `active_at_end` - Whether the concept was active at the end of the usage period
60+
/// * `usage` - The usage. A count of 1-4 is denoted by a *. Counts above 4 are
61+
/// denoted by a number rounded to the nearest 10.
62+
/// * `active_at_start` - Whether the concept was active at the start of the
63+
/// usage period
64+
/// * `active_at_end` - Whether the concept was active at the end of the usage
65+
/// period
4866
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4967
pub struct SnomedUsageDataEntry {
5068
pub snomed_concept_id: String,

rust/codelist-builder-rs/tests/download_usage.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
use codelist_builder_rs::errors::CodeListBuilderError;
2-
use codelist_builder_rs::snomed_usage_data::SnomedUsageData;
1+
use codelist_builder_rs::{errors::CodeListBuilderError, snomed_usage_data::SnomedUsageData};
32

43
#[tokio::test]
54
async fn test_download_usage() -> Result<(), CodeListBuilderError> {

rust/codelist-rs/src/errors.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use std::io;
55
use csv;
66
use serde_json;
77

8+
use crate::types::Contributor;
9+
810
/// Enum to represent the different types of errors that can occur in the
911
/// codelist library
1012
@@ -53,7 +55,7 @@ pub enum CodeListError {
5355
CodeEntryTermDoesNotExist { code: String, msg: String },
5456

5557
#[error("Contributor {contributor} not found")]
56-
ContributorNotFound { contributor: String },
58+
ContributorNotFound { contributor: Contributor },
5759

5860
#[error("Invalid metadata source: {source_string}")]
5961
InvalidMetadataSource { source_string: String },

rust/codelist-rs/src/metadata/metadata.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ mod tests {
5858
use indexmap::IndexSet;
5959

6060
use super::*;
61-
use crate::{errors::CodeListError, metadata::Source};
61+
use crate::{errors::CodeListError, metadata::Source, types::Contributor};
6262

6363
// helper function to get the time difference between the current time and the
6464
// given date
@@ -69,8 +69,10 @@ mod tests {
6969

7070
#[test]
7171
fn test_new() -> Result<(), CodeListError> {
72-
let provenance =
73-
Provenance::new(Source::ManuallyCreated, Some(IndexSet::from(["Test".to_string()])));
72+
let provenance = Provenance::new(
73+
Source::ManuallyCreated,
74+
Some(IndexSet::from([Contributor::from("Test")])),
75+
);
7476
let categorisation_and_usage = CategorisationAndUsage::new(
7577
Some(HashSet::from(["tag1".to_string()])),
7678
Some(HashSet::from(["usage1".to_string()])),
@@ -100,7 +102,7 @@ mod tests {
100102
assert!(time_difference < 1000);
101103
let time_difference = get_time_difference(metadata.provenance.last_modified_date);
102104
assert!(time_difference < 1000);
103-
assert_eq!(metadata.provenance.contributors, IndexSet::from(["Test".to_string()]));
105+
assert_eq!(metadata.provenance.contributors, IndexSet::from([Contributor::from("Test")]));
104106

105107
assert_eq!(metadata.categorisation_and_usage.tags, HashSet::from(["tag1".to_string()]));
106108
assert_eq!(metadata.categorisation_and_usage.usage, HashSet::from(["usage1".to_string()]));

rust/codelist-rs/src/metadata/provenance.rs

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ use serde::{Deserialize, Serialize};
1010

1111
// Internal imports
1212
use crate::errors::CodeListError;
13-
use crate::metadata::metadata_source::Source;
13+
use crate::{metadata::metadata_source::Source, types::Contributor};
1414

1515
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1616
pub struct Provenance {
1717
pub source: Source,
1818
pub created_date: chrono::DateTime<Utc>,
1919
pub last_modified_date: chrono::DateTime<Utc>,
20-
pub contributors: IndexSet<String>,
20+
pub contributors: IndexSet<Contributor>,
2121
}
2222

2323
impl Default for Provenance {
@@ -31,7 +31,7 @@ impl Provenance {
3131
///
3232
/// # Arguments
3333
/// * `source` - The source of the codelist
34-
pub fn new(source: Source, contributors: Option<IndexSet<String>>) -> Provenance {
34+
pub fn new(source: Source, contributors: Option<IndexSet<Contributor>>) -> Provenance {
3535
Self {
3636
source,
3737
created_date: Utc::now(),
@@ -53,7 +53,7 @@ impl Provenance {
5353
/// # Arguments
5454
/// * `self` - The provenance to update
5555
/// * `contributor` - The contributor to add
56-
pub fn add_contributor(&mut self, contributor: String) {
56+
pub fn add_contributor(&mut self, contributor: Contributor) {
5757
self.contributors.insert(contributor);
5858
}
5959

@@ -62,7 +62,7 @@ impl Provenance {
6262
/// # Arguments
6363
/// * `self` - The provenance to update
6464
/// * `contributor` - The contributor to remove
65-
pub fn remove_contributor(&mut self, contributor: String) -> Result<(), CodeListError> {
65+
pub fn remove_contributor(&mut self, contributor: Contributor) -> Result<(), CodeListError> {
6666
if self.contributors.shift_remove(&contributor) {
6767
Ok(())
6868
} else {
@@ -88,7 +88,7 @@ mod tests {
8888

8989
fn create_test_provenance_with_contributors() -> Provenance {
9090
let mut contributors = IndexSet::new();
91-
contributors.insert("Example Contributor".to_string());
91+
contributors.insert(Contributor::from("Example Contributor"));
9292
Provenance::new(Source::LoadedFromFile, Some(contributors))
9393
}
9494

@@ -100,14 +100,17 @@ mod tests {
100100
assert!(time_difference < 1000);
101101
let time_difference = get_time_difference(provenance.last_modified_date);
102102
assert!(time_difference < 1000);
103-
assert_eq!(provenance.contributors, IndexSet::new());
103+
assert_eq!(provenance.contributors, IndexSet::<Contributor>::new());
104104
}
105105

106106
#[test]
107107
fn test_new_provenance_with_contributors() {
108108
let provenance = create_test_provenance_with_contributors();
109109
assert_eq!(provenance.source, Source::LoadedFromFile);
110-
assert_eq!(provenance.contributors, IndexSet::from(["Example Contributor".to_string()]));
110+
assert_eq!(
111+
provenance.contributors,
112+
IndexSet::from([Contributor::from("Example Contributor")])
113+
);
111114
let time_difference = get_time_difference(provenance.created_date);
112115
assert!(time_difference < 1000);
113116
let time_difference = get_time_difference(provenance.last_modified_date);
@@ -125,23 +128,35 @@ mod tests {
125128
#[test]
126129
fn test_add_contributor() {
127130
let mut provenance = create_test_provenance_no_contributors();
128-
provenance.add_contributor("Example Contributor".to_string());
129-
assert_eq!(provenance.contributors, IndexSet::from(["Example Contributor".to_string()]));
131+
provenance.add_contributor(Contributor::from("Example Contributor"));
132+
assert_eq!(
133+
provenance.contributors,
134+
IndexSet::from([Contributor::from("Example Contributor")])
135+
);
136+
}
137+
138+
#[test]
139+
fn add_contributor_uses_newtype() {
140+
let mut p = create_test_provenance_no_contributors();
141+
let c = Contributor::from("Caroline");
142+
p.add_contributor(c.clone());
143+
assert!(p.contributors.contains(&c));
130144
}
131145

132146
#[test]
133147
fn test_remove_contributor() -> Result<(), CodeListError> {
134148
let mut provenance = create_test_provenance_with_contributors();
135-
provenance.add_contributor("Example Contributor".to_string());
136-
provenance.remove_contributor("Example Contributor".to_string())?;
137-
assert_eq!(provenance.contributors, IndexSet::new());
149+
provenance.add_contributor(Contributor::from("Example Contributor"));
150+
provenance.remove_contributor(Contributor::from("Example Contributor"))?;
151+
assert_eq!(provenance.contributors, IndexSet::<Contributor>::new());
138152
Ok(())
139153
}
140154

141155
#[test]
142156
fn test_remove_contributor_not_found() {
143157
let mut provenance = create_test_provenance_no_contributors();
144-
let error = provenance.remove_contributor("Example Contributor".to_string()).unwrap_err();
158+
let error =
159+
provenance.remove_contributor(Contributor::from("Example Contributor")).unwrap_err();
145160
let error_string = error.to_string();
146161
assert_eq!(error_string, "Contributor Example Contributor not found");
147162
}
@@ -150,35 +165,35 @@ mod tests {
150165
fn test_contributors_order_is_maintained() -> Result<(), CodeListError> {
151166
let mut provenance = create_test_provenance_no_contributors();
152167

153-
provenance.add_contributor("Example1".to_string());
168+
provenance.add_contributor(Contributor::from("Example1"));
154169
{
155170
let mut iter = provenance.contributors.iter();
156-
assert_eq!(iter.next(), Some(&"Example1".to_string()));
171+
assert_eq!(iter.next(), Some(&Contributor::from("Example1")));
157172
assert_eq!(iter.next(), None);
158173
}
159174

160-
provenance.add_contributor("Example2".to_string());
175+
provenance.add_contributor(Contributor::from("Example2"));
161176
{
162177
let mut iter = provenance.contributors.iter();
163-
assert_eq!(iter.next(), Some(&"Example1".to_string()));
164-
assert_eq!(iter.next(), Some(&"Example2".to_string()));
178+
assert_eq!(iter.next(), Some(&Contributor::from("Example1")));
179+
assert_eq!(iter.next(), Some(&Contributor::from("Example2")));
165180
assert_eq!(iter.next(), None);
166181
}
167182

168-
provenance.add_contributor("Example3".to_string());
183+
provenance.add_contributor(Contributor::from("Example3"));
169184
{
170185
let mut iter = provenance.contributors.iter();
171-
assert_eq!(iter.next(), Some(&"Example1".to_string()));
172-
assert_eq!(iter.next(), Some(&"Example2".to_string()));
173-
assert_eq!(iter.next(), Some(&"Example3".to_string()));
186+
assert_eq!(iter.next(), Some(&Contributor::from("Example1")));
187+
assert_eq!(iter.next(), Some(&Contributor::from("Example2")));
188+
assert_eq!(iter.next(), Some(&Contributor::from("Example3")));
174189
assert_eq!(iter.next(), None);
175190
}
176191

177-
provenance.remove_contributor("Example2".to_string())?;
192+
provenance.remove_contributor(Contributor::from("Example2"))?;
178193
{
179194
let mut iter = provenance.contributors.iter();
180-
assert_eq!(iter.next(), Some(&"Example1".to_string()));
181-
assert_eq!(iter.next(), Some(&"Example3".to_string()));
195+
assert_eq!(iter.next(), Some(&Contributor::from("Example1")));
196+
assert_eq!(iter.next(), Some(&Contributor::from("Example3")));
182197
assert_eq!(iter.next(), None);
183198
}
184199

0 commit comments

Comments
 (0)