Skip to main content

rh_codegen/
value_sets.rs

1//! ValueSet management and code generation utilities
2//!
3//! This module handles FHIR ValueSets, including generation of Rust enums
4//! from ValueSet codes and management of code system mappings.
5
6use crate::fhir_types::{CodeSystem, ValueSet, ValueSetComposeConcept, ValueSetExpansionContains};
7use crate::rust_types::{RustEnum, RustEnumVariant};
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// Manages FHIR ValueSets and their conversion to Rust enums
13#[derive(Debug, Clone)]
14pub struct ValueSetManager {
15    /// Directory containing ValueSet JSON files
16    value_set_dir: Option<PathBuf>,
17    /// Cache of ValueSet URLs to generated enum names
18    value_set_cache: HashMap<String, String>,
19    /// Cache of generated enums by name
20    enum_cache: HashMap<String, RustEnum>,
21}
22
23impl ValueSetManager {
24    pub fn new() -> Self {
25        Self {
26            value_set_dir: None,
27            value_set_cache: HashMap::new(),
28            enum_cache: HashMap::new(),
29        }
30    }
31
32    pub fn new_with_directory<P: AsRef<Path>>(value_set_dir: P) -> Self {
33        Self {
34            value_set_dir: Some(value_set_dir.as_ref().to_path_buf()),
35            value_set_cache: HashMap::new(),
36            enum_cache: HashMap::new(),
37        }
38    }
39
40    /// Generate a Rust enum name from a ValueSet URL
41    pub fn generate_enum_name(&self, value_set_url: &str) -> String {
42        // Extract the last part of the URL and convert to PascalCase
43        let name = value_set_url
44            .split('/')
45            .next_back()
46            .unwrap_or("UnknownValueSet")
47            .split(&['-', '.'][..])
48            .filter(|part| !part.is_empty())
49            .map(|part| {
50                let mut chars = part.chars();
51                match chars.next() {
52                    None => String::new(),
53                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
54                }
55            })
56            .collect::<String>();
57
58        // Ensure it's a valid Rust identifier
59        if name.chars().next().unwrap_or('0').is_ascii_digit() {
60            format!("ValueSet{name}")
61        } else {
62            name
63        }
64    }
65
66    /// Check if a ValueSet is already cached
67    pub fn is_cached(&self, value_set_url: &str) -> bool {
68        self.value_set_cache.contains_key(value_set_url)
69    }
70
71    /// Get the enum name for a cached ValueSet
72    pub fn get_enum_name(&self, value_set_url: &str) -> Option<&String> {
73        self.value_set_cache.get(value_set_url)
74    }
75
76    /// Cache a ValueSet with its generated enum
77    pub fn cache_value_set(
78        &mut self,
79        value_set_url: String,
80        enum_name: String,
81        rust_enum: RustEnum,
82    ) {
83        self.value_set_cache
84            .insert(value_set_url, enum_name.clone());
85        self.enum_cache.insert(enum_name, rust_enum);
86    }
87
88    /// Get all cached enums
89    pub fn get_cached_enums(&self) -> &HashMap<String, RustEnum> {
90        &self.enum_cache
91    }
92
93    /// Get available codes from a ValueSet for documentation purposes
94    /// Returns a list of (code, display) tuples
95    pub fn get_value_set_codes(
96        &self,
97        value_set_url: &str,
98        version: Option<&str>,
99    ) -> Result<Vec<(String, Option<String>)>, String> {
100        // Try to find and load the ValueSet file
101        let value_set = match self.load_value_set(value_set_url, version) {
102            Ok(vs) => vs,
103            Err(err) => {
104                eprintln!("Warning: Could not load ValueSet '{value_set_url}': {err}");
105                return Err(format!("ValueSet not found: {value_set_url}"));
106            }
107        };
108
109        let mut codes = Vec::new();
110
111        // Try to get codes from expansion first
112        if let Some(expansion) = &value_set.expansion {
113            if let Some(contains) = &expansion.contains {
114                for concept in contains {
115                    codes.push((concept.code.clone(), concept.display.clone()));
116                }
117                if !codes.is_empty() {
118                    return Ok(codes);
119                }
120            }
121        }
122
123        // Fallback to compose if no expansion or expansion is empty
124        if let Some(compose) = &value_set.compose {
125            if let Some(includes) = &compose.include {
126                for include in includes {
127                    if let Some(concepts) = &include.concept {
128                        for concept in concepts {
129                            codes.push((concept.code.clone(), concept.display.clone()));
130                        }
131                    }
132                }
133            }
134        }
135
136        if codes.is_empty() {
137            Err("No codes found in ValueSet".to_string())
138        } else {
139            Ok(codes)
140        }
141    }
142
143    /// Generate enum from ValueSet, trying expansion first, then compose
144    pub fn generate_enum_from_value_set(
145        &mut self,
146        value_set_url: &str,
147        version: Option<&str>,
148    ) -> Result<String, String> {
149        let enum_name = self.generate_enum_name(value_set_url);
150
151        if self.is_cached(value_set_url) {
152            return Ok(enum_name);
153        }
154
155        // Try to find and load the ValueSet file
156        let value_set = match self.load_value_set(value_set_url, version) {
157            Ok(vs) => vs,
158            Err(err) => {
159                eprintln!("Warning: Could not load ValueSet '{value_set_url}': {err}");
160                return Err(format!("ValueSet not found: {value_set_url}"));
161            }
162        };
163
164        // Try to generate enum from expansion first
165        if let Some(expansion) = &value_set.expansion {
166            if let Some(contains) = &expansion.contains {
167                if !contains.is_empty() {
168                    let rust_enum =
169                        self.create_enum_from_expansion(&enum_name, contains, value_set_url);
170                    self.cache_value_set(value_set_url.to_string(), enum_name.clone(), rust_enum);
171                    return Ok(enum_name);
172                }
173            }
174        }
175
176        // Fallback to compose if no expansion or expansion is empty
177        if let Some(compose) = &value_set.compose {
178            if let Some(includes) = &compose.include {
179                // Check if there are any filters - if so, we can't generate enum
180                for include in includes {
181                    if include.filter.as_ref().is_some_and(|f| !f.is_empty()) {
182                        eprintln!("Warning: ValueSet '{value_set_url}' has filters, cannot generate enum. Falling back to String.");
183                        return Err("ValueSet has filters".to_string());
184                    }
185                }
186
187                // Try to generate from explicit concepts
188                let mut all_concepts = Vec::new();
189                for include in includes {
190                    if let Some(concepts) = &include.concept {
191                        all_concepts.extend(concepts.iter().cloned());
192                    } else if let Some(system) = &include.system {
193                        // Try to load the entire code system
194                        if let Ok(code_system) = self.load_code_system(system) {
195                            if let Some(cs_concepts) = &code_system.concept {
196                                for cs_concept in cs_concepts {
197                                    let compose_concept = ValueSetComposeConcept {
198                                        code: cs_concept.code.clone(),
199                                        display: cs_concept.display.clone(),
200                                    };
201                                    all_concepts.push(compose_concept);
202                                }
203                            }
204                        }
205                    }
206                }
207
208                if !all_concepts.is_empty() {
209                    let rust_enum =
210                        self.create_enum_from_concepts(&enum_name, &all_concepts, value_set_url);
211                    self.cache_value_set(value_set_url.to_string(), enum_name.clone(), rust_enum);
212                    return Ok(enum_name);
213                }
214            }
215        }
216
217        // If all methods fail, return error
218        eprintln!("Warning: Could not generate enum for ValueSet '{value_set_url}', no expansion or compose concepts found. Falling back to String.");
219        Err("No concepts found in ValueSet".to_string())
220    }
221
222    /// Load a ValueSet from file
223    fn load_value_set(
224        &self,
225        value_set_url: &str,
226        _version: Option<&str>,
227    ) -> Result<ValueSet, String> {
228        let value_set_dir = self
229            .value_set_dir
230            .as_ref()
231            .ok_or("No ValueSet directory configured")?;
232
233        // Extract the ID from the URL (last part after '/')
234        let id = value_set_url
235            .split('/')
236            .next_back()
237            .ok_or("Invalid ValueSet URL")?;
238
239        // Try common filename patterns
240        let filenames = vec![
241            format!("ValueSet-{}.json", id),
242            format!("valueset-{}.json", id),
243            format!("{}.json", id),
244        ];
245
246        for filename in filenames {
247            let file_path = value_set_dir.join(&filename);
248            if file_path.exists() {
249                let content = fs::read_to_string(&file_path)
250                    .map_err(|e| format!("Failed to read file '{}': {}", file_path.display(), e))?;
251
252                let value_set: ValueSet = serde_json::from_str(&content)
253                    .map_err(|e| format!("Failed to parse ValueSet JSON: {e}"))?;
254                return Ok(value_set);
255            }
256        }
257
258        Err(format!("ValueSet file not found for ID: {id}"))
259    }
260
261    /// Load a CodeSystem from file
262    fn load_code_system(&self, system_url: &str) -> Result<CodeSystem, String> {
263        let value_set_dir = self
264            .value_set_dir
265            .as_ref()
266            .ok_or("No ValueSet directory configured")?;
267
268        // Extract the ID from the URL (last part after '/')
269        let id = system_url
270            .split('/')
271            .next_back()
272            .ok_or("Invalid CodeSystem URL")?;
273
274        // Try common filename patterns
275        let filenames = vec![
276            format!("CodeSystem-{}.json", id),
277            format!("codesystem-{}.json", id),
278            format!("{}.json", id),
279        ];
280
281        for filename in filenames {
282            let file_path = value_set_dir.join(&filename);
283            if file_path.exists() {
284                let content = fs::read_to_string(&file_path)
285                    .map_err(|e| format!("Failed to read file '{}': {}", file_path.display(), e))?;
286
287                let code_system: CodeSystem = serde_json::from_str(&content)
288                    .map_err(|e| format!("Failed to parse CodeSystem JSON: {e}"))?;
289                return Ok(code_system);
290            }
291        }
292
293        Err(format!("CodeSystem file not found for ID: {id}"))
294    }
295
296    /// Create enum from ValueSet expansion
297    fn create_enum_from_expansion(
298        &self,
299        enum_name: &str,
300        contains: &[ValueSetExpansionContains],
301        value_set_url: &str,
302    ) -> RustEnum {
303        let mut rust_enum = RustEnum::new(enum_name.to_string());
304        rust_enum.doc_comment = Some(format!(" Generated enum for ValueSet: {value_set_url}"));
305
306        for concept in contains {
307            let variant_name = ValueSetConcept::new(concept.code.clone()).to_variant_name();
308            let mut variant = RustEnumVariant::new(variant_name);
309
310            if let Some(display) = &concept.display {
311                let clean = display.replace('\t', "    ").replace(['\n', '\r'], " ");
312                variant.doc_comment = Some(format!(" {clean}"));
313            }
314
315            // Add serde annotation to map to the original code
316            variant.serde_rename = Some(concept.code.clone());
317
318            rust_enum.add_variant(variant);
319        }
320
321        rust_enum
322    }
323
324    /// Create enum from ValueSet compose concepts
325    fn create_enum_from_concepts(
326        &self,
327        enum_name: &str,
328        concepts: &[ValueSetComposeConcept],
329        value_set_url: &str,
330    ) -> RustEnum {
331        let mut rust_enum = RustEnum::new(enum_name.to_string());
332        rust_enum.doc_comment = Some(format!(" Generated enum for ValueSet: {value_set_url}"));
333
334        for concept in concepts {
335            let variant_name = ValueSetConcept::new(concept.code.clone()).to_variant_name();
336            let mut variant = RustEnumVariant::new(variant_name);
337
338            if let Some(display) = &concept.display {
339                let clean = display.replace('\t', "    ").replace(['\n', '\r'], " ");
340                variant.doc_comment = Some(format!(" {clean}"));
341            }
342
343            // Add serde annotation to map to the original code
344            variant.serde_rename = Some(concept.code.clone());
345
346            rust_enum.add_variant(variant);
347        }
348
349        rust_enum
350    }
351
352    /// Generate a basic enum for unknown ValueSets (fallback)
353    pub fn generate_placeholder_enum(&mut self, value_set_url: &str) -> String {
354        let enum_name = self.generate_enum_name(value_set_url);
355
356        if !self.is_cached(value_set_url) {
357            let mut rust_enum = RustEnum::new(enum_name.clone());
358            rust_enum.doc_comment = Some(format!(" Generated enum for ValueSet: {value_set_url}"));
359
360            // Add a placeholder variant
361            rust_enum.add_variant(RustEnumVariant::new("Unknown".to_string()));
362
363            self.cache_value_set(value_set_url.to_string(), enum_name.clone(), rust_enum);
364        }
365
366        enum_name
367    }
368}
369
370impl Default for ValueSetManager {
371    fn default() -> Self {
372        Self::new()
373    }
374}
375
376/// Represents a FHIR ValueSet concept
377#[derive(Debug, Clone)]
378pub struct ValueSetConcept {
379    pub code: String,
380    pub display: Option<String>,
381    pub definition: Option<String>,
382    pub system: Option<String>,
383}
384
385impl ValueSetConcept {
386    pub fn new(code: String) -> Self {
387        Self {
388            code,
389            display: None,
390            definition: None,
391            system: None,
392        }
393    }
394
395    /// Convert the concept code to a valid Rust enum variant name
396    pub fn to_variant_name(&self) -> String {
397        // Handle special cases first
398        let sanitized_code = match self.code.as_str() {
399            "=" => "Equal".to_string(),
400            "!=" => "NotEqual".to_string(),
401            "<" => "LessThan".to_string(),
402            "<=" => "LessThanOrEqual".to_string(),
403            ">" => "GreaterThan".to_string(),
404            ">=" => "GreaterThanOrEqual".to_string(),
405            "+" => "Plus".to_string(),
406            "-" => "Minus".to_string(),
407            "*" => "Star".to_string(),
408            "/" => "Slash".to_string(),
409            "&" => "Ampersand".to_string(),
410            "|" => "Pipe".to_string(),
411            "%" => "Percent".to_string(),
412            "#" => "Hash".to_string(),
413            "@" => "At".to_string(),
414            "!" => "Exclamation".to_string(),
415            "?" => "Question".to_string(),
416            "^" => "Caret".to_string(),
417            "~" => "Tilde".to_string(),
418            "(" => "LeftParen".to_string(),
419            ")" => "RightParen".to_string(),
420            "[" => "LeftBracket".to_string(),
421            "]" => "RightBracket".to_string(),
422            "{" => "LeftBrace".to_string(),
423            "}" => "RightBrace".to_string(),
424            "'" => "SingleQuote".to_string(),
425            "\"" => "DoubleQuote".to_string(),
426            "`" => "Backtick".to_string(),
427            "$" => "Dollar".to_string(),
428            ";" => "Semicolon".to_string(),
429            ":" => "Colon".to_string(),
430            "," => "Comma".to_string(),
431            _ => {
432                // For other codes, sanitize by removing/replacing invalid characters
433                self.code
434                    .chars()
435                    .map(|c| match c {
436                        'a'..='z' | 'A'..='Z' | '0'..='9' => c.to_string(),
437                        '-' | '_' | '.' | ' ' => "-".to_string(), // Convert to dash for splitting
438                        _ => format!("_{:02x}", c as u32),        // Convert other characters to hex
439                    })
440                    .collect::<String>()
441            }
442        };
443
444        // Convert kebab-case, snake_case, or other formats to PascalCase
445        let name = sanitized_code
446            .split(&['-', '_', '.', ' '][..])
447            .filter(|part| !part.is_empty()) // Filter out empty parts
448            .map(|part| {
449                let mut chars = part.chars();
450                match chars.next() {
451                    None => String::new(),
452                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
453                }
454            })
455            .collect::<String>();
456
457        // Ensure it starts with a letter and is not empty
458        if name.is_empty() {
459            "Unknown".to_string()
460        } else if name.chars().next().unwrap_or('0').is_ascii_digit() {
461            format!("Code{name}")
462        } else {
463            // Escape Rust keywords that are valid identifiers but reserved
464            match name.as_str() {
465                "Self" | "SelfType" => format!("{name}Value"),
466                "Type" | "Match" | "Use" | "Mod" | "Ref" | "Mut" | "Let" | "Fn" | "Impl"
467                | "Trait" | "Struct" | "Enum" | "Pub" | "Crate" | "Super" | "Return" | "If"
468                | "Else" | "While" | "For" | "In" | "Loop" | "Break" | "Continue" | "As"
469                | "Move" | "Static" | "Const" | "Unsafe" | "Extern" | "Where" | "Async"
470                | "Await" | "Dyn" | "Abstract" | "Become" | "Box" | "Do" | "Final" | "Override"
471                | "Priv" | "Typeof" | "Unsized" | "Virtual" | "Yield" => {
472                    format!("{name}Value")
473                }
474                _ => name,
475            }
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn test_generate_enum_name() {
486        let manager = ValueSetManager::new();
487
488        assert_eq!(
489            manager.generate_enum_name("http://hl7.org/fhir/ValueSet/administrative-gender"),
490            "AdministrativeGender"
491        );
492
493        assert_eq!(
494            manager.generate_enum_name("http://hl7.org/fhir/ValueSet/123-test"),
495            "ValueSet123Test"
496        );
497    }
498
499    #[test]
500    fn test_concept_variant_name() {
501        let concept = ValueSetConcept::new("male".to_string());
502        assert_eq!(concept.to_variant_name(), "Male");
503
504        let concept = ValueSetConcept::new("unknown-gender".to_string());
505        assert_eq!(concept.to_variant_name(), "UnknownGender");
506
507        let concept = ValueSetConcept::new("123-code".to_string());
508        assert_eq!(concept.to_variant_name(), "Code123Code");
509    }
510}