use std::collections::BTreeMap;
use std::path::PathBuf;
use ::convert_case::{Case, Casing};
use crate::r5::meta;
const PRIMITIVES: &[&str] = &[
"base64Binary", "boolean", "canonical", "code", "date", "dateTime", "decimal", "id", "instant",
"integer", "integer64", "markdown", "oid", "positiveInt", "string", "time", "unsignedInt",
"uri", "url", "uuid", "xhtml",
];
#[derive(Debug, Clone)]
pub struct Variant {
pub code: String,
pub variant: String,
pub key: String,
pub old_field: String,
pub rust_type: String,
pub primitive: bool,
}
#[derive(Debug, Clone)]
pub struct ChoiceConversion {
pub path: String,
pub struct_name: String,
pub field: String,
pub enum_name: String,
pub variants: Vec<Variant>,
}
fn crate_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn type_snake(code: &str) -> String {
match code {
"base64Binary" => "base64_binary".to_string(),
"integer64" => "integer64".to_string(),
other => other.to_case(Case::Snake),
}
}
fn cap_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
}
fn struct_path_prefixes() -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
for el in meta::elements() {
let segments: Vec<&str> = el.path.split('.').collect();
for take in 1..segments.len() {
let prefix = &segments[..take];
let name: String = prefix.iter().map(|s| s.to_case(Case::Pascal)).collect();
map.entry(name).or_insert_with(|| prefix.join("."));
}
}
map
}
#[must_use]
pub fn plan() -> Vec<ChoiceConversion> {
let prefix_to_struct: BTreeMap<String, String> = struct_path_prefixes()
.into_iter()
.map(|(name, prefix)| (prefix, name))
.collect();
let mut plan = Vec::new();
for el in meta::elements() {
if !el.path.ends_with("[x]") {
continue;
}
let base_camel = el.path.rsplit('.').next().unwrap().trim_end_matches("[x]");
let parent_prefix = &el.path[..el.path.len() - base_camel.len() - "[x]".len() - 1];
let Some(struct_name) = prefix_to_struct.get(parent_prefix) else {
continue;
};
let field = base_camel.to_case(Case::Snake);
let enum_name = format!("{struct_name}{}", base_camel.to_case(Case::Pascal));
let variants = el
.types
.iter()
.map(|t| {
let variant = t.code.to_case(Case::Pascal);
Variant {
key: format!("{base_camel}{}", cap_first(t.code)),
old_field: format!("{field}_{}", type_snake(t.code)),
rust_type: format!("types::{variant}"),
primitive: PRIMITIVES.contains(&t.code),
variant,
code: t.code.to_string(),
}
})
.collect();
plan.push(ChoiceConversion {
path: el.path.to_string(),
struct_name: struct_name.clone(),
field,
enum_name,
variants,
});
}
plan
}
fn struct_spans(lines: &[&str]) -> Vec<(String, usize, usize)> {
let mut spans = Vec::new();
let mut i = 0;
while i < lines.len() {
if let Some(rest) = lines[i].trim_start().strip_prefix("pub struct ") {
let name: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if !lines[i].contains('{') {
i += 1; continue;
}
let mut depth = 0i32;
let mut j = i;
let mut started = false;
while j < lines.len() {
for c in lines[j].chars() {
if c == '{' {
depth += 1;
started = true;
} else if c == '}' {
depth -= 1;
}
}
if started && depth == 0 {
break;
}
j += 1;
}
spans.push((name, i, j));
i = j + 1;
} else {
i += 1;
}
}
spans
}
fn render_enum(c: &ChoiceConversion) -> String {
use std::fmt::Write as _;
let mut s = String::new();
let _ = writeln!(
s,
"\n/// The `{}` choice element (see spec/11-choice-types.md).",
c.path
);
s.push_str("#[derive(Debug, Clone, PartialEq, Eq, fhir_derive_macros::FhirChoice, Validate)]\n");
s.push_str("#[allow(clippy::large_enum_variant)]\n");
let _ = writeln!(s, "pub enum {} {{", c.enum_name);
for v in &c.variants {
let _ = writeln!(s, " /// `{}` variant.", v.key);
let _ = writeln!(s, " #[fhir(\"{}\")]", v.key);
let inner = if v.primitive {
format!("crate::r5::choice::Primitive<{}>", v.rust_type)
} else {
format!("Box<{}>", v.rust_type)
};
let _ = writeln!(s, " {}({inner}),", v.variant);
}
s.push_str("}\n");
s
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn apply_file(text: &str, conversions: &[ChoiceConversion], skipped: &mut Vec<String>) -> (String, usize) {
let lines: Vec<&str> = text.lines().collect();
let spans = struct_spans(&lines);
let mut edits: Vec<(usize, usize, String)> = Vec::new();
let mut enums = String::new();
let mut removed_fields: Vec<String> = Vec::new();
let mut applied = 0;
for c in conversions {
if text.contains(&format!("pub enum {}", c.enum_name)) {
continue;
}
let Some((_, s_start, s_end)) = spans.iter().find(|(n, ..)| n == &c.struct_name) else {
skipped.push(format!("{}: struct not in file", c.path));
continue;
};
let mut field_lines = Vec::new();
let mut all_found = true;
for v in &c.variants {
let needle = format!("pub {}:", v.old_field);
if let Some(i) =
(*s_start..=*s_end).find(|&i| lines[i].trim_start().starts_with(&needle))
{
field_lines.push(i);
} else {
all_found = false;
break;
}
}
if !all_found {
skipped.push(format!("{}: missing flattened fields", c.path));
continue;
}
field_lines.sort_unstable();
let first = *field_lines.first().unwrap();
let last = *field_lines.last().unwrap();
let ours: std::collections::HashSet<usize> = field_lines.iter().copied().collect();
let interloper = (first..=last).any(|i| {
lines[i].trim_start().starts_with("pub ") && !ours.contains(&i)
});
if interloper {
skipped.push(format!("{}: flattened fields not contiguous", c.path));
continue;
}
let mut start = first;
while start > *s_start {
let prev = lines[start - 1].trim_start();
if prev.starts_with("///") || prev.starts_with("#[") {
start -= 1;
} else {
break;
}
}
let replacement = format!(
" /// The `{}` choice element (0..1); see [`{}`].\n #[serde(flatten)]\n pub {}: Option<{}>,",
c.path, c.enum_name, c.field, c.enum_name
);
edits.push((start, last, replacement));
enums.push_str(&render_enum(c));
for v in &c.variants {
removed_fields.push(v.old_field.clone());
}
applied += 1;
}
if applied == 0 {
return (text.to_string(), 0);
}
let mut out: Vec<String> = lines.iter().map(|s| (*s).to_string()).collect();
edits.sort_by_key(|e| std::cmp::Reverse(e.0));
for (start, end, replacement) in edits {
out.splice(start..=end, std::iter::once(replacement));
}
let removed: std::collections::HashSet<&str> = removed_fields.iter().map(String::as_str).collect();
let mut in_expect = false;
let mut has_rest = false;
let mut filtered: Vec<String> = Vec::with_capacity(out.len());
for line in out {
let t = line.trim_start();
if t.starts_with("let expect = T {") {
in_expect = true;
has_rest = false;
filtered.push(line);
continue;
}
if in_expect {
let field = t.split(':').next().unwrap_or("").trim();
if removed.contains(field) {
continue; }
if t.contains("..Default::default()") {
has_rest = true;
}
if t == "};" {
if !has_rest {
filtered.push(" ..Default::default()".to_string());
}
in_expect = false;
}
}
filtered.push(line);
}
let mut joined = filtered.join("\n");
joined.push_str(&enums);
if text.ends_with('\n') && !joined.ends_with('\n') {
joined.push('\n');
}
(joined, applied)
}
pub fn apply_group(group: &str, skipped: &mut Vec<String>) -> usize {
let plan = plan();
let dir = crate_root().join("src").join("r5").join(group);
let mut total = 0;
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("read dir")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "rs"))
.collect();
files.sort();
for file in files {
let text = std::fs::read_to_string(&file).expect("read");
let names: std::collections::HashSet<String> = struct_spans(
&text.lines().collect::<Vec<_>>(),
)
.into_iter()
.map(|(n, ..)| n)
.collect();
let here: Vec<ChoiceConversion> =
plan.iter().filter(|c| names.contains(&c.struct_name)).cloned().collect();
if here.is_empty() {
continue;
}
let (new_text, applied) = apply_file(&text, &here, skipped);
if applied > 0 {
std::fs::write(&file, new_text).expect("write");
total += applied;
}
}
total
}
#[cfg(test)]
fn all_source() -> String {
let mut s = String::new();
for group in ["types", "resources"] {
let dir = crate_root().join("src").join("r5").join(group);
for entry in std::fs::read_dir(&dir).expect("read dir").flatten() {
let p = entry.path();
if p.extension().is_some_and(|x| x == "rs") {
s.push_str(&std::fs::read_to_string(&p).expect("read"));
s.push('\n');
}
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_covers_known_choices() {
let plan = plan();
let obs = plan.iter().find(|c| c.path == "Observation.value[x]").expect("value[x]");
assert_eq!(obs.enum_name, "ObservationValue");
assert_eq!(obs.field, "value");
assert_eq!(obs.variants.len(), 13);
let q = obs.variants.iter().find(|v| v.code == "Quantity").unwrap();
assert_eq!(q.key, "valueQuantity");
assert_eq!(q.old_field, "value_quantity");
assert!(!q.primitive);
let s = obs.variants.iter().find(|v| v.code == "string").unwrap();
assert_eq!(s.key, "valueString");
assert_eq!(s.variant, "String");
assert!(s.primitive);
}
#[test]
#[ignore = "dry-run report only"]
fn report() {
let plan = plan();
let src = all_source();
println!("choice conversions planned: {}", plan.len());
let mut missing = 0;
for c in &plan {
let absent: Vec<&str> = c
.variants
.iter()
.filter(|v| !src.contains(&format!("pub {}:", v.old_field)))
.map(|v| v.old_field.as_str())
.collect();
if !absent.is_empty() {
missing += 1;
println!(" {} ({}): missing fields {:?}", c.path, c.enum_name, absent);
}
}
println!("conversions with missing/renamed fields: {missing} of {}", plan.len());
}
#[test]
#[ignore = "writes src/r5/types/*.rs"]
fn apply_types() {
let mut skipped = Vec::new();
let n = apply_group("types", &mut skipped);
println!("types: applied {n} choice conversions; skipped {}", skipped.len());
for s in &skipped {
println!(" SKIP {s}");
}
}
#[test]
#[ignore = "writes src/r5/resources/*.rs"]
fn apply_resources() {
let mut skipped = Vec::new();
let n = apply_group("resources", &mut skipped);
println!("resources: applied {n} choice conversions; skipped {}", skipped.len());
for s in &skipped {
println!(" SKIP {s}");
}
}
}