1mod minimize;
28pub mod rules;
29
30use serde::Deserialize;
31use serde_json::Value;
32use std::{
33 collections::HashMap,
34 fmt,
35 path::{Component, Path, PathBuf},
36};
37use walkdir::WalkDir;
38
39#[derive(Debug, thiserror::Error)]
40pub enum Error {
41 #[error("failed to read '{path}': {source}")]
42 Io {
43 path: PathBuf,
44 source: std::io::Error,
45 },
46 #[error("invalid manifest '{path}': {source}")]
47 Manifest {
48 path: PathBuf,
49 source: Box<toml::de::Error>,
50 },
51 #[error("invalid JSON in '{path}': {source}")]
52 Json {
53 path: PathBuf,
54 source: serde_json::Error,
55 },
56 #[error("unknown rule '{0}' (known rules: {known})", known = rules::names().join(", "))]
57 UnknownRule(String),
58 #[error("patch '{patch}' has a target with neither an 'id' nor a 'url'")]
59 UnaddressedTarget { patch: PathBuf },
60 #[error("patch '{patch}' target {target} matched {count} resources, expected exactly 1")]
61 TargetMatchCount {
62 patch: PathBuf,
63 target: Target,
64 count: usize,
65 },
66 #[error("patch '{patch}' failed on {resource}: {source}")]
67 Apply {
68 patch: PathBuf,
69 resource: String,
70 source: json_patch::PatchError,
71 },
72}
73
74#[derive(Debug, Deserialize)]
75#[serde(deny_unknown_fields)]
76struct Manifest {
77 sources: Vec<PathBuf>,
78 #[serde(default)]
79 rules: Vec<RuleConfig>,
80}
81
82#[derive(Debug, Deserialize)]
83#[serde(deny_unknown_fields)]
84struct RuleConfig {
85 name: String,
86 #[serde(default)]
88 resource_types: Vec<String>,
89}
90
91#[derive(Debug, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct PatchFile {
95 pub description: String,
97 pub patches: Vec<ResourcePatch>,
98}
99
100#[derive(Debug, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct ResourcePatch {
103 pub target: Target,
104 pub operations: Vec<json_patch::PatchOperation>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
110#[serde(deny_unknown_fields, rename_all = "camelCase")]
111pub struct Target {
112 pub resource_type: String,
113 pub id: Option<String>,
114 pub url: Option<String>,
115}
116
117impl Target {
118 fn matches(&self, resource: &Value) -> bool {
119 let field = |name: &str| resource.get(name).and_then(Value::as_str);
120 field("resourceType") == Some(self.resource_type.as_str())
121 && self.id.as_deref().is_none_or(|id| field("id") == Some(id))
122 && self
123 .url
124 .as_deref()
125 .is_none_or(|url| field("url") == Some(url))
126 }
127}
128
129impl fmt::Display for Target {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 write!(f, "{}", self.resource_type)?;
132 if let Some(id) = &self.id {
133 write!(f, "/{id}")?;
134 }
135 if let Some(url) = &self.url {
136 write!(f, " ({url})")?;
137 }
138 Ok(())
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum Origin {
145 Patch(PathBuf),
147 Rule(&'static str),
148}
149
150impl fmt::Display for Origin {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 match self {
153 Origin::Patch(path) => write!(f, "patch {}", path.display()),
154 Origin::Rule(name) => write!(f, "rule {name}"),
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct Change {
162 pub source: PathBuf,
164 pub resource: String,
166 pub pointer: String,
168 pub before: Option<Value>,
169 pub after: Option<Value>,
170 pub origin: Origin,
171}
172
173#[derive(Debug)]
175pub struct Output {
176 pub path: PathBuf,
177 pub contents: String,
178}
179
180#[derive(Debug, Default)]
181pub struct Build {
182 pub outputs: Vec<Output>,
183 pub changes: Vec<Change>,
184}
185
186struct LoadedPatch {
187 path: PathBuf,
189 file: PatchFile,
190}
191
192struct ActiveRule<'a> {
193 rule: &'static dyn rules::Rule,
194 config: &'a RuleConfig,
195}
196
197fn read(path: &Path) -> Result<String, Error> {
198 std::fs::read_to_string(path).map_err(|source| Error::Io {
199 path: path.to_path_buf(),
200 source,
201 })
202}
203
204fn parse_json<T: serde::de::DeserializeOwned>(path: &Path, text: &str) -> Result<T, Error> {
205 serde_json::from_str(text).map_err(|source| Error::Json {
206 path: path.to_path_buf(),
207 source,
208 })
209}
210
211fn json_files(root: &Path) -> Result<Vec<PathBuf>, Error> {
212 let mut files = Vec::new();
213 for entry in WalkDir::new(root).sort_by_file_name() {
214 let entry = entry.map_err(|e| Error::Io {
215 path: root.to_path_buf(),
216 source: e.into(),
217 })?;
218 let path = entry.path();
219 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
220 let is_json = path
221 .extension()
222 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
223 if entry.file_type().is_file() && is_json && !name.ends_with(".min.json") {
224 files.push(path.to_path_buf());
225 }
226 }
227 Ok(files)
228}
229
230fn load_patches(dir: &Path) -> Result<Vec<LoadedPatch>, Error> {
231 json_files(dir)?
232 .into_iter()
233 .map(|path| {
234 let file: PatchFile = parse_json(&path, &read(&path)?)?;
235 let relative = path.strip_prefix(dir).unwrap_or(&path).to_path_buf();
236 if file
237 .patches
238 .iter()
239 .any(|p| p.target.id.is_none() && p.target.url.is_none())
240 {
241 return Err(Error::UnaddressedTarget { patch: relative });
242 }
243 Ok(LoadedPatch {
244 path: relative,
245 file,
246 })
247 })
248 .collect()
249}
250
251fn resource_key(resource: &Value) -> String {
252 let field = |name: &str| resource.get(name).and_then(Value::as_str).unwrap_or("?");
253 format!("{}/{}", field("resourceType"), field("id"))
254}
255
256fn lookup(doc: &Value, pointer: &str) -> (String, Option<Value>) {
259 if let Some(parent) = pointer.strip_suffix("/-")
260 && let Some(array) = doc.pointer(parent).and_then(Value::as_array)
261 && let Some(last) = array.len().checked_sub(1)
262 {
263 let resolved = format!("{parent}/{last}");
264 let value = doc.pointer(&resolved).cloned();
265 return (resolved, value);
266 }
267 (pointer.to_string(), doc.pointer(pointer).cloned())
268}
269
270struct Context<'a> {
271 source: &'a Path,
272 patches: &'a [LoadedPatch],
273 rules: &'a [ActiveRule<'a>],
274 matches: &'a mut HashMap<(usize, usize), usize>,
276 changes: &'a mut Vec<Change>,
277}
278
279fn process_resource(resource: &mut Value, ctx: &mut Context<'_>) -> Result<(), Error> {
280 let key = resource_key(resource);
281
282 for (file_index, patch_file) in ctx.patches.iter().enumerate() {
283 for (patch_index, patch) in patch_file.file.patches.iter().enumerate() {
284 if !patch.target.matches(resource) {
285 continue;
286 }
287 *ctx.matches.entry((file_index, patch_index)).or_default() += 1;
288
289 for operation in &patch.operations {
290 let path = operation.path().to_string();
291 let before = resource.pointer(&path).cloned();
292 json_patch::patch(resource, std::slice::from_ref(operation)).map_err(|source| {
293 Error::Apply {
294 patch: patch_file.path.clone(),
295 resource: key.clone(),
296 source,
297 }
298 })?;
299 if matches!(operation, json_patch::PatchOperation::Test(_)) {
300 continue;
301 }
302 let (pointer, after) = lookup(resource, &path);
303 ctx.changes.push(Change {
304 source: ctx.source.to_path_buf(),
305 resource: key.clone(),
306 pointer,
307 before,
308 after,
309 origin: Origin::Patch(patch_file.path.clone()),
310 });
311 }
312 }
313 }
314
315 let resource_type = resource
316 .get("resourceType")
317 .and_then(Value::as_str)
318 .unwrap_or_default()
319 .to_string();
320 for active in ctx.rules {
321 let types = &active.config.resource_types;
322 if !types.is_empty() && !types.contains(&resource_type) {
323 continue;
324 }
325 for edit in active.rule.apply(resource) {
326 ctx.changes.push(Change {
327 source: ctx.source.to_path_buf(),
328 resource: key.clone(),
329 pointer: edit.pointer,
330 before: Some(edit.before),
331 after: Some(edit.after),
332 origin: Origin::Rule(active.rule.name()),
333 });
334 }
335 }
336
337 minimize::minimize_resource(resource);
338 Ok(())
339}
340
341fn normalize(path: &Path) -> PathBuf {
345 let mut normalized = PathBuf::new();
346 for component in path.components() {
347 match component {
348 Component::CurDir => {}
349 Component::ParentDir
350 if matches!(
351 normalized.components().next_back(),
352 Some(Component::Normal(_))
353 ) =>
354 {
355 normalized.pop();
356 }
357 other => normalized.push(other),
358 }
359 }
360 normalized
361}
362
363fn output_path(source: &Path) -> PathBuf {
365 source.with_extension("min.json")
366}
367
368pub fn build(manifest_path: &Path) -> Result<Build, Error> {
378 let dir = manifest_path.parent().unwrap_or(Path::new("."));
379 let manifest: Manifest =
380 toml::from_str(&read(manifest_path)?).map_err(|source| Error::Manifest {
381 path: manifest_path.to_path_buf(),
382 source: Box::new(source),
383 })?;
384
385 let rules = manifest
386 .rules
387 .iter()
388 .map(|config| {
389 rules::find(&config.name)
390 .map(|rule| ActiveRule { rule, config })
391 .ok_or_else(|| Error::UnknownRule(config.name.clone()))
392 })
393 .collect::<Result<Vec<_>, _>>()?;
394 let patches = load_patches(dir)?;
395
396 let mut build = Build::default();
397 let mut matches = HashMap::new();
398
399 for source in &manifest.sources {
400 let source = normalize(&dir.join(source));
401 let files = if source.is_dir() {
402 json_files(&source)?
403 } else {
404 vec![source]
405 };
406
407 for file in files {
408 let mut document: Value = parse_json(&file, &read(&file)?)?;
409 let Some(resource_type) = document.get("resourceType").and_then(Value::as_str) else {
412 continue;
413 };
414
415 let mut ctx = Context {
416 source: &file,
417 patches: &patches,
418 rules: &rules,
419 matches: &mut matches,
420 changes: &mut build.changes,
421 };
422 if resource_type == "Bundle" {
423 let entries = document
424 .get_mut("entry")
425 .and_then(Value::as_array_mut)
426 .into_iter()
427 .flatten();
428 for resource in entries.filter_map(|entry| entry.get_mut("resource")) {
429 process_resource(resource, &mut ctx)?;
430 }
431 } else {
432 process_resource(&mut document, &mut ctx)?;
433 }
434
435 build.outputs.push(Output {
436 path: output_path(&file),
437 contents: serde_json::to_string(&document).map_err(|source| Error::Json {
438 path: file.clone(),
439 source,
440 })?,
441 });
442 }
443 }
444
445 for (file_index, patch_file) in patches.iter().enumerate() {
446 for (patch_index, patch) in patch_file.file.patches.iter().enumerate() {
447 let count = matches
448 .get(&(file_index, patch_index))
449 .copied()
450 .unwrap_or(0);
451 if count != 1 {
452 return Err(Error::TargetMatchCount {
453 patch: patch_file.path.clone(),
454 target: patch.target.clone(),
455 count,
456 });
457 }
458 }
459 }
460
461 Ok(build)
462}