1use std::{collections::BTreeMap, ffi::OsString, fs, path::PathBuf};
15
16use clap::{Arg, ArgAction, ArgMatches, Command};
17use serde_json::{Value, json};
18use tower::Service;
19
20use crate::{
21 AuthStrategy, DecodedResponse, FerriskeySdk, OperationInput, SdkConfig, SdkError, SdkRequest,
22 Transport,
23 generated::{
24 self, GeneratedOperationDescriptor, GeneratedParameterDescriptor, ParameterLocation,
25 },
26};
27
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
30pub struct CliCredentials {
31 pub base_url: Option<String>,
33 pub bearer_token: Option<String>,
35}
36
37impl CliCredentials {
38 fn config_dir() -> Option<PathBuf> {
40 dirs::home_dir().map(|home| home.join(".ferriskey-cli"))
41 }
42
43 fn config_path() -> Option<PathBuf> {
45 Self::config_dir().map(|dir| dir.join("config.toml"))
46 }
47
48 pub fn load() -> Self {
50 let Some(path) = Self::config_path() else {
51 return Self::default();
52 };
53
54 fs::read_to_string(path)
55 .ok()
56 .and_then(|content| toml::from_str(&content).ok())
57 .unwrap_or_default()
58 }
59
60 pub fn save(&self) -> Result<(), std::io::Error> {
62 let Some(dir) = Self::config_dir() else {
63 return Err(std::io::Error::new(
64 std::io::ErrorKind::NotFound,
65 "Could not determine home directory",
66 ));
67 };
68
69 fs::create_dir_all(&dir)?;
70
71 let path = dir.join("config.toml");
72 let content = toml::to_string_pretty(self)
73 .map_err(|e| std::io::Error::other(format!("TOML serialization error: {e}")))?;
74
75 fs::write(path, content)
76 }
77}
78
79#[derive(Debug, thiserror::Error)]
81pub enum CliError {
82 #[error(transparent)]
84 Clap(#[from] clap::Error),
85 #[error("failed to read CLI body file {path}: {source}")]
87 BodyFile {
88 path: String,
90 source: std::io::Error,
92 },
93 #[error("unknown FerrisKey CLI operation: {operation_id}")]
95 UnknownOperation {
96 operation_id: String,
98 },
99 #[error(transparent)]
101 Sdk(#[from] SdkError),
102 #[error("failed to render CLI output: {0}")]
104 Output(#[from] serde_json::Error),
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum OutputFormat {
110 Json,
112 Pretty,
114}
115
116impl OutputFormat {
117 #[must_use]
119 #[expect(clippy::should_implement_trait)]
120 pub fn from_str(s: &str) -> Self {
121 match s {
122 "pretty" => Self::Pretty,
123 _ => Self::Json,
124 }
125 }
126}
127
128#[derive(Clone, Debug, Eq, PartialEq)]
135pub struct CliConfig {
136 pub base_url: String,
138 pub bearer_token: Option<String>,
140 pub output_format: OutputFormat,
142}
143
144impl CliConfig {
145 #[must_use]
147 pub fn to_sdk_config(&self) -> SdkConfig {
148 let auth = self.bearer_token.clone().map_or(AuthStrategy::None, AuthStrategy::Bearer);
149
150 SdkConfig::new(self.base_url.clone(), auth)
151 }
152}
153
154#[derive(Clone, Debug, Eq, PartialEq)]
156pub struct CliInvocation {
157 pub config: CliConfig,
159 pub operation_id: &'static str,
161 pub input: OperationInput,
163}
164
165#[must_use]
167pub fn render_help() -> String {
168 let mut command = build_command();
169 let mut buffer = Vec::new();
170
171 if command.write_long_help(&mut buffer).is_err() {
172 return String::new();
173 }
174
175 String::from_utf8(buffer).unwrap_or_default()
176}
177
178pub fn parse_args<I, T>(args: I) -> Result<CliInvocation, CliError>
180where
181 I: IntoIterator<Item = T>,
182 T: Into<OsString> + Clone,
183{
184 let matches = build_command().try_get_matches_from(args)?;
185 parse_matches(&matches)
186}
187
188pub async fn execute_with_transport<T>(
196 invocation: CliInvocation,
197 transport: T,
198) -> Result<String, CliError>
199where
200 T: Transport + Clone,
201 <T as Service<SdkRequest>>::Future: Send,
202{
203 let sdk_config = invocation.config.to_sdk_config();
204 let sdk = FerriskeySdk::new(sdk_config, transport);
205
206 let operation = sdk.operation(invocation.operation_id).ok_or_else(|| {
207 CliError::UnknownOperation { operation_id: invocation.operation_id.to_string() }
208 })?;
209
210 let decoded = operation.execute_decoded(invocation.input.clone()).await?;
211
212 if invocation.operation_id == "authenticate" &&
214 let Some(response_body) = decoded.json_body() &&
215 let Some(access_token) = response_body.get("access_token").and_then(|v| v.as_str())
216 {
217 let credentials = CliCredentials {
218 base_url: Some(invocation.config.base_url.clone()),
219 bearer_token: Some(access_token.to_string()),
220 };
221 let _ = credentials.save();
222 }
223
224 render_output(invocation.operation_id, &decoded, invocation.config.output_format)
225}
226
227fn build_command() -> Command {
228 let mut command = Command::new("ferriskey-cli")
229 .about("FerrisKey CLI")
230 .arg(
231 Arg::new("base-url")
232 .long("base-url")
233 .value_name("URL")
234 .help("Base URL for the FerrisKey API (or saved from 'auth' command)"),
235 )
236 .arg(
237 Arg::new("bearer-token")
238 .long("bearer-token")
239 .global(true)
240 .value_name("TOKEN")
241 .help("Bearer token for secured operations (or saved from 'auth' command)"),
242 )
243 .arg(
244 Arg::new("output")
245 .long("output")
246 .default_value("json")
247 .global(true)
248 .value_parser(["json", "pretty"])
249 .value_name("FORMAT")
250 .help("Structured output mode"),
251 )
252 .subcommand(
253 Command::new("login")
254 .about("Authenticate with FerrisKey and save credentials to ~/.ferriskey-cli/config.toml")
255 .arg(
256 Arg::new("base-url")
257 .long("base-url")
258 .required(true)
259 .value_name("URL")
260 .help("Base URL for the FerrisKey API"),
261 )
262 .arg(
263 Arg::new("username")
264 .long("username")
265 .short('u')
266 .required(true)
267 .value_name("USERNAME")
268 .help("Username for authentication"),
269 )
270 .arg(
271 Arg::new("password")
272 .long("password")
273 .short('p')
274 .required(true)
275 .value_name("PASSWORD")
276 .help("Password for authentication"),
277 )
278 .arg(
279 Arg::new("realm-name")
280 .long("realm-name")
281 .value_name("REALM")
282 .default_value("master")
283 .help("Realm name for authentication"),
284 ),
285 );
286
287 for tag in generated::TAG_NAMES {
288 let mut tag_command = Command::new(*tag);
289
290 for descriptor in
291 generated::OPERATION_DESCRIPTORS.iter().filter(|descriptor| descriptor.tag == *tag)
292 {
293 tag_command = tag_command.subcommand(operation_command(descriptor));
294 }
295
296 command = command.subcommand(tag_command);
297 }
298
299 command
300}
301
302fn operation_command(descriptor: &'static GeneratedOperationDescriptor) -> Command {
303 let mut command = Command::new(leak_string(command_name(descriptor.operation_id)));
304
305 for parameter in descriptor.parameters {
306 let long_name = leak_string(parameter.name.replace('_', "-"));
307 let mut arg = Arg::new(parameter.name)
308 .long(long_name)
309 .value_name(parameter.name)
310 .required(parameter.required)
311 .help(parameter_help(parameter));
312
313 if parameter.location == ParameterLocation::Query {
314 arg = arg.action(ArgAction::Append);
315 }
316
317 command = command.arg(arg);
318 }
319
320 if let Some(request_body) = descriptor.request_body {
321 let mut body_arg = Arg::new("body")
322 .long("body")
323 .value_name("JSON_OR_@FILE")
324 .help("Request body as inline JSON or @path/to/file.json");
325
326 if request_body.required && !request_body.nullable {
327 body_arg = body_arg.required(true);
328 }
329
330 command = command.arg(body_arg);
331 }
332
333 command
334}
335
336fn parse_matches(matches: &ArgMatches) -> Result<CliInvocation, CliError> {
337 if let Some(auth_matches) = matches.subcommand_matches("login") {
339 return handle_auth_command(auth_matches);
340 }
341
342 let credentials = CliCredentials::load();
344
345 let base_url =
346 matches.get_one::<String>("base-url").cloned().or(credentials.base_url).ok_or_else(
347 || {
348 clap::Error::raw(
349 clap::error::ErrorKind::MissingRequiredArgument,
350 "missing required argument --base-url (or run 'auth' command first)",
351 )
352 },
353 )?;
354
355 let bearer_token =
356 matches.get_one::<String>("bearer-token").cloned().or(credentials.bearer_token);
357
358 let config = CliConfig {
359 base_url,
360 bearer_token,
361 output_format: OutputFormat::from_str(&required_string(matches, "output")?),
362 };
363
364 let (_, tag_matches) = matches.subcommand().ok_or_else(|| {
365 clap::Error::raw(clap::error::ErrorKind::MissingSubcommand, "an API tag is required")
366 })?;
367
368 let (operation_name, operation_matches) = tag_matches.subcommand().ok_or_else(|| {
369 clap::Error::raw(clap::error::ErrorKind::MissingSubcommand, "an operation is required")
370 })?;
371
372 let descriptor = generated::OPERATION_DESCRIPTORS
373 .iter()
374 .find(|descriptor| command_name(descriptor.operation_id) == operation_name)
375 .ok_or_else(|| CliError::UnknownOperation {
376 operation_id: operation_name.replace('-', "_"),
377 })?;
378
379 let input = parse_operation_input(descriptor, operation_matches)?;
380
381 Ok(CliInvocation { config, operation_id: descriptor.operation_id, input })
382}
383
384fn handle_auth_command(matches: &ArgMatches) -> Result<CliInvocation, CliError> {
386 let base_url = required_string(matches, "base-url")?;
387 let username = required_string(matches, "username")?;
388 let password = required_string(matches, "password")?;
389 let realm_name =
390 matches.get_one::<String>("realm-name").cloned().unwrap_or_else(|| "master".to_string());
391
392 let auth_body = json!({
394 "username": username,
395 "password": password,
396 });
397
398 let mut path_params = BTreeMap::new();
400 path_params.insert("realm_name".to_string(), realm_name);
401
402 let input = OperationInput {
403 body: Some(auth_body.to_string().into_bytes()),
404 headers: BTreeMap::new(),
405 path_params,
406 query_params: BTreeMap::new(),
407 };
408
409 let config = CliConfig {
410 base_url,
411 bearer_token: None,
412 output_format: OutputFormat::from_str(
413 matches.get_one::<String>("output").map_or("json", |s| s.as_str()),
414 ),
415 };
416
417 Ok(CliInvocation { config, operation_id: "authenticate", input })
418}
419
420fn parse_operation_input(
421 descriptor: &'static GeneratedOperationDescriptor,
422 matches: &ArgMatches,
423) -> Result<OperationInput, CliError> {
424 let mut headers = BTreeMap::new();
425 let mut path_params = BTreeMap::new();
426 let mut query_params = BTreeMap::new();
427
428 for parameter in descriptor.parameters {
429 let values = matches
430 .get_many::<String>(parameter.name)
431 .map(|values| values.cloned().collect::<Vec<_>>())
432 .unwrap_or_default();
433
434 if values.is_empty() {
435 continue;
436 }
437
438 match parameter.location {
439 ParameterLocation::Header => {
440 headers.insert(parameter.name.to_string(), values[0].clone());
441 }
442 ParameterLocation::Path => {
443 path_params.insert(parameter.name.to_string(), values[0].clone());
444 }
445 ParameterLocation::Query => {
446 query_params.insert(parameter.name.to_string(), values);
447 }
448 }
449 }
450
451 let body = if descriptor.request_body.is_some() {
452 matches.get_one::<String>("body").map(|value| read_body(value)).transpose()?
453 } else {
454 None
455 };
456
457 Ok(OperationInput { body, headers, path_params, query_params })
458}
459
460fn read_body(value: &str) -> Result<Vec<u8>, CliError> {
461 if let Some(path) = value.strip_prefix('@') {
462 return fs::read(path)
463 .map_err(|source| CliError::BodyFile { path: path.to_string(), source });
464 }
465
466 Ok(value.as_bytes().to_vec())
467}
468
469fn render_output(
470 operation_id: &str,
471 response: &DecodedResponse,
472 output_format: OutputFormat,
473) -> Result<String, CliError> {
474 let response_value = response.json_body().cloned().unwrap_or_else(|| {
475 if response.raw_body.is_empty() {
476 Value::Null
477 } else {
478 Value::String(String::from_utf8_lossy(&response.raw_body).into_owned())
479 }
480 });
481
482 let rendered = json!({
483 "operation_id": operation_id,
484 "schema_name": response.schema_name,
485 "status": response.status,
486 "response": response_value,
487 });
488
489 match output_format {
490 OutputFormat::Json => serde_json::to_string(&rendered).map_err(CliError::Output),
491 OutputFormat::Pretty => serde_json::to_string_pretty(&rendered).map_err(CliError::Output),
492 }
493}
494
495fn required_string(matches: &ArgMatches, name: &str) -> Result<String, CliError> {
496 matches.get_one::<String>(name).cloned().ok_or_else(|| {
497 clap::Error::raw(
498 clap::error::ErrorKind::MissingRequiredArgument,
499 format!("missing required argument --{name}"),
500 )
501 .into()
502 })
503}
504
505fn parameter_help(parameter: &GeneratedParameterDescriptor) -> String {
506 if let Some(description) = parameter.description {
507 description.to_string()
508 } else {
509 match parameter.location {
510 ParameterLocation::Header => format!("Header parameter: {}", parameter.name),
511 ParameterLocation::Path => format!("Path parameter: {}", parameter.name),
512 ParameterLocation::Query => format!("Query parameter: {}", parameter.name),
513 }
514 }
515}
516
517fn command_name(operation_id: &str) -> String {
518 operation_id.replace('_', "-")
519}
520
521fn leak_string(value: String) -> &'static str {
522 Box::leak(value.into_boxed_str())
523}