use std::path::PathBuf;
fn crate_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
const SERDE_ATTR: &str = " #[serde(default, skip_serializing_if = \"Vec::is_empty\")]";
#[must_use]
pub fn apply_file(text: &str) -> (String, usize) {
let lines: Vec<&str> = text.lines().collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len() + 64);
let mut converted: Vec<String> = Vec::new();
let mut count = 0;
for line in &lines {
if let Some(field) = parse_option_vec_field(line) {
out.push(SERDE_ATTR.to_string());
out.push(retype_line(line));
converted.push(field);
count += 1;
} else {
out.push((*line).to_string());
}
}
if count == 0 {
return (text.to_string(), 0);
}
let removed: std::collections::HashSet<&str> = converted.iter().map(String::as_str).collect();
let mut in_expect = 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;
} else if in_expect {
if t == "};" {
in_expect = false;
} else {
let field = t.split(':').next().unwrap_or("").trim();
if removed.contains(field) && t.ends_with("None,") {
continue;
}
}
}
filtered.push(line);
}
let mut joined = filtered.join("\n");
if text.ends_with('\n') {
joined.push('\n');
}
(joined, count)
}
fn parse_option_vec_field(line: &str) -> Option<String> {
let t = line.trim_start();
let rest = t.strip_prefix("pub ")?;
let colon = rest.find(':')?;
let name = rest[..colon].trim().to_string();
let after = rest[colon + 1..].trim_start();
if after.starts_with("Option<Vec<") {
Some(name)
} else {
None
}
}
fn retype_line(line: &str) -> String {
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
let (code, comment) = match line.split_once("//") {
Some((c, cm)) => (c.trim_end(), Some(cm)),
None => (line.trim_end(), None),
};
let code = code.trim();
let body = code.trim_end_matches(',');
let (decl, ty) = body.split_once(':').expect("field has a colon");
let ty = ty.trim();
let new_ty = ty
.strip_prefix("Option<")
.and_then(|s| s.strip_suffix('>'))
.unwrap_or(ty);
let mut result = format!("{indent}{}: {new_ty},", decl.trim());
if let Some(cm) = comment {
result.push_str(" //");
result.push_str(cm);
}
result
}
pub fn apply_group(group: &str) -> usize {
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 (new_text, count) = apply_file(&text);
if count > 0 {
std::fs::write(&file, new_text).expect("write");
total += count;
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retypes_a_field() {
let (out, n) = apply_file(" pub name: Option<Vec<types::HumanName>>,\n");
assert_eq!(n, 1);
assert!(out.contains("pub name: Vec<types::HumanName>,"), "{out}");
assert!(out.contains("skip_serializing_if = \"Vec::is_empty\""), "{out}");
}
#[test]
fn preserves_comment_and_nested_generics() {
let (out, _) = apply_file(" pub contained: Option<Vec<::serde_json::Value>>, // « x »\n");
assert!(out.contains("pub contained: Vec<::serde_json::Value>, // « x »"), "{out}");
}
#[test]
#[ignore = "writes src/r5/resources/*.rs"]
fn apply_resources() {
println!("resources: converted {} Option<Vec> fields", apply_group("resources"));
}
#[test]
#[ignore = "writes src/r5/types/*.rs"]
fn apply_types() {
println!("types: converted {} Option<Vec> fields", apply_group("types"));
}
}