Skip to main content

ferriskey_sdk/
cli.rs

1//! Descriptor-driven CLI helpers shared by the FerrisKey binary and tests.
2//!
3//! ## Design Philosophy
4//!
5//! The CLI module provides a bridge between command-line arguments and the
6//! typed SDK interface. It uses clap for argument parsing and converts
7//! the results into `OperationInput` for SDK execution.
8//!
9//! ## Extension Point
10//!
11//! Custom CLI commands can be added via extension traits without modifying
12//! the core CLI infrastructure.
13
14use 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/// Configuration file for persistent CLI authentication.
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
30pub struct CliCredentials {
31    /// Base URL for the FerrisKey API.
32    pub base_url: Option<String>,
33    /// Bearer token for authenticated operations.
34    pub bearer_token: Option<String>,
35}
36
37impl CliCredentials {
38    /// Path to the credentials file.
39    fn config_dir() -> Option<PathBuf> {
40        dirs::home_dir().map(|home| home.join(".ferriskey-cli"))
41    }
42
43    /// Path to the credentials file.
44    fn config_path() -> Option<PathBuf> {
45        Self::config_dir().map(|dir| dir.join("config.toml"))
46    }
47
48    /// Load credentials from disk.
49    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    /// Save credentials to disk.
61    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/// Errors raised while parsing or executing CLI requests.
80#[derive(Debug, thiserror::Error)]
81pub enum CliError {
82    /// Command-line parsing failed.
83    #[error(transparent)]
84    Clap(#[from] clap::Error),
85    /// Reading a request body from disk failed.
86    #[error("failed to read CLI body file {path}: {source}")]
87    BodyFile {
88        /// Source file path from the `@file` CLI syntax.
89        path: String,
90        /// Underlying file-system error.
91        source: std::io::Error,
92    },
93    /// The requested CLI command did not resolve to a generated operation.
94    #[error("unknown FerrisKey CLI operation: {operation_id}")]
95    UnknownOperation {
96        /// Operation identifier requested by the CLI.
97        operation_id: String,
98    },
99    /// The SDK execution path failed.
100    #[error(transparent)]
101    Sdk(#[from] SdkError),
102    /// Rendering structured CLI output failed.
103    #[error("failed to render CLI output: {0}")]
104    Output(#[from] serde_json::Error),
105}
106
107/// Output rendering mode for CLI responses.
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum OutputFormat {
110    /// Compact JSON output.
111    Json,
112    /// Indented JSON output.
113    Pretty,
114}
115
116impl OutputFormat {
117    /// Parse output format from string.
118    #[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/// CLI runtime configuration resolved from the command line.
129///
130/// ## Immutability
131///
132/// Once built, `CliConfig` is immutable. This prevents accidental mutation
133/// during request processing.
134#[derive(Clone, Debug, Eq, PartialEq)]
135pub struct CliConfig {
136    /// Base URL used to resolve generated request paths.
137    pub base_url: String,
138    /// Optional bearer token applied to secured operations.
139    pub bearer_token: Option<String>,
140    /// Output mode for structured CLI responses.
141    pub output_format: OutputFormat,
142}
143
144impl CliConfig {
145    /// Convert CLI config to SDK config.
146    #[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/// Parsed CLI invocation normalized into the shared SDK request shape.
155#[derive(Clone, Debug, Eq, PartialEq)]
156pub struct CliInvocation {
157    /// Runtime configuration resolved from global CLI arguments.
158    pub config: CliConfig,
159    /// Generated operation identifier selected by the CLI subcommand tree.
160    pub operation_id: &'static str,
161    /// Canonical SDK request input assembled from CLI arguments.
162    pub input: OperationInput,
163}
164
165/// Render the top-level CLI help text.
166#[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
178/// Parse CLI arguments into a normalized invocation.
179pub 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
188/// Execute a parsed CLI invocation through the shared SDK runtime.
189///
190/// ## Generic Transport
191///
192/// The transport type is generic, allowing callers to provide any
193/// `tower::Service<SdkRequest>` implementation. This enables
194/// middleware composition at the call site.
195pub 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    // Save credentials after successful authentication
213    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    // Handle auth subcommand
338    if let Some(auth_matches) = matches.subcommand_matches("login") {
339        return handle_auth_command(auth_matches);
340    }
341
342    // Load credentials from config file
343    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
384/// Handle the auth subcommand to authenticate and save credentials.
385fn 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    // Create the authenticate request body
393    let auth_body = json!({
394        "username": username,
395        "password": password,
396    });
397
398    // Build the operation input for authenticate
399    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}