Skip to main content

haste_health/cli/
config.rs

1//! Schema and on-disk persistence for named server connection profiles
2//! (`~/.haste_health/config.toml`). The `profile` *command* (create/delete/list profiles)
3//! lives in [`crate::commands::profile`]; this module only owns the data.
4//!
5//! Secret material (client secrets, cached OAuth tokens) is never stored here — see
6//! [`crate::cli::secrets`] — so this file is safe to inspect, back up, or share.
7
8use haste_fhir_model::r4::generated::terminology::IssueType;
9use haste_fhir_operation_error::OperationOutcomeError;
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13/// Scope a client-credentials profile requests when it doesn't store one.
14pub(crate) const DEFAULT_CLIENT_CREDENTIALS_SCOPE: &str = "openid system/*.*";
15
16#[derive(Serialize, Deserialize, Debug, Default)]
17pub(crate) struct CliConfiguration {
18    pub(crate) active_profile: Option<String>,
19    pub(crate) profiles: Vec<Profile>,
20}
21
22impl CliConfiguration {
23    pub(crate) fn current_profile(&self) -> Option<&Profile> {
24        if let Some(active_profile_id) = self.active_profile.as_ref() {
25            self.profiles.iter().find(|p| &p.name == active_profile_id)
26        } else {
27            None
28        }
29    }
30
31    /// The active profile, or an error saying how to pick or create one.
32    pub(crate) fn require_current_profile(&self) -> Result<&Profile, OperationOutcomeError> {
33        self.current_profile().ok_or_else(|| {
34            OperationOutcomeError::error(
35                IssueType::invalid(),
36                "No active profile. Pick one with `haste-health profile use`, or create one with `haste-health profile create`."
37                    .to_string(),
38            )
39        })
40    }
41}
42
43/// A named connection to a FHIR server plus how the CLI should authenticate to it.
44#[derive(Serialize, Deserialize, Debug, Clone)]
45pub(crate) struct Profile {
46    pub(crate) name: String,
47    pub(crate) r4_url: String,
48    pub(crate) oidc_discovery_uri: String,
49    pub(crate) auth: ProfileAuth,
50}
51
52/// How the CLI authenticates for a given profile. Any secret values (client secret,
53/// cached tokens) live in the separate secrets file, keyed by profile name.
54#[derive(Serialize, Deserialize, Debug, Clone)]
55pub(crate) enum ProfileAuth {
56    /// A confidential (server-to-server) client authenticated with a client secret.
57    ClientCredentails {
58        client_id: String,
59        /// Scope to request; `None` requests `openid system/*.*`.
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        scope: Option<String>,
62    },
63    /// A public (no secret) OIDC client authenticated by a human via the browser-based
64    /// authorization_code + PKCE flow. Run `haste-health login` to obtain tokens.
65    AuthorizationCode {
66        client_id: String,
67        redirect_uri: String,
68        scope: String,
69    },
70    Basic {
71        username: String,
72    },
73    /// No authentication; requests are sent unauthenticated.
74    Public {},
75}
76
77fn read_existing_config(location: &PathBuf) -> Result<CliConfiguration, OperationOutcomeError> {
78    let config_str = std::fs::read_to_string(location).map_err(|_| {
79        OperationOutcomeError::error(
80            IssueType::exception(),
81            format!(
82                "Failed to read config file at location '{}'",
83                location.to_string_lossy()
84            ),
85        )
86    })?;
87
88    toml::from_str::<CliConfiguration>(&config_str).map_err(|_| {
89        OperationOutcomeError::error(
90            IssueType::exception(),
91            format!(
92                "Failed to parse config file at location '{}'",
93                location.to_string_lossy()
94            ),
95        )
96    })
97}
98
99/// Loads the config file, creating a default one on disk if it doesn't exist yet.
100pub(crate) fn load_config(location: &PathBuf) -> CliConfiguration {
101    if let Ok(config) = read_existing_config(location) {
102        return config;
103    }
104
105    let config = CliConfiguration::default();
106    write_config(location, &config).expect("Failed to write default config file");
107    config
108}
109
110pub(crate) fn write_config(
111    location: &PathBuf,
112    config: &CliConfiguration,
113) -> Result<(), OperationOutcomeError> {
114    std::fs::write(location, toml::to_string(config).unwrap()).map_err(|_| {
115        OperationOutcomeError::error(
116            IssueType::exception(),
117            format!(
118                "Failed to write config file at location '{}'",
119                location.to_string_lossy()
120            ),
121        )
122    })
123}