Skip to main content

haste_health/commands/
artifacts.rs

1use clap::Subcommand;
2use haste_artifact_patcher::{Build, Change};
3use haste_fhir_model::r4::generated::terminology::IssueType;
4use haste_fhir_operation_error::OperationOutcomeError;
5use serde_json::Value;
6use similar::{ChangeTag, TextDiff};
7use std::{
8    fmt::Write,
9    path::{Path, PathBuf},
10};
11
12/// Patch externally provided (HL7) artifacts without editing the upstream files.
13#[derive(Subcommand, Debug)]
14pub(crate) enum ArtifactCommands {
15    /// Apply a package's patches and rules to its upstream files and write the `.min.json` outputs.
16    Build {
17        /// Patch manifest(s), e.g. `../artifacts/r4/hl7-core/patches/manifest.toml`.
18        #[arg(required = true)]
19        manifest: Vec<PathBuf>,
20        /// Fail if any output is out of date instead of writing it.
21        #[arg(long)]
22        check: bool,
23    },
24    /// List every change the patches and rules make to the upstream resources.
25    Diff {
26        /// Patch manifest(s), e.g. `../artifacts/r4/hl7-core/patches/manifest.toml`.
27        #[arg(required = true)]
28        manifest: Vec<PathBuf>,
29        /// Only show resources whose `ResourceType/id` contains this text.
30        #[arg(short, long)]
31        resource: Option<String>,
32        /// Only show changes whose origin (`patch <file>` or `rule <name>`) contains this text.
33        #[arg(short, long)]
34        origin: Option<String>,
35        /// Print a count of changes per origin instead of the changes.
36        #[arg(long)]
37        summary: bool,
38    },
39}
40
41fn error(message: String) -> OperationOutcomeError {
42    OperationOutcomeError::error(IssueType::exception(), message)
43}
44
45fn build(manifest: &Path) -> Result<Build, OperationOutcomeError> {
46    haste_artifact_patcher::build(manifest).map_err(|e| error(e.to_string()))
47}
48
49fn render(value: Option<&Value>) -> String {
50    match value {
51        None => "(absent)".to_string(),
52        Some(Value::String(text)) => text.clone(),
53        Some(other) => other.to_string(),
54    }
55}
56
57fn print_value(prefix: char, value: Option<&Value>) {
58    for line in render(value).lines() {
59        println!("    {prefix} {line}");
60    }
61}
62
63/// Word diff of two strings in `git diff --word-diff` style
64/// (`[-removed-]{+added+}`), printing only the lines that changed.
65fn print_text_diff(before: &str, after: &str) {
66    let mut marked = String::new();
67    for change in TextDiff::from_words(before, after).iter_all_changes() {
68        match change.tag() {
69            ChangeTag::Equal => marked.push_str(change.value()),
70            ChangeTag::Delete => {
71                let _ = write!(marked, "[-{}-]", change.value());
72            }
73            ChangeTag::Insert => {
74                let _ = write!(marked, "{{+{}+}}", change.value());
75            }
76        }
77    }
78    for line in marked
79        .lines()
80        .filter(|l| l.contains("[-") || l.contains("{+"))
81    {
82        println!("    ~ {line}");
83    }
84}
85
86fn print_changes(changes: &[&Change]) {
87    let mut current = None;
88    for change in changes {
89        let heading = (&change.resource, &change.origin);
90        if current != Some(heading) {
91            println!(
92                "\n{}  [{}]  ({})",
93                change.resource,
94                change.origin,
95                change.source.display()
96            );
97            current = Some(heading);
98        }
99        println!("  {}", change.pointer);
100        if let (Some(Value::String(before)), Some(Value::String(after))) =
101            (&change.before, &change.after)
102        {
103            print_text_diff(before, after);
104        } else {
105            print_value('-', change.before.as_ref());
106            print_value('+', change.after.as_ref());
107        }
108    }
109}
110
111fn print_summary(changes: &[&Change]) {
112    let mut counts: Vec<(String, usize)> = Vec::new();
113    for change in changes {
114        let origin = change.origin.to_string();
115        match counts.iter_mut().find(|(name, _)| *name == origin) {
116            Some((_, count)) => *count += 1,
117            None => counts.push((origin, 1)),
118        }
119    }
120    for (origin, count) in counts {
121        println!("{count:>8}  {origin}");
122    }
123}
124
125/// Runs the `artifacts` command group.
126pub(crate) async fn run(command: &ArtifactCommands) -> Result<(), OperationOutcomeError> {
127    match command {
128        ArtifactCommands::Build { manifest, check } => {
129            let mut stale = Vec::new();
130            for manifest in manifest {
131                let result = build(manifest)?;
132                for output in result.outputs {
133                    let current = tokio::fs::read_to_string(&output.path).await.ok();
134                    if current.as_deref() == Some(output.contents.as_str()) {
135                        continue;
136                    }
137                    if *check {
138                        stale.push(output.path.display().to_string());
139                    } else {
140                        tokio::fs::write(&output.path, output.contents)
141                            .await
142                            .map_err(|e| error(format!("{}: {e}", output.path.display())))?;
143                        println!("Wrote {}", output.path.display());
144                    }
145                }
146                println!(
147                    "{}: {} change(s) from patches and rules",
148                    manifest.display(),
149                    result.changes.len()
150                );
151            }
152
153            if stale.is_empty() {
154                Ok(())
155            } else {
156                Err(error(format!(
157                    "Out of date, run `cargo run artifacts build` to regenerate: {}",
158                    stale.join(", ")
159                )))
160            }
161        }
162        ArtifactCommands::Diff {
163            manifest,
164            resource,
165            origin,
166            summary,
167        } => {
168            for manifest in manifest {
169                let result = build(manifest)?;
170                let changes = result
171                    .changes
172                    .iter()
173                    .filter(|c| {
174                        resource
175                            .as_ref()
176                            .is_none_or(|r| c.resource.contains(r.as_str()))
177                    })
178                    .filter(|c| {
179                        origin
180                            .as_ref()
181                            .is_none_or(|o| c.origin.to_string().contains(o.as_str()))
182                    })
183                    .collect::<Vec<_>>();
184
185                println!("# {} ({} change(s))", manifest.display(), changes.len());
186                if *summary {
187                    print_summary(&changes);
188                } else {
189                    print_changes(&changes);
190                }
191            }
192            Ok(())
193        }
194    }
195}