1use crate::{CLIState, CONFIG_LOCATION};
2use clap::Subcommand;
3use dialoguer::{Confirm, Select};
4use dialoguer::{Input, Password, theme::ColorfulTheme};
5use haste_fhir_model::r4::generated::terminology::IssueType;
6use haste_fhir_operation_error::OperationOutcomeError;
7use serde::{Deserialize, Serialize};
8use std::{path::PathBuf, sync::Arc};
9use tokio::sync::Mutex;
10
11#[derive(Serialize, Deserialize, Debug)]
12pub struct CLIConfiguration {
13 pub active_profile: Option<String>,
14 pub profiles: Vec<Profile>,
15}
16
17impl CLIConfiguration {
18 pub fn current_profile(&self) -> Option<&Profile> {
19 if let Some(active_profile_id) = self.active_profile.as_ref() {
20 self.profiles.iter().find(|p| &p.name == active_profile_id)
21 } else {
22 None
23 }
24 }
25}
26
27impl Default for CLIConfiguration {
28 fn default() -> Self {
29 CLIConfiguration {
30 active_profile: None,
31 profiles: vec![],
32 }
33 }
34}
35
36#[derive(Serialize, Deserialize, Debug, Clone)]
37pub struct Profile {
38 pub name: String,
39 pub r4_url: String,
40 pub oidc_discovery_uri: String,
41 pub auth: ProfileAuth,
42}
43
44#[derive(Serialize, Deserialize, Debug, Clone)]
45pub enum ProfileAuth {
46 ClientCredentails {
47 client_id: String,
48 client_secret: String,
49 },
50 Public {},
51}
52
53#[derive(Subcommand, Debug)]
54pub enum ConfigCommands {
55 ShowProfile,
56 CreateProfile {
57 #[arg(short, long)]
58 name: Option<String>,
59 #[arg(short, long)]
60 r4_url: Option<String>,
61 #[arg(short, long)]
62 discovery_uri: Option<String>,
63 #[arg(short, long)]
64 id: Option<String>,
65 #[arg(short, long)]
66 secret: Option<String>,
67 },
68 DeleteProfile {
69 #[arg(short, long)]
70 name: Option<String>,
71 #[arg(short, long)]
72 confirm: Option<bool>,
73 },
74 SetActiveProfile {
75 #[arg(short, long)]
76 name: Option<String>,
77 },
78}
79
80fn read_existing_config(location: &PathBuf) -> Result<CLIConfiguration, OperationOutcomeError> {
81 let config_str = std::fs::read_to_string(location).map_err(|_| {
82 OperationOutcomeError::error(
83 IssueType::Exception(None),
84 format!(
85 "Failed to read config file at location '{}'",
86 location.to_string_lossy()
87 ),
88 )
89 })?;
90
91 let config = toml::from_str::<CLIConfiguration>(&config_str).map_err(|_| {
92 OperationOutcomeError::error(
93 IssueType::Exception(None),
94 format!(
95 "Failed to parse config file at location '{}'",
96 location.to_string_lossy()
97 ),
98 )
99 })?;
100
101 Ok(config)
102}
103
104pub fn load_config(location: &PathBuf) -> CLIConfiguration {
105 let config: Result<CLIConfiguration, OperationOutcomeError> = read_existing_config(location);
106
107 if let Ok(config) = config {
108 config
109 } else {
110 let config = CLIConfiguration::default();
111
112 std::fs::write(location, toml::to_string(&config).unwrap())
113 .map_err(|_| {
114 OperationOutcomeError::error(
115 IssueType::Exception(None),
116 format!(
117 "Failed to write default config file at location '{}'",
118 location.to_string_lossy()
119 ),
120 )
121 })
122 .expect("Failed to write default config file");
123
124 config
125 }
126}
127
128pub async fn config(
129 state: &Arc<Mutex<CLIState>>,
130 command: &ConfigCommands,
131) -> Result<(), OperationOutcomeError> {
132 match command {
133 ConfigCommands::ShowProfile => {
134 let state = state.lock().await;
135 if let Some(active_profile_id) = state.config.active_profile.as_ref()
136 && let Some(active_profile) = state
137 .config
138 .profiles
139 .iter()
140 .find(|p| &p.name == active_profile_id)
141 {
142 println!("{:#?}", active_profile);
143 } else {
144 println!("No active profile set.");
145 }
146
147 Ok(())
148 }
149 ConfigCommands::CreateProfile {
150 name,
151 r4_url,
152 discovery_uri,
153 id,
154 secret,
155 } => {
156 let name: String = if let Some(name) = name {
157 name.clone()
158 } else {
159 Input::with_theme(&ColorfulTheme::default())
160 .with_prompt("Profile Name")
161 .interact_text()
162 .unwrap()
163 };
164
165 let r4_url: String = if let Some(r4_url) = r4_url {
166 r4_url.clone()
167 } else {
168 Input::with_theme(&ColorfulTheme::default())
169 .with_prompt("FHIR R4 Server URL")
170 .interact_text()
171 .unwrap()
172 };
173
174 let oidc_discovery_uri: String = if let Some(discovery_uri) = discovery_uri {
175 discovery_uri.clone()
176 } else {
177 Input::with_theme(&ColorfulTheme::default())
178 .with_prompt("OIDC Discovery URI")
179 .interact_text()
180 .unwrap()
181 };
182
183 let client_id: String = if let Some(id) = id {
184 id.clone()
185 } else {
186 Input::with_theme(&ColorfulTheme::default())
187 .with_prompt("OIDC Client ID")
188 .interact_text()
189 .unwrap()
190 };
191
192 let client_secret: String = if let Some(secret) = secret {
193 secret.clone()
194 } else {
195 Password::with_theme(&ColorfulTheme::default())
196 .with_prompt("OIDC Client Secret")
197 .interact()
198 .unwrap()
199 };
200
201 let mut state = state.lock().await;
202 if state
203 .config
204 .profiles
205 .iter()
206 .any(|profile| profile.name == *name)
207 {
208 return Err(OperationOutcomeError::error(
209 IssueType::Exception(None),
210 format!("Profile with name '{}' already exists", name),
211 ));
212 }
213
214 let profile = Profile {
215 name: name.clone(),
216 r4_url: r4_url.clone(),
217 oidc_discovery_uri: oidc_discovery_uri.clone(),
218 auth: ProfileAuth::ClientCredentails {
219 client_id: client_id.clone(),
220 client_secret: client_secret.clone(),
221 },
222 };
223
224 state.config.profiles.push(profile);
225 state.config.active_profile = Some(name.clone());
226
227 std::fs::write(&*CONFIG_LOCATION, toml::to_string(&state.config).unwrap()).map_err(
228 |_| {
229 OperationOutcomeError::error(
230 IssueType::Exception(None),
231 format!(
232 "Failed to write config file at location '{}'",
233 CONFIG_LOCATION.to_string_lossy()
234 ),
235 )
236 },
237 )?;
238
239 Ok(())
240 }
241 ConfigCommands::DeleteProfile { name, confirm } => {
242 let name: String = if let Some(name) = name {
243 name.clone()
244 } else {
245 Input::with_theme(&ColorfulTheme::default())
246 .with_prompt("Enter the profile name you wish to delete")
247 .interact_text()
248 .unwrap()
249 };
250
251 let confirmed = if let Some(confirm) = confirm {
252 confirm.clone()
253 } else {
254 Confirm::with_theme(&ColorfulTheme::default())
255 .with_prompt(format!(
256 "Are you sure you want to delete the profile '{}'? ",
257 name
258 ))
259 .interact()
260 .unwrap_or(false)
261 };
262
263 if !confirmed {
264 println!("Profile deletion cancelled.");
265 return Ok(());
266 }
267
268 let mut state = state.lock().await;
269 state
270 .config
271 .profiles
272 .retain(|profile| profile.name != *name);
273
274 std::fs::write(&*CONFIG_LOCATION, toml::to_string(&state.config).unwrap()).map_err(
275 |_| {
276 OperationOutcomeError::error(
277 IssueType::Exception(None),
278 format!(
279 "Failed to write config file at location '{}'",
280 CONFIG_LOCATION.to_string_lossy()
281 ),
282 )
283 },
284 )?;
285
286 Ok(())
287 }
288 ConfigCommands::SetActiveProfile { name } => {
289 let mut state = state.lock().await;
290 let user_profile_names = state
291 .config
292 .profiles
293 .iter()
294 .map(|p| p.name.as_str())
295 .collect::<Vec<_>>();
296
297 if user_profile_names.is_empty() {
298 return Err(OperationOutcomeError::error(
299 IssueType::Exception(None),
300 "No profiles available to set as active.".to_string(),
301 ));
302 }
303
304 let active_profile_index = state
305 .config
306 .active_profile
307 .as_ref()
308 .and_then(|active_name| {
309 user_profile_names
310 .iter()
311 .position(|&name| name == active_name)
312 })
313 .unwrap_or(0);
314
315 let name: String = if let Some(name) = name {
316 name.clone()
317 } else {
318 let selection = Select::new()
319 .with_prompt("Choose a profile to set as active.")
320 .items(&user_profile_names)
321 .default(active_profile_index)
322 .interact()
323 .unwrap();
324 user_profile_names[selection].to_string()
325 };
326
327 if !state
328 .config
329 .profiles
330 .iter()
331 .any(|profile| profile.name == name)
332 {
333 return Err(OperationOutcomeError::error(
334 IssueType::Exception(None),
335 format!("Profile with name '{}' does not exist", name),
336 ));
337 }
338
339 state.config.active_profile = Some(name.to_string());
340
341 std::fs::write(&*CONFIG_LOCATION, toml::to_string(&state.config).unwrap()).map_err(
342 |_| {
343 OperationOutcomeError::error(
344 IssueType::Exception(None),
345 format!(
346 "Failed to write config file at location '{}'",
347 CONFIG_LOCATION.to_string_lossy()
348 ),
349 )
350 },
351 )?;
352 Ok(())
353 }
354 }
355}