package_metadata.rs (6282B)
1 use std::{fs, path::Path}; 2 3 use crate::{ 4 build_control::Mode, 5 sdk_generation::{ 6 fs::{write_bytes_if_changed, write_or_check}, 7 package_matrix::{package_specs, wasm_package_specs}, 8 }, 9 }; 10 11 pub(crate) const PACKAGE_README_FILE: &str = "README.md"; 12 pub(crate) const PACKAGE_LICENSE_FILES: [&str; 2] = ["LICENSE-MIT", "LICENSE-APACHE"]; 13 pub(crate) fn generate_package_metadata( 14 source_root: &Path, 15 consumer_root: &Path, 16 mode: Mode, 17 ) -> Result<(), String> { 18 for spec in package_specs() { 19 write_package_metadata(source_root, &consumer_root.join(spec.package_dir), mode)?; 20 println!("generated package metadata {}", spec.package_name); 21 } 22 for spec in wasm_package_specs() { 23 write_package_metadata(source_root, &consumer_root.join(spec.package_dir), mode)?; 24 println!("generated package metadata {}", spec.package_name); 25 } 26 Ok(()) 27 } 28 29 pub(crate) fn package_description<'a>( 30 json: &'a serde_json::Value, 31 package_json_path: &Path, 32 ) -> Result<&'a str, String> { 33 let description = json 34 .get("description") 35 .and_then(serde_json::Value::as_str) 36 .ok_or_else(|| { 37 format!( 38 "package.json missing description: {}", 39 package_json_path.display() 40 ) 41 })?; 42 if description.trim().is_empty() || description.trim() != description { 43 return Err(format!( 44 "package.json description must be non-empty and trimmed: {}", 45 package_json_path.display() 46 )); 47 } 48 Ok(description) 49 } 50 51 pub(crate) fn package_readme(package_name: &str, description: &str) -> String { 52 format!( 53 "# {package_name}\n\n{description}\n\nThis package publishes generated ESM JavaScript, TypeScript declarations, and any runtime artifacts from the Radroots SDK build pipeline. Runtime files are distributed from `dist/`; source and provenance metadata are kept outside the npm package payload.\n\n## License\n\nLicensed under either MIT or Apache-2.0, at your option. See `LICENSE-MIT` and `LICENSE-APACHE`.\n" 54 ) 55 } 56 57 fn write_package_metadata( 58 source_root: &Path, 59 package_dir: &Path, 60 mode: Mode, 61 ) -> Result<bool, String> { 62 let package_json_path = package_dir.join("package.json"); 63 let json = read_package_json(&package_json_path)?; 64 let package_name = package_name(&json, &package_json_path)?; 65 let description = package_description(&json, &package_json_path)?; 66 let readme_path = package_dir.join(PACKAGE_README_FILE); 67 let readme = package_readme(package_name, description); 68 let previous_readme = fs::read_to_string(&readme_path).ok(); 69 write_or_check(&readme_path, &readme, mode)?; 70 let mut changed = previous_readme.as_deref() != Some(readme.as_str()); 71 for file_name in PACKAGE_LICENSE_FILES { 72 let source_path = source_root.join(file_name); 73 let contents = fs::read(&source_path) 74 .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?; 75 let destination = package_dir.join(file_name); 76 match mode { 77 Mode::Write => changed |= write_bytes_if_changed(&destination, &contents)?, 78 Mode::Check => { 79 let current = fs::read(&destination).map_err(|error| { 80 format!("failed to read {}: {error}", destination.display()) 81 })?; 82 if current != contents { 83 return Err(format!( 84 "stale package license metadata: {}", 85 destination.display() 86 )); 87 } 88 } 89 } 90 } 91 Ok(changed) 92 } 93 94 fn read_package_json(path: &Path) -> Result<serde_json::Value, String> { 95 let raw = fs::read_to_string(path) 96 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 97 serde_json::from_str::<serde_json::Value>(&raw) 98 .map_err(|error| format!("failed to parse {}: {error}", path.display())) 99 } 100 101 fn package_name<'a>( 102 json: &'a serde_json::Value, 103 package_json_path: &Path, 104 ) -> Result<&'a str, String> { 105 json.get("name") 106 .and_then(serde_json::Value::as_str) 107 .ok_or_else(|| format!("package.json missing name: {}", package_json_path.display())) 108 } 109 110 #[cfg(test)] 111 mod tests { 112 use std::{ 113 fs, 114 path::PathBuf, 115 time::{SystemTime, UNIX_EPOCH}, 116 }; 117 118 use crate::build_control::Mode; 119 120 use super::{package_readme, write_package_metadata}; 121 122 #[test] 123 fn package_metadata_generation_is_deterministic() { 124 let root = test_root("metadata_generation"); 125 let package_dir = root.join("packages/example"); 126 fs::create_dir_all(&package_dir).expect("create package"); 127 fs::write(root.join("LICENSE-MIT"), "MIT license\n").expect("write MIT license"); 128 fs::write(root.join("LICENSE-APACHE"), "Apache license\n").expect("write Apache license"); 129 fs::write( 130 package_dir.join("package.json"), 131 r#"{ 132 "name": "@radroots/example", 133 "description": "Example package" 134 }"#, 135 ) 136 .expect("write package json"); 137 138 assert!( 139 write_package_metadata(&root, &package_dir, Mode::Write).expect("first generation") 140 ); 141 assert_eq!( 142 fs::read_to_string(package_dir.join("README.md")).expect("read README"), 143 package_readme("@radroots/example", "Example package") 144 ); 145 assert_eq!( 146 fs::read_to_string(package_dir.join("LICENSE-MIT")).expect("read MIT"), 147 "MIT license\n" 148 ); 149 assert!( 150 !write_package_metadata(&root, &package_dir, Mode::Write).expect("second generation") 151 ); 152 write_package_metadata(&root, &package_dir, Mode::Check).expect("fresh metadata"); 153 154 let _ = fs::remove_dir_all(root); 155 } 156 157 fn test_root(name: &str) -> PathBuf { 158 let stamp = SystemTime::now() 159 .duration_since(UNIX_EPOCH) 160 .expect("system time after epoch") 161 .as_nanos(); 162 let root = std::env::temp_dir().join(format!( 163 "radroots_sdk_xtask_package_metadata_{name}_{}_{}", 164 std::process::id(), 165 stamp 166 )); 167 let _ = fs::remove_dir_all(&root); 168 root 169 } 170 }