Skip to main content

haste_artifact_patcher/
lib.rs

1//! Applies patches and rules to externally provided FHIR artifacts (for example
2//! the HL7 core package) without editing the files that came from upstream.
3//!
4//! A package opts in with a `patches/manifest.toml`:
5//!
6//! ```toml
7//! # Upstream files or directories, relative to this manifest. Every `*.json`
8//! # resource found is patched and written next to it as `<name>.min.json`.
9//! sources = ["../definitions/hl7"]
10//!
11//! # Rules are transforms implemented in Rust (see `rules`) that run over every
12//! # resource, optionally limited to some resource types.
13//! [[rules]]
14//! name = "clippy-doc-markdown"
15//! ```
16//!
17//! Every other `*.json` file under `patches/` is a [`PatchFile`]: a description
18//! plus RFC 6902 JSON Patch operations scoped to a single resource, which is
19//! addressed by `resourceType` and `id` or `url` rather than by its position in
20//! a bundle.
21//!
22//! The pipeline for each resource is: targeted patches (in file path order),
23//! then rules (in manifest order), then minimization. Every change made by a
24//! patch or rule is recorded as a [`Change`], so the complete set of
25//! differences from upstream can be listed without diffing the output files.
26
27mod 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    /// Restrict the rule to these resource types. Empty means every resource.
87    #[serde(default)]
88    resource_types: Vec<String>,
89}
90
91/// A file of targeted edits to upstream resources.
92#[derive(Debug, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct PatchFile {
95    /// Why the upstream resources are being changed.
96    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    /// RFC 6902 operations; paths are relative to the targeted resource.
105    pub operations: Vec<json_patch::PatchOperation>,
106}
107
108/// Identifies one resource across all sources of a package.
109#[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/// What produced a [`Change`].
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum Origin {
145    /// A patch file, relative to the manifest directory.
146    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/// One edit to an upstream resource. `None` means the value is absent.
160#[derive(Debug, Clone)]
161pub struct Change {
162    /// Upstream file the resource was read from.
163    pub source: PathBuf,
164    /// `ResourceType/id`.
165    pub resource: String,
166    /// JSON pointer into the resource.
167    pub pointer: String,
168    pub before: Option<Value>,
169    pub after: Option<Value>,
170    pub origin: Origin,
171}
172
173/// A file produced by [`build`].
174#[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    /// Relative to the manifest directory.
188    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
256/// Value at `pointer`, resolving a trailing `-` (JSON Patch "end of array") to
257/// the last element so an append can be reported where it landed.
258fn 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    /// Resources matched per (patch file, patch) so stale targets are reported.
275    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
341/// Resolves `.` and `..` in `path` without touching the filesystem, so
342/// reported source paths read `pkg/definitions/x.json` rather than
343/// `pkg/patches/../definitions/x.json`.
344fn 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
363/// Output path for an upstream file: `foo.json` becomes `foo.min.json`.
364fn output_path(source: &Path) -> PathBuf {
365    source.with_extension("min.json")
366}
367
368/// Reads the manifest at `manifest_path`, applies its patches and rules to the
369/// upstream sources, and returns the files to write and every change made.
370/// Nothing is written to disk.
371///
372/// # Errors
373///
374/// Returns an error if the manifest, a patch file or a source cannot be read or
375/// parsed, if the manifest names an unknown rule, if a patch operation fails,
376/// or if a patch target does not match exactly one resource.
377pub 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            // Not every JSON file in an upstream package is a resource
410            // (e.g. fhir.schema.json); those are left alone.
411            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}