Skip to main content

haste_health/commands/
package.rs

1//! Install FHIR packages (implementation guides) into the active profile's
2//! project, through `ImplementationGuide/$install` and `$uninstall`.
3
4use crate::cli::state::CliState;
5use base64::{Engine as _, engine::general_purpose::STANDARD};
6use clap::Subcommand;
7use haste_fhir_client::{FHIRClient, url::ParsedParameters};
8use haste_fhir_generated_ops::generated::{
9    HasteHealthImplementationguideInstall as Install,
10    HasteHealthImplementationguideUninstall as Uninstall,
11};
12use haste_fhir_model::r4::generated::{
13    resources::{Parameters, Resource, ResourceType},
14    terminology::IssueType,
15    types::{FHIRBase64Binary, FHIRBoolean, FHIRString},
16};
17use haste_fhir_operation_error::OperationOutcomeError;
18use std::{path::Path, sync::Arc};
19use tokio::sync::Mutex;
20
21/// Install FHIR packages (implementation guides) into the active profile's
22/// project: their profiles, extensions, ValueSets, CodeSystems, ConceptMaps,
23/// NamingSystems and SearchParameters.
24#[derive(Subcommand, Debug)]
25pub(crate) enum PackageCommands {
26    /// Install packages, with the packages they depend on.
27    Install {
28        /// What to install: `name@version` from the package registry (for
29        /// example `hl7.fhir.us.core@6.1.0`, `hl7.fhir.us.core@6.1.x`, or a
30        /// name alone for the latest), a package tarball (`.tgz`), or a
31        /// package folder (one holding `package.json`).
32        #[arg(required = true)]
33        packages: Vec<String>,
34        /// Install only the packages given, not the packages they depend on.
35        #[arg(long)]
36        no_dependencies: bool,
37        /// Also install the example resources of the packages given.
38        #[arg(long)]
39        examples: bool,
40        /// Print the server's response (a Parameters resource) instead of a
41        /// summary.
42        #[arg(long)]
43        json: bool,
44    },
45    /// Uninstall a package: delete what it installed. Packages it depends on
46    /// stay installed.
47    Uninstall {
48        /// The package name, for example `hl7.fhir.us.core`.
49        name: String,
50        /// Only uninstall if this version is the one installed.
51        #[arg(long)]
52        version: Option<String>,
53    },
54    /// List the installed packages.
55    List,
56}
57
58fn fhir_string(value: impl Into<String>) -> FHIRString {
59    FHIRString {
60        value: Some(value.into()),
61        ..Default::default()
62    }
63}
64
65fn value(string: Option<&FHIRString>) -> &str {
66    string.and_then(|s| s.value.as_deref()).unwrap_or_default()
67}
68
69/// A package to upload, read from `argument` when it names a file or folder.
70fn upload(argument: &str) -> Result<Option<FHIRBase64Binary>, OperationOutcomeError> {
71    let path = Path::new(argument);
72    let bytes = if path.is_dir() {
73        haste_fhir_package::pack(path)?
74    } else if path.is_file() {
75        std::fs::read(path).map_err(|e| {
76            OperationOutcomeError::error(
77                IssueType::exception(),
78                format!("Failed to read '{argument}': {e}"),
79            )
80        })?
81    } else {
82        return Ok(None);
83    };
84    Ok(Some(FHIRBase64Binary {
85        value: Some(STANDARD.encode(bytes)),
86        ..Default::default()
87    }))
88}
89
90fn parameters(
91    input: impl Into<Vec<haste_fhir_model::r4::generated::resources::ParametersParameter>>,
92) -> Parameters {
93    Parameters {
94        parameter: Some(input.into()),
95        ..Default::default()
96    }
97}
98
99fn print_install_summary(output: &Install::Output) {
100    for package in output.package.iter().flatten() {
101        let name = value(Some(&package.name));
102        let version = value(Some(&package.version));
103        let status = package.status.value.as_deref().unwrap_or_default();
104        let resources = package
105            .resources
106            .as_ref()
107            .and_then(|count| count.value)
108            .unwrap_or_default();
109        match status {
110            "skipped" => println!(
111                "skipped    {name}@{version}: {}",
112                value(package.reason.as_ref())
113            ),
114            "updated" => println!(
115                "updated    {name}@{version} (was {}): {resources} resources",
116                value(package.previousVersion.as_ref())
117            ),
118            _ => println!("{status:<10} {name}@{version}: {resources} resources"),
119        }
120    }
121    let warnings = output
122        .outcome
123        .iter()
124        .flat_map(|outcome| &outcome.issue)
125        .filter_map(|issue| issue.diagnostics.as_ref()?.value.as_deref())
126        .collect::<Vec<_>>();
127    if !warnings.is_empty() {
128        println!("\n{} warning(s):", warnings.len());
129        for warning in warnings {
130            println!("  {warning}");
131        }
132    }
133}
134
135/// Runs the `package` command group.
136pub(crate) async fn run(
137    state: Arc<Mutex<CliState>>,
138    command: &PackageCommands,
139) -> Result<(), OperationOutcomeError> {
140    let fhir_client = crate::cli::client::fhir_client(state).await?;
141
142    match command {
143        PackageCommands::Install {
144            packages,
145            no_dependencies,
146            examples,
147            json,
148        } => {
149            let mut named = Vec::new();
150            let mut uploads = Vec::new();
151            for argument in packages {
152                match upload(argument)? {
153                    Some(content) => uploads.push(content),
154                    None => named.push(fhir_string(argument)),
155                }
156            }
157            let input = Install::Input {
158                package: Some(named).filter(|named| !named.is_empty()),
159                npmContent: Some(uploads).filter(|uploads| !uploads.is_empty()),
160                dependencies: Some(FHIRBoolean {
161                    value: Some(!no_dependencies),
162                    ..Default::default()
163                }),
164                examples: Some(FHIRBoolean {
165                    value: Some(*examples),
166                    ..Default::default()
167                }),
168            };
169
170            let result = fhir_client
171                .invoke_type(
172                    (),
173                    ResourceType::ImplementationGuide,
174                    "install".to_string(),
175                    parameters(input),
176                )
177                .await?;
178
179            if *json {
180                println!(
181                    "{}",
182                    serde_json::to_string_pretty(&result).expect("Failed to serialize response")
183                );
184                return Ok(());
185            }
186            let Resource::Parameters(result) = result else {
187                return Err(OperationOutcomeError::error(
188                    IssueType::exception(),
189                    "The server answered $install with something other than Parameters."
190                        .to_string(),
191                ));
192            };
193            print_install_summary(&Install::Output::try_from(
194                result.parameter.unwrap_or_default(),
195            )?);
196            Ok(())
197        }
198        PackageCommands::Uninstall { name, version } => {
199            let input = Uninstall::Input {
200                name: fhir_string(name),
201                version: version.as_ref().map(fhir_string),
202            };
203            let result = fhir_client
204                .invoke_type(
205                    (),
206                    ResourceType::ImplementationGuide,
207                    "uninstall".to_string(),
208                    parameters(input),
209                )
210                .await?;
211            match result {
212                Resource::OperationOutcome(outcome) => {
213                    for issue in outcome.issue {
214                        println!("{}", value(issue.diagnostics.as_deref()));
215                    }
216                }
217                other => println!(
218                    "{}",
219                    serde_json::to_string_pretty(&other).expect("Failed to serialize response")
220                ),
221            }
222            Ok(())
223        }
224        PackageCommands::List => {
225            let bundle = fhir_client
226                .search_type(
227                    (),
228                    ResourceType::ImplementationGuide,
229                    ParsedParameters::try_from("_count=1000")?,
230                )
231                .await?;
232            for entry in bundle.entry.unwrap_or_default() {
233                if let Some(Resource::ImplementationGuide(guide)) = entry.resource.as_deref() {
234                    let title = guide
235                        .title
236                        .as_deref()
237                        .and_then(|title| title.value.as_deref())
238                        .unwrap_or_default();
239                    println!(
240                        "{}@{}  {title}",
241                        guide.packageId.value.as_deref().unwrap_or_default(),
242                        value(guide.version.as_deref()),
243                    );
244                }
245            }
246            Ok(())
247        }
248    }
249}