use serde_json::{json, Value}; use std::{env, fs, path::PathBuf}; fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str { meta.get(key) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| panic!("{file}: meta.{key} must be a non-empty string")) } fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); let lang_dir = manifest_dir.join("lang"); println!("cargo:rerun-if-changed={}", lang_dir.display()); let mut files: Vec = fs::read_dir(&lang_dir) .unwrap_or_else(|error| panic!("cannot read {}: {error}", lang_dir.display())) .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json")) .collect(); files.sort(); if files.is_empty() { panic!("no language files found in {}", lang_dir.display()); } let mut assets = Vec::new(); let mut manifest_languages = Vec::new(); let mut has_english = false; for path in files { println!("cargo:rerun-if-changed={}", path.display()); let filename = path.file_name().and_then(|v| v.to_str()).expect("UTF-8 language filename"); let stem = path.file_stem().and_then(|v| v.to_str()).expect("UTF-8 language code"); if !stem.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { panic!("{filename}: filename may only contain ASCII letters, digits, '-' and '_'"); } let source = fs::read_to_string(&path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); let document: Value = serde_json::from_str(&source) .unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}")); let root_object = document.as_object().unwrap_or_else(|| panic!("{filename}: language pack root must be an object")); let unexpected: Vec<&str> = root_object.keys() .filter(|key| key.as_str() != "meta" && key.as_str() != "translations") .map(|key| key.as_str()) .collect(); if !unexpected.is_empty() { panic!("{filename}: unexpected top-level keys {:?}; translation keys must be inside 'translations'", unexpected); } let meta = document.get("meta").unwrap_or_else(|| panic!("{filename}: missing meta object")); let code = required_string(meta, "code", filename); let name = required_string(meta, "name", filename); let native_name = required_string(meta, "native_name", filename); let locale = required_string(meta, "locale", filename); if code != stem { panic!("{filename}: meta.code '{code}' must match filename '{stem}.json'"); } if !document.get("translations").is_some_and(Value::is_object) { panic!("{filename}: translations must be a JSON object"); } if code == "en" { has_english = true; } manifest_languages.push(json!({ "code": code, "name": name, "native_name": native_name, "locale": locale, "path": format!("lang/{code}.json") })); assets.push((code.to_owned(), path)); } if !has_english { panic!("lang/en.json is required as the default fallback language"); } let manifest_json = serde_json::to_string(&json!({ "default": "en", "languages": manifest_languages })).expect("serialize language manifest"); let mut generated = String::new(); generated.push_str("// @generated by build.rs - do not edit.\n"); generated.push_str(&format!("pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n", manifest_json)); generated.push_str("pub const LANGUAGE_ASSETS: &[(&str, &str)] = &[\n"); for (code, path) in assets { generated.push_str(&format!( " ({:?}, include_str!({:?})),\n", code, path.to_string_lossy() )); } generated.push_str("];\n"); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); fs::write(out_dir.join("languages.rs"), generated).expect("write generated languages.rs"); }