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.is_some() && !include.filter.as_ref().unwrap().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                variant.doc_comment = Some(format!(" {}", display.clone()));
312            }
313
314            // Add serde annotation to map to the original code
315            variant.serde_rename = Some(concept.code.clone());
316
317            rust_enum.add_variant(variant);
318        }
319
320        rust_enum
321    }
322
323    /// Create enum from ValueSet compose concepts
324    fn create_enum_from_concepts(
325        &self,
326        enum_name: &str,
327        concepts: &[ValueSetComposeConcept],
328        value_set_url: &str,
329    ) -> RustEnum {
330        let mut rust_enum = RustEnum::new(enum_name.to_string());
331        rust_enum.doc_comment = Some(format!(" Generated enum for ValueSet: {value_set_url}"));
332
333        for concept in concepts {
334            let variant_name = ValueSetConcept::new(concept.code.clone()).to_variant_name();
335            let mut variant = RustEnumVariant::new(variant_name);
336
337            if let Some(display) = &concept.display {
338                variant.doc_comment = Some(format!(" {}", display.clone()));
339            }
340
341            // Add serde annotation to map to the original code
342            variant.serde_rename = Some(concept.code.clone());
343
344            rust_enum.add_variant(variant);
345        }
346
347        rust_enum
348    }
349
350    /// Generate a basic enum for unknown ValueSets (fallback)
351    pub fn generate_placeholder_enum(&mut self, value_set_url: &str) -> String {
352        let enum_name = self.generate_enum_name(value_set_url);
353
354        if !self.is_cached(value_set_url) {
355            let mut rust_enum = RustEnum::new(enum_name.clone());
356            rust_enum.doc_comment = Some(format!(" Generated enum for ValueSet: {value_set_url}"));
357
358            // Add a placeholder variant
359            rust_enum.add_variant(RustEnumVariant::new("Unknown".to_string()));
360
361            self.cache_value_set(value_set_url.to_string(), enum_name.clone(), rust_enum);
362        }
363
364        enum_name
365    }
366}
367
368impl Default for ValueSetManager {
369    fn default() -> Self {
370        Self::new()
371    }
372}
373
374/// Represents a FHIR ValueSet concept
375#[derive(Debug, Clone)]
376pub struct ValueSetConcept {
377    pub code: String,
378    pub display: Option<String>,
379    pub definition: Option<String>,
380    pub system: Option<String>,
381}
382
383impl ValueSetConcept {
384    pub fn new(code: String) -> Self {
385        Self {
386            code,
387            display: None,
388            definition: None,
389            system: None,
390        }
391    }
392
393    /// Convert the concept code to a valid Rust enum variant name
394    pub fn to_variant_name(&self) -> String {
395        // Handle special cases first
396        let sanitized_code = match self.code.as_str() {
397            "=" => "Equal".to_string(),
398            "!=" => "NotEqual".to_string(),
399            "<" => "LessThan".to_string(),
400            "<=" => "LessThanOrEqual".to_string(),
401            ">" => "GreaterThan".to_string(),
402            ">=" => "GreaterThanOrEqual".to_string(),
403            "+" => "Plus".to_string(),
404            "-" => "Minus".to_string(),
405            "*" => "Star".to_string(),
406            "/" => "Slash".to_string(),
407            "&" => "Ampersand".to_string(),
408            "|" => "Pipe".to_string(),
409            "%" => "Percent".to_string(),
410            "#" => "Hash".to_string(),
411            "@" => "At".to_string(),
412            "!" => "Exclamation".to_string(),
413            "?" => "Question".to_string(),
414            "^" => "Caret".to_string(),
415            "~" => "Tilde".to_string(),
416            "(" => "LeftParen".to_string(),
417            ")" => "RightParen".to_string(),
418            "[" => "LeftBracket".to_string(),
419            "]" => "RightBracket".to_string(),
420            "{" => "LeftBrace".to_string(),
421            "}" => "RightBrace".to_string(),
422            "'" => "SingleQuote".to_string(),
423            "\"" => "DoubleQuote".to_string(),
424            "`" => "Backtick".to_string(),
425            "$" => "Dollar".to_string(),
426            ";" => "Semicolon".to_string(),
427            ":" => "Colon".to_string(),
428            "," => "Comma".to_string(),
429            _ => {
430                // For other codes, sanitize by removing/replacing invalid characters
431                self.code
432                    .chars()
433                    .map(|c| match c {
434                        'a'..='z' | 'A'..='Z' | '0'..='9' => c.to_string(),
435                        '-' | '_' | '.' | ' ' => "-".to_string(), // Convert to dash for splitting
436                        _ => format!("_{:02x}", c as u32),        // Convert other characters to hex
437                    })
438                    .collect::<String>()
439            }
440        };
441
442        // Convert kebab-case, snake_case, or other formats to PascalCase
443        let name = sanitized_code
444            .split(&['-', '_', '.', ' '][..])
445            .filter(|part| !part.is_empty()) // Filter out empty parts
446            .map(|part| {
447                let mut chars = part.chars();
448                match chars.next() {
449                    None => String::new(),
450                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
451                }
452            })
453            .collect::<String>();
454
455        // Ensure it starts with a letter and is not empty
456        if name.is_empty() {
457            "Unknown".to_string()
458        } else if name.chars().next().unwrap_or('0').is_ascii_digit() {
459            format!("Code{name}")
460        } else {
461            name
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn test_generate_enum_name() {
472        let manager = ValueSetManager::new();
473
474        assert_eq!(
475            manager.generate_enum_name("http://hl7.org/fhir/ValueSet/administrative-gender"),
476            "AdministrativeGender"
477        );
478
479        assert_eq!(
480            manager.generate_enum_name("http://hl7.org/fhir/ValueSet/123-test"),
481            "ValueSet123Test"
482        );
483    }
484
485    #[test]
486    fn test_concept_variant_name() {
487        let concept = ValueSetConcept::new("male".to_string());
488        assert_eq!(concept.to_variant_name(), "Male");
489
490        let concept = ValueSetConcept::new("unknown-gender".to_string());
491        assert_eq!(concept.to_variant_name(), "UnknownGender");
492
493        let concept = ValueSetConcept::new("123-code".to_string());
494        assert_eq!(concept.to_variant_name(), "Code123Code");
495    }
496}