From d8fb76d6ca9522172fde741a57bae52f372b92a3 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Mon, 30 Mar 2026 15:58:16 +0200 Subject: [PATCH] refactor: Split the cli-core crate Split basic CLI functionality into the separate crate --- Cargo.lock | 40 +- Cargo.toml | 2 +- Dockerfile | 4 +- cli-core/Cargo.toml | 48 + cli-core/src/cli.rs | 198 ++++ cli-core/src/common.rs | 241 +++++ cli-core/src/config.rs | 339 +++++++ cli-core/src/error.rs | 219 +++++ cli-core/src/lib.rs | 44 + cli-core/src/output.rs | 903 ++++++++++++++++++ .../src/tracing_stats.rs | 8 +- openstack_cli/Cargo.toml | 11 +- openstack_cli/src/cli.rs | 180 +--- openstack_cli/src/common.rs | 227 +---- openstack_cli/src/config.rs | 309 +----- openstack_cli/src/error.rs | 206 +--- openstack_cli/src/lib.rs | 31 +- openstack_cli/src/output.rs | 869 +---------------- 18 files changed, 2053 insertions(+), 1826 deletions(-) create mode 100644 cli-core/Cargo.toml create mode 100644 cli-core/src/cli.rs create mode 100644 cli-core/src/common.rs create mode 100644 cli-core/src/config.rs create mode 100644 cli-core/src/error.rs create mode 100644 cli-core/src/lib.rs create mode 100644 cli-core/src/output.rs rename {openstack_cli => cli-core}/src/tracing_stats.rs (97%) diff --git a/Cargo.lock b/Cargo.lock index 7e012f271..5fdc48d7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2747,6 +2747,38 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "openstack-cli-core" +version = "0.13.5" +dependencies = [ + "base64 0.22.1", + "clap", + "clap_complete", + "comfy-table", + "config", + "dialoguer", + "dirs", + "eyre", + "http", + "indicatif", + "itertools", + "openstack-sdk-auth-core", + "openstack-sdk-core", + "owo-colors", + "rand 0.10.0", + "reqwest", + "serde", + "serde_json", + "structable", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "openstack-sdk-auth-applicationcredential" version = "0.1.0" @@ -3062,20 +3094,15 @@ dependencies = [ "clap", "clap_complete", "color-eyre", - "comfy-table", - "config", "dialoguer", - "dirs", "eyre", "futures", "http", - "indicatif", - "itertools", "json-patch", "md5", + "openstack-cli-core", "openstack_sdk", "openstack_types", - "owo-colors", "rand 0.10.0", "regex", "reqwest", @@ -3087,7 +3114,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-util", "tracing", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml index d5f08d7d3..f2c8d796d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ "openstack_tui", "openstack_types", "xtask", - "fuzz", + "fuzz", "cli-core", ] default-members = ["openstack_cli", "openstack_sdk", "openstack_tui", "openstack_types"] diff --git a/Dockerfile b/Dockerfile index bccdc3ad7..e1e17a502 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,7 @@ COPY auth-receipt/Cargo.toml /usr/src/openstack/auth-receipt/ COPY auth-token/Cargo.toml /usr/src/openstack/auth-token/ COPY auth-totp/Cargo.toml /usr/src/openstack/auth-totp/ COPY auth-websso/Cargo.toml /usr/src/openstack/auth-websso/ +COPY cli-core/Cargo.toml /usr/src/openstack/cli-core/ COPY openstack_sdk/Cargo.toml /usr/src/openstack/openstack_sdk/ COPY openstack_cli/Cargo.toml /usr/src/openstack/openstack_cli/ COPY openstack_tui/Cargo.toml /usr/src/openstack/openstack_tui/ @@ -62,7 +63,8 @@ RUN mkdir -p openstack/openstack_cli/src/bin && touch openstack/openstack_cli/sr mkdir -p openstack/auth-receipt/src && touch openstack/auth-receipt/src/lib.rs &&\ mkdir -p openstack/auth-token/src && touch openstack/auth-token/src/lib.rs &&\ mkdir -p openstack/auth-totp/src && touch openstack/auth-totp/src/lib.rs &&\ - mkdir -p openstack/auth-websso/src && touch openstack/auth-websso/src/lib.rs + mkdir -p openstack/auth-websso/src && touch openstack/auth-websso/src/lib.rs &&\ + mkdir -p openstack/cli-core/src && touch openstack/cli-core/src/lib.rs # Set the working directory WORKDIR /usr/src/openstack diff --git a/cli-core/Cargo.toml b/cli-core/Cargo.toml new file mode 100644 index 000000000..6cd72e1e6 --- /dev/null +++ b/cli-core/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "openstack-cli-core" +description = "OpenStack CLI core" +version = "0.13.5" +license.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +base64 = { workspace = true } +clap = { workspace = true } +clap_complete = { workspace = true } +comfy-table = { version = "^7.2" } +config.workspace = true +dialoguer = { workspace = true } +dirs = { workspace = true } +eyre = { workspace = true } +http = { workspace = true } +indicatif = "^0.18" +itertools = { workspace = true } +#openstack_sdk = { path="../openstack_sdk", version = "^0.22", default-features = false } +openstack-sdk-auth-core = { path="../auth-core", version = "^0.22", default-features = false } +openstack-sdk-core = { path="../sdk-core", version = "^0.22", default-features = false } +owo-colors = { version = "^4.3", features = ["supports-colors"] } +rand = { version = "^0.10" } +reqwest = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = {workspace = true} +structable = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-std"]} +tokio-util = {workspace = true} +tracing = { workspace = true} +tracing-subscriber = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +#assert_cmd = "^2.2" +#futures.workspace = true +#md5 = "^0.8.0" +#rand = "^0.10" +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/cli-core/src/cli.rs b/cli-core/src/cli.rs new file mode 100644 index 000000000..3b1fda814 --- /dev/null +++ b/cli-core/src/cli.rs @@ -0,0 +1,198 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! CLI top level command and processing +//! +use clap::builder::{ + Styles, + styling::{AnsiColor, Effects}, +}; +use clap::{Args, Parser, ValueEnum, ValueHint}; +use clap_complete::Shell; + +use crate::error::OpenStackCliError; + +use crate::config::{Config, ConfigError}; + +pub fn styles() -> Styles { + Styles::styled() + .header(AnsiColor::Green.on_default() | Effects::BOLD) + .usage(AnsiColor::Green.on_default() | Effects::BOLD) + .literal(AnsiColor::White.on_default() | Effects::BOLD) + .placeholder(AnsiColor::Cyan.on_default()) +} + +/// Trait for getting Command execution context. +pub trait CliArgs { + fn global_opts(&self) -> &GlobalOpts; + fn config(&self) -> &Config; +} + +/// Parse config file +pub fn parse_config(s: &str) -> Result { + let mut builder = Config::builder()?; + if !s.is_empty() { + builder = builder.add_source(s).map_err(ConfigError::builder)?; + } + Ok(builder.build()?) +} + +/// Connection options. +#[derive(Args)] +#[command(next_display_order = 800, next_help_heading = "Connection options")] +pub struct ConnectionOpts { + /// Name reference to the clouds.yaml entry for the cloud configuration. + #[arg(long, env = "OS_CLOUD", global = true, display_order = 801, conflicts_with_all(["cloud_config_from_env", "os_cloud_name"]))] + pub os_cloud: Option, + + /// Get the cloud config from environment variables. + /// + /// Conflicts with the `--os-cloud` option. No merging of environment variables with the + /// options from the `clouds.yaml` file done. It is possible to rely on the `--auth-helper-cmd` + /// command, but than the `--os-cloud-name` should be specified to give a reasonable connection + /// name. + #[arg(long, global = true, action = clap::ArgAction::SetTrue, display_order = 802)] + pub cloud_config_from_env: bool, + + /// Cloud name used when configuration is retrieved from environment variables. When not + /// specified the `envvars` would be used as a default. This value will be used eventually by + /// the authentication helper when data need to be provided dynamically. + #[arg(long, env = "OS_CLOUD_NAME", global = true, display_order = 802)] + pub os_cloud_name: Option, + + /// Project ID to use instead of the one in connection profile. + #[arg(long, env = "OS_PROJECT_ID", global = true, display_order = 803)] + pub os_project_id: Option, + + /// Project Name to use instead of the one in the connection profile. + #[arg(long, env = "OS_PROJECT_NAME", global = true, display_order = 803)] + pub os_project_name: Option, + + /// Region Name to use instead of the one in the connection profile. + #[arg(long, env = "OS_REGION_NAME", global = true, display_order = 804)] + pub os_region_name: Option, + + /// Custom path to the `clouds.yaml` config file. + #[arg( + long, + env = "OS_CLIENT_CONFIG_FILE", + global = true, + value_hint = ValueHint::FilePath, + display_order = 805 + )] + pub os_client_config_file: Option, + + /// Custom path to the `secure.yaml` config file. + #[arg( + long, + env = "OS_CLIENT_SECURE_FILE", + global = true, + value_hint = ValueHint::FilePath, + display_order = 805 + )] + pub os_client_secure_file: Option, + + /// External authentication helper command. + /// + /// Invoke external command to obtain necessary connection parameters. This is a path to the + /// executable, which is called with first parameter being the attribute key (i.e. `password`) + /// and a second parameter a cloud name (whatever is used in `--os-cloud` or + /// `--os-cloud-name`). + #[arg(long, global = true, value_hint = ValueHint::ExecutablePath, display_order = 810)] + pub auth_helper_cmd: Option, +} + +/// Output configuration. +#[derive(Args)] +#[command(next_display_order = 900, next_help_heading = "Output options")] +pub struct OutputOpts { + /// Output format. + #[arg(short, long, global = true, value_enum, display_order = 910)] + pub output: Option, + + /// Fields to return in the output (only in normal and wide mode). + #[arg(short, long, global=true, action=clap::ArgAction::Append, display_order = 910)] + pub fields: Vec, + + /// Pretty print the output. + #[arg(short, long, global=true, action = clap::ArgAction::SetTrue, display_order = 910)] + pub pretty: bool, + + /// Verbosity level. Repeat to increase level. + #[arg(short, long, global=true, action = clap::ArgAction::Count, display_order = 920)] + pub verbose: u8, + + /// Output Table arrangement. + #[arg(long, global=true, default_value_t = TableArrangement::Dynamic, value_enum, display_order = 930)] + pub table_arrangement: TableArrangement, + + /// Record HTTP request timings. + #[arg(long, global=true, action = clap::ArgAction::SetTrue, display_order = 950)] + pub timing: bool, +} + +/// Global CLI options. +#[derive(Args)] +#[command(next_display_order = 900)] +pub struct GlobalOpts { + /// Connection options. + #[command(flatten)] + pub connection: ConnectionOpts, + + /// Output config. + #[command(flatten)] + pub output: OutputOpts, +} + +/// Output format. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum OutputFormat { + /// Json output. + Json, + /// Wide (Human readable table with extra attributes). Note: this has + /// effect only in list operations. + Wide, +} + +/// Table arrangement. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum TableArrangement { + /// Dynamically determine the width of columns in regard to terminal width and content length. + /// With this mode, the content in cells will wrap dynamically to get the the best column + /// layout for the given content. + #[default] + Dynamic, + /// This is mode is the same as the `Dynamic` arrangement, but it will always use as much space + /// as it’s given. Any surplus space will be distributed between all columns. + DynamicFullWidth, + /// Don’t do any content arrangement. Tables with this mode might become wider than your output + /// and look ugly. + Disabled, +} + +/// Output shell completion code for the specified shell (bash, zsh, fish, or powershell). The +/// shell code must be evaluated to provide interactive completion of `osc` commands. This can +/// be done by sourcing it from the .bash_profile. +/// +/// Examples: +/// +/// Enable completion at a shell start: +/// +/// `echo 'source <(osc completion bash)' >>~/.bashrc` +/// +#[derive(Parser, Debug)] +pub struct CompletionCommand { + /// If provided, outputs the completion file for given shell. + #[arg(default_value_t = Shell::Bash)] + pub shell: Shell, +} diff --git a/cli-core/src/common.rs b/cli-core/src/common.rs new file mode 100644 index 000000000..023f5fafe --- /dev/null +++ b/cli-core/src/common.rs @@ -0,0 +1,241 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Common helpers. +use eyre::OptionExt; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::error::Error; +use std::io::IsTerminal; + +use indicatif::{ProgressBar, ProgressStyle}; +use std::path::Path; +use tokio::fs; +use tokio::io::{self}; +use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; +use tokio_util::io::InspectReader; + +use openstack_sdk_core::types::BoxedAsyncRead; +use structable::{StructTable, StructTableOptions}; + +use crate::error::OpenStackCliError; + +/// Newtype for the `HashMap` +#[derive(Deserialize, Default, Debug, Clone, Serialize)] +pub struct HashMapStringString(pub HashMap); + +impl StructTable for HashMapStringString { + fn instance_headers( + &self, + _options: &O, + ) -> Option<::std::vec::Vec<::std::string::String>> { + Some(self.0.keys().map(Into::into).collect()) + } + + fn data( + &self, + _options: &O, + ) -> ::std::vec::Vec> { + self.0.values().map(|x| Some(x.into())).collect() + } +} + +// /// Try to deserialize data and return `Default` if that fails +// pub fn deser_ok_or_default<'a, T, D>(deserializer: D) -> Result +// where +// T: Deserialize<'a> + Default, +// D: Deserializer<'a>, +// { +// let v: Value = Deserialize::deserialize(deserializer)?; +// Ok(T::deserialize(v).unwrap_or_default()) +// } + +/// Parse a single key-value pair +pub fn parse_key_val(s: &str) -> Result<(T, U), Box> +where + T: std::str::FromStr, + T::Err: Error + Send + Sync + 'static, + U: std::str::FromStr, + U::Err: Error + Send + Sync + 'static, +{ + let (k, v) = s + .split_once('=') + .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?; + Ok((k.parse()?, v.parse()?)) +} + +/// Parse a single key-value pair where value can be null +pub fn parse_key_val_opt( + s: &str, +) -> Result<(T, Option), Box> +where + T: std::str::FromStr, + T::Err: Error + Send + Sync + 'static, + U: std::str::FromStr, + U::Err: Error + Send + Sync + 'static, +{ + let (k, v) = s + .split_once('=') + .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?; + + let key = k.parse()?; + let val = (!v.is_empty()).then(|| v.parse()).transpose()?; + + Ok((key, val)) +} + +pub fn parse_json(s: &str) -> Result> +where +{ + Ok(serde_json::from_str(s)?) +} + +/// Download content from the reqwests response stream. +/// When dst_name = "-" - write content to the stdout. +/// Otherwise write into the destination and display progress_bar +pub async fn download_file( + dst_name: String, + size: u64, + data: BoxedAsyncRead, +) -> Result<(), OpenStackCliError> { + let progress_bar = ProgressBar::new(size); + + let mut inspect_reader = + InspectReader::new(data.compat(), |bytes| progress_bar.inc(bytes.len() as u64)); + if dst_name == "-" { + progress_bar.set_style( + ProgressStyle::default_bar() + .progress_chars("#>-") + .template("[{bar:40.cyan/blue}] {bytes}/{total_bytes} at {bytes_per_sec}")?, + ); + + let mut writer = io::stdout(); + io::copy(&mut inspect_reader, &mut writer).await?; + } else { + let path = Path::new(&dst_name); + let fname = path + .file_name() + .ok_or_eyre("download file name must be known")? + .to_str() + .ok_or_eyre("download file name must be a string")?; + progress_bar.set_message(String::from(fname)); + progress_bar.set_style( + ProgressStyle::default_bar() + .progress_chars("#>-") + .template( + "[{bar:40.cyan/blue}] {bytes}/{total_bytes} at {bytes_per_sec} - {msg}", + )?, + ); + + let mut writer = fs::File::create(path).await?; + io::copy(&mut inspect_reader, &mut writer).await?; + } + progress_bar.finish(); + Ok(()) +} + +/// Construct BoxedAsyncRead with progress bar from stdin +pub async fn build_upload_asyncread_from_stdin() -> Result { + let progress_bar = ProgressBar::new(0); + + progress_bar.set_style( + ProgressStyle::default_bar() + .progress_chars("#>-") + .template("[{bar:40.cyan/blue}] {bytes}/{total_bytes} at {bytes_per_sec}")?, + ); + + let inspect_reader = InspectReader::new(io::stdin(), move |bytes| { + progress_bar.inc(bytes.len() as u64) + }); + Ok(BoxedAsyncRead::new(inspect_reader.compat())) +} + +/// Construct BoxedAsyncRead with progress bar from the file +pub async fn build_upload_asyncread_from_file( + file_path: &str, +) -> Result { + let progress_bar = ProgressBar::new(0); + + progress_bar.set_style( + ProgressStyle::default_bar() + .progress_chars("#>-") + .template("[{bar:40.cyan/blue}] {bytes}/{total_bytes} at {bytes_per_sec}")?, + ); + let reader = fs::File::open(&file_path).await?; + + progress_bar.set_length(reader.metadata().await?.len()); + let inspect_reader = + InspectReader::new(reader, move |bytes| progress_bar.inc(bytes.len() as u64)); + + Ok(BoxedAsyncRead::new(inspect_reader.compat())) +} + +/// Wrap file or stdout for being uploaded with reqwests library. +/// When dst_name = "-" - write content to the stdout. +/// Otherwise write into the destination and display progress_bar +pub async fn build_upload_asyncread( + src_name: Option, +) -> Result { + if !std::io::stdin().is_terminal() && src_name.is_none() { + // Reading from stdin + build_upload_asyncread_from_stdin().await + } else { + match src_name + .ok_or(OpenStackCliError::InputParameters( + "upload source name must be provided when stdin is not being piped".into(), + ))? + .as_str() + { + "-" => build_upload_asyncread_from_stdin().await, + file_name => build_upload_asyncread_from_file(file_name).await, + } + } +} + +// #[derive(Debug, PartialEq, PartialOrd)] +// pub(crate) struct ServiceApiVersion(pub u8, pub u8); +// +// impl TryFrom for ServiceApiVersion { +// type Error = (); +// fn try_from(ver: String) -> Result { +// let parts: Vec = ver.split('.').flat_map(|v| v.parse::()).collect(); +// Ok(ServiceApiVersion(parts[0], parts[1])) +// } +// } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_key_val() { + assert_eq!( + ("foo".to_string(), "bar".to_string()), + parse_key_val::("foo=bar").unwrap() + ); + } + + #[test] + fn test_parse_key_val_opt() { + assert_eq!( + ("foo".to_string(), Some("bar".to_string())), + parse_key_val_opt::("foo=bar").unwrap() + ); + assert_eq!( + ("foo".to_string(), None), + parse_key_val_opt::("foo=").unwrap() + ); + } +} diff --git a/cli-core/src/config.rs b/cli-core/src/config.rs new file mode 100644 index 000000000..61bbbb096 --- /dev/null +++ b/cli-core/src/config.rs @@ -0,0 +1,339 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! OpenStackClient configuration +//! +//! It is possible to configure different aspects of the OpenStackClient (not the clouds connection +//! credentials) using the configuration file (`$XDG_CONFIG_DIR/osc/config.yaml`). This enables +//! user to configurate which columns should be returned when no corresponding run time arguments +//! on a resource base. +//! +//! ```yaml +//! views: +//! compute.server: +//! # Listing compute servers will only return ID, NAME and IMAGE columns unless `-o wide` or +//! `-f XXX` parameters are being passed +//! fields: [id, name, image] +//! dns.zone/recordset: +//! # DNS zone recordsets are listed in the wide mode by default. +//! wide: true +//! ``` + +use eyre::Result; +use serde::Deserialize; +use std::{ + collections::HashMap, + fmt, + path::{Path, PathBuf}, +}; +use thiserror::Error; +use tracing::error; + +const CONFIG: &str = include_str!("../../openstack_cli/.config/config.yaml"); + +/// Errors which may occur when dealing with OpenStack connection +/// configuration data. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ConfigError { + /// Parsing error. + #[error("failed to parse config: {}", source)] + Parse { + /// The source of the error. + #[from] + source: config::ConfigError, + }, + + /// Config dir cannot be identified. + #[error("config dir cannot be identified")] + ConfigDirCannotBeIdentified, + + /// Parsing error. + #[error("failed to parse config: {}", source)] + Builder { + /// The source of the error. + #[from] + source: ConfigBuilderError, + }, +} + +impl ConfigError { + /// Build a `[ConfigError::Parse]` error from `[ConfigError]` + pub fn parse(source: config::ConfigError) -> Self { + ConfigError::Parse { source } + } + /// Build a `[ConfigError::Builder]` error from `[ConfigBuilderError]` + pub fn builder(source: ConfigBuilderError) -> Self { + ConfigError::Builder { source } + } +} + +/// Errors which may occur when adding sources to the [`ConfigBuilder`]. +#[derive(Error)] +#[non_exhaustive] +pub enum ConfigBuilderError { + /// File parsing error + #[error("failed to parse file {path:?}: {source}")] + FileParse { + /// Error source + source: Box, + /// Builder object + builder: ConfigBuilder, + /// Error file path + path: PathBuf, + }, + /// Config file deserialization error + #[error("failed to deserialize config {path:?}: {source}")] + ConfigDeserialize { + /// Error source + source: Box, + /// Builder object + builder: ConfigBuilder, + /// Error file path + path: PathBuf, + }, +} +/// +/// Output configuration +/// +/// This structure is controlling how the table table is being built for a structure. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct ViewConfig { + /// Limit fields (their titles) to be returned + #[serde(default)] + pub default_fields: Vec, + /// Fields configurations + #[serde(default)] + pub fields: Vec, + /// Defaults to wide mode + #[serde(default)] + pub wide: Option, +} + +/// Field output configuration +#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialOrd, PartialEq)] +pub struct FieldConfig { + /// Attribute name + pub name: String, + /// Fixed width of the column + #[serde(default)] + pub width: Option, + /// Min width of the column + #[serde(default)] + pub min_width: Option, + /// Max width of the column + #[serde(default)] + pub max_width: Option, + /// [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901) to extract data from the + /// field + #[serde(default)] + pub json_pointer: Option, +} + +const fn _default_true() -> bool { + true +} + +/// OpenStackClient configuration +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Config { + /// Map of views with the key being the resource key `.[/]`) + /// and the value being an `[OutputConfig]` + #[serde(default)] + pub views: HashMap, + /// List of CLI hints per resource + #[serde(default)] + pub command_hints: HashMap>>, + /// General hints for the CLI to be used independent on the command + #[serde(default)] + pub hints: Vec, + /// Enable/disable show the hints after successful command execution. Enabled by default + #[serde(default = "_default_true")] + pub enable_hints: bool, +} + +/// A builder to create a [`ConfigFile`] by specifying which files to load. +pub struct ConfigBuilder { + /// Config source files + sources: Vec, +} + +impl ConfigBuilder { + /// Add a source to the builder. This will directly parse the config and check if it is valid. + /// Values of sources added first will be overridden by later added sources, if the keys match. + /// In other words, the sources will be merged, with the later taking precedence over the + /// earlier ones. + pub fn add_source(mut self, source: impl AsRef) -> Result { + let config = match config::Config::builder() + .add_source(config::File::from(source.as_ref())) + .build() + { + Ok(config) => config, + Err(error) => { + return Err(ConfigBuilderError::FileParse { + source: Box::new(error), + builder: self, + path: source.as_ref().to_owned(), + }); + } + }; + + if let Err(error) = config.clone().try_deserialize::() { + return Err(ConfigBuilderError::ConfigDeserialize { + source: Box::new(error), + builder: self, + path: source.as_ref().to_owned(), + }); + } + + self.sources.push(config); + Ok(self) + } + + /// This will build a [`ConfigFile`] with the previously specified sources. Since + /// the sources have already been checked on errors, this will not fail. + pub fn build(self) -> Result { + let mut config = config::Config::builder(); + + for source in self.sources { + config = config.add_source(source); + } + + Ok(config.build()?.try_deserialize::()?) + } +} + +impl fmt::Debug for ConfigBuilderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigBuilderError::FileParse { source, path, .. } => f + .debug_struct("FileParse") + .field("source", source) + .field("path", path) + .finish_non_exhaustive(), + ConfigBuilderError::ConfigDeserialize { source, path, .. } => f + .debug_struct("ConfigDeserialize") + .field("source", source) + .field("path", path) + .finish_non_exhaustive(), + } + } +} + +impl Config { + /// Get the config builder + pub fn builder() -> Result { + let default_config: config::Config = config::Config::builder() + .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml)) + .build()?; + + Ok(ConfigBuilder { + sources: Vec::from([default_config]), + }) + } + + /// Instantiate new config reading default config updating it with local configuration + pub fn new() -> Result { + let default_config: config::Config = config::Config::builder() + .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml)) + .build()?; + + let config_dir = + get_config_dir().ok_or_else(|| ConfigError::ConfigDirCannotBeIdentified)?; + let mut builder = ConfigBuilder { + sources: Vec::from([default_config]), + }; + + let config_files = [ + ("config.yaml", config::FileFormat::Yaml), + ("views.yaml", config::FileFormat::Yaml), + ]; + let mut found_config = false; + for (file, _format) in &config_files { + if config_dir.join(file).exists() { + found_config = true; + + builder = match builder.add_source(config_dir.join(file)) { + Ok(builder) => builder, + Err(ConfigBuilderError::FileParse { source, .. }) => { + return Err(ConfigError::parse(*source)); + } + Err(ConfigBuilderError::ConfigDeserialize { + source, + builder, + path, + }) => { + error!( + "The file {path:?} could not be deserialized and will be ignored: {source}" + ); + builder + } + } + } + } + if !found_config { + tracing::error!("No configuration file found. Application may not behave as expected"); + } + + builder.build() + } +} + +fn get_config_dir() -> Option { + dirs::config_dir().map(|val| val.join("osc")) +} + +impl fmt::Display for Config { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::Builder; + + #[test] + fn test_parse_config() { + let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap(); + + const CONFIG_DATA: &str = r#" + views: + foo: + default_fields: ["a", "b", "c"] + bar: + fields: + - name: "b" + min_width: 1 + command_hints: + res: + cmd: + - hint1 + - hint2 + hints: + - hint1 + - hint2 + enable_hints: true + "#; + + write!(config_file, "{CONFIG_DATA}").unwrap(); + + let _cfg = Config::builder() + .unwrap() + .add_source(config_file.path()) + .unwrap() + .build(); + } +} diff --git a/cli-core/src/error.rs b/cli-core/src/error.rs new file mode 100644 index 000000000..edc2fda49 --- /dev/null +++ b/cli-core/src/error.rs @@ -0,0 +1,219 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! CLI Errors + +use indicatif; +use reqwest; +use thiserror::Error; + +/// CLI error type +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum OpenStackCliError { + /// Json serialization error. + #[error("failed to serialize data to json: {}", source)] + SerializeJson { + /// The source of the error. + #[from] + source: serde_json::Error, + }, + + /// Json deserialization error. + #[error( + "failed to deserialize data to json. Try using `-o json` to still see the data. \n\t{}", + data + )] + DeserializeJson { + /// The source of the error. + source: serde_json::Error, + /// Source json data + data: String, + }, + + /// OpenStack Auth error. + #[error("authentication error")] + Auth { + /// The source of the error. + source: openstack_sdk_core::OpenStackError, + }, + /// Re-scope error. + #[error("error changing scope to {:?}", scope)] + ReScope { + /// Target scope. + scope: openstack_sdk_auth_core::authtoken_scope::AuthTokenScope, + /// The source of the error. + source: openstack_sdk_core::OpenStackError, + }, + + /// SDK error. + #[error(transparent)] + OpenStackSDK { + /// The source of the error. + #[from] + source: openstack_sdk_core::OpenStackError, + }, + /// OpenStack API error. + #[error(transparent)] + OpenStackApi { + /// The source of the error. + #[from] + source: openstack_sdk_core::api::ApiError, + }, + + /// Configuration error. + #[error(transparent)] + CliConfig { + /// The source of the error. + #[from] + source: crate::config::ConfigError, + }, + + /// Configuration error. + #[error(transparent)] + CloudConfig { + /// The source of the error. + #[from] + source: openstack_sdk_core::config::ConfigError, + }, + + /// OpenStack Service Catalog error. + #[error(transparent)] + OpenStackCatalog { + /// The source of the error. + #[from] + source: openstack_sdk_core::catalog::CatalogError, + }, + + /// No subcommands. + #[error("command has no subcommands")] + NoSubcommands, + + /// Resource is not found. + #[error("resource not found")] + ResourceNotFound, + + /// Resource identifier is not unique. + #[error("cannot find resource by identifier")] + IdNotUnique, + + /// Resource attribute is not present. + #[error("cannot find resource attribute {0}")] + ResourceAttributeMissing(String), + + /// Resource attribute is not string. + #[error("resource attribute {0} is not a string")] + ResourceAttributeNotString(String), + + /// IO error. + #[error("IO error: {}", source)] + IO { + /// The source of the error. + #[from] + source: std::io::Error, + }, + /// Reqwest library error. + #[error("reqwest error: {}", source)] + Reqwest { + /// The source of the error. + #[from] + source: reqwest::Error, + }, + /// Clap library error. + #[error("argument parsing error: {}", source)] + Clap { + /// The source of the error. + #[from] + source: clap::error::Error, + }, + /// Indicativ library error. + #[error("indicativ error: {}", source)] + Idinticatif { + /// The source of the error. + #[from] + source: indicatif::style::TemplateError, + }, + /// Endpoint builder error. + #[error("OpenStackSDK endpoint builder error: `{0}`")] + EndpointBuild(String), + + /// Connection error. + #[error("cloud connection `{0:?}` cannot be found")] + ConnectionNotFound(String), + + /// Invalid header name. + #[error("invalid header name `{}`", source)] + InvalidHeaderName { + /// The source of the error. + #[from] + source: http::header::InvalidHeaderName, + }, + + /// Invalid header value. + #[error("invalid header value `{}`", source)] + InvalidHeaderValue { + /// The source of the error. + #[from] + source: http::header::InvalidHeaderValue, + }, + + /// Invalid URL. + #[error("invalid url: {}", source)] + InvalidUri { + /// The source of the error. + #[from] + source: http::uri::InvalidUri, + }, + + /// User interaction using dialoguer crate failed + #[error("dialoguer error `{}`", source)] + DialoguerError { + /// The source of the error. + #[from] + source: dialoguer::Error, + }, + + /// Input parameters + #[error("input parameters error: {0}")] + InputParameters(String), + + /// Base64 decoding error. + #[error(transparent)] + Base64Decode(#[from] base64::DecodeError), + + /// Re-authorization not possible without active authentication. + #[error("valid authentication is missing to be able to rescope the session")] + MissingValidAuthenticationForRescope, + + /// URL parsing error + #[error(transparent)] + UrlParse { + /// The source of the error. + #[from] + source: url::ParseError, + }, + + /// Others. + #[error(transparent)] + Other(#[from] eyre::Report), +} + +impl OpenStackCliError { + /// Build a deserialization error + pub fn deserialize(error: serde_json::Error, data: String) -> Self { + Self::DeserializeJson { + source: error, + data, + } + } +} diff --git a/cli-core/src/lib.rs b/cli-core/src/lib.rs new file mode 100644 index 000000000..9d7d2662a --- /dev/null +++ b/cli-core/src/lib.rs @@ -0,0 +1,44 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # OpenStack CLI core functionality + +use comfy_table::ContentArrangement; +use comfy_table::Table; +use comfy_table::presets::UTF8_FULL_CONDENSED; + +pub mod cli; +pub mod common; +pub mod config; +pub mod error; +pub mod output; +pub mod tracing_stats; + +use crate::tracing_stats::HttpRequestStats; + +/// Build a table of HTTP request timings +pub fn build_http_requests_timing_table(data: &HttpRequestStats) -> Table { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL_CONDENSED) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(Vec::from(["Url", "Method", "Duration (ms)"])); + + let mut total_http_duration: u128 = 0; + for rec in data.summarize_by_url_method() { + total_http_duration += rec.2; + table.add_row(vec![rec.0, rec.1, rec.2.to_string()]); + } + table.add_row(vec!["Total", "", &total_http_duration.to_string()]); + table +} diff --git a/cli-core/src/output.rs b/cli-core/src/output.rs new file mode 100644 index 000000000..10f2434d8 --- /dev/null +++ b/cli-core/src/output.rs @@ -0,0 +1,903 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Output processing module + +use std::collections::BTreeSet; +use std::io::{self, Write}; + +use comfy_table::{ + Cell, Color, ColumnConstraint, ContentArrangement, Table, Width, presets::UTF8_FULL_CONDENSED, +}; +use itertools::Itertools; +use openstack_sdk_core::types::EntryStatus; +use owo_colors::{OwoColorize, Stream::Stderr}; +use rand::prelude::*; +use serde::de::DeserializeOwned; + +use structable::{OutputConfig, StructTable, StructTableOptions}; + +use crate::cli::{CliArgs, OutputFormat, TableArrangement}; +use crate::config::ViewConfig; +use crate::error::OpenStackCliError; + +/// Output target (human or machine) enum +#[derive(Default, Clone)] +pub enum OutputFor { + #[default] + Human, + Machine, +} + +impl From for ContentArrangement { + fn from(value: TableArrangement) -> Self { + match value { + TableArrangement::Dynamic => Self::Dynamic, + TableArrangement::DynamicFullWidth => Self::DynamicFullWidth, + TableArrangement::Disabled => Self::Disabled, + } + } +} + +/// Output Processor. +#[derive(Default, Clone)] +pub struct OutputProcessor { + /// Resource output configuration + pub config: Option, + /// Whether output is for human or for machine + pub target: OutputFor, + /// Table arrangement + pub table_arrangement: TableArrangement, + /// Fields requested + pub fields: BTreeSet, + /// Wide mode + pub wide: bool, + /// Pretty mode + pub pretty: bool, + /// Command hints + hints: Option>, +} + +impl StructTableOptions for OutputProcessor { + fn wide_mode(&self) -> bool { + self.wide + || self + .config + .as_ref() + .is_some_and(|cfg| cfg.wide.is_some_and(|w| w)) + } + + fn pretty_mode(&self) -> bool { + self.pretty + } + + fn should_return_field>(&self, field: S, is_wide_field: bool) -> bool { + let is_requested = self + .fields + .iter() + .any(|x| x.to_lowercase() == field.as_ref().to_lowercase()) + || (self.fields.is_empty() + && self + .config + .as_ref() + .map(|cfg| { + cfg.default_fields + .iter() + .any(|x| x.to_lowercase() == field.as_ref().to_lowercase()) + }) + .is_some_and(|x| x)); + + if !is_wide_field { + // Return non wide field when no field filters passed or explicitly requested the field + is_requested + || (self.fields.is_empty() + && self + .config + .as_ref() + .is_none_or(|cfg| cfg.default_fields.is_empty())) + } else { + // The wide field is returned in wide mode when no filters passed or explicitly + // requested the field + (self.fields.is_empty() && self.wide_mode()) || is_requested + } + } + + fn field_data_json_pointer>(&self, field: S) -> Option { + if !self.wide_mode() { + self.config.as_ref().and_then(|config| { + config + .fields + .iter() + .find(|x| x.name.to_lowercase() == field.as_ref().to_lowercase()) + .and_then(|field_config| field_config.json_pointer.clone()) + }) + } else { + None + } + } +} + +impl OutputProcessor { + /// Get OutputConfig from passed arguments + pub fn from_args, A: AsRef>( + args: &C, + resource_key: Option, + action: Option, + ) -> Self { + let target = match args.global_opts().output.output { + None => OutputFor::Human, + Some(OutputFormat::Wide) => OutputFor::Human, + _ => OutputFor::Machine, + }; + let mut hints: Vec = args.config().hints.clone(); + + if let (Some(resource_key), Some(action)) = (&resource_key, &action) { + args.config() + .command_hints + .get(resource_key.as_ref()) + .and_then(|cmd_hints| { + cmd_hints.get(action.as_ref()).map(|val| { + hints.extend(val.clone()); + }) + }); + } + + Self { + config: resource_key + .as_ref() + .and_then(|val| args.config().views.get(val.as_ref()).cloned()), + target, + table_arrangement: args.global_opts().output.table_arrangement, + fields: BTreeSet::from_iter(args.global_opts().output.fields.iter().cloned()), + wide: matches!(args.global_opts().output.output, Some(OutputFormat::Wide)), + pretty: args.global_opts().output.pretty, + hints: Some(hints), + } + } + + /// Validate command arguments with respect to the output producing + pub fn validate_args(&self, _args: &C) -> Result<(), OpenStackCliError> { + Ok(()) + } + + /// Re-sort table according to the configuration and determine column constraints + fn prepare_table( + &self, + headers: Vec, + data: Vec>, + ) -> (Vec, Vec>, Vec>) { + let mut headers = headers; + let mut rows = data; + let mut column_constrains: Vec> = vec![None; headers.len()]; + + if let Some(cfg) = &self.config { + // Offset from the current iteration pointer + if headers.len() > 1 { + let mut idx_offset: usize = 0; + for (default_idx, field) in cfg.default_fields.iter().unique().enumerate() { + if let Some(curr_idx) = headers + .iter() + .position(|x| x.to_lowercase() == field.to_lowercase()) + { + // Swap headers between current and should pos + if default_idx - idx_offset < headers.len() { + headers.swap(default_idx - idx_offset, curr_idx); + for row in rows.iter_mut() { + // Swap also data columns + row.swap(default_idx - idx_offset, curr_idx); + } + } + } else { + // This column is not found in the data. Perhars structable returned some + // other name. Move the column to the very end + if default_idx - idx_offset < headers.len() { + let curr_hdr = headers.remove(default_idx - idx_offset); + headers.push(curr_hdr); + for row in rows.iter_mut() { + let curr_cell = row.remove(default_idx - idx_offset); + row.push(curr_cell); + } + // Some unmatched field moved to the end. Our "current" index should respect + // the offset + idx_offset += 1; + } + } + } + } + // Find field configuration + for (idx, field) in headers.iter().enumerate() { + if let Some(field_config) = cfg + .fields + .iter() + .find(|x| x.name.to_lowercase() == field.to_lowercase()) + { + let constraint = match ( + field_config.width, + field_config.min_width, + field_config.max_width, + ) { + (Some(fixed), _, _) => { + Some(ColumnConstraint::Absolute(Width::Fixed(fixed as u16))) + } + (None, Some(lower), Some(upper)) => Some(ColumnConstraint::Boundaries { + lower: Width::Fixed(lower as u16), + upper: Width::Fixed(upper as u16), + }), + (None, Some(lower), None) => { + Some(ColumnConstraint::LowerBoundary(Width::Fixed(lower as u16))) + } + (None, None, Some(upper)) => { + Some(ColumnConstraint::UpperBoundary(Width::Fixed(upper as u16))) + } + _ => None, + }; + column_constrains[idx] = constraint; + } + } + } + (headers, rows, column_constrains) + } + + /// Output List of resources + pub fn output_list(&self, data: Vec) -> Result<(), OpenStackCliError> + where + T: StructTable, + T: DeserializeOwned, + for<'a> &'a T: StructTable, + { + match self.target { + OutputFor::Human => { + let table: Vec = serde_json::from_value(serde_json::Value::Array(data.clone())) + .map_err(|err| { + OpenStackCliError::deserialize( + err, + serde_json::to_string(&serde_json::Value::Array( + data.into_iter() + .filter(|item| { + serde_json::from_value::(item.clone()).is_err() + }) + .collect(), + )) + .unwrap_or_else(|v| format!("{v:?}")), + ) + })?; + + let data = structable::build_list_table(table.iter(), self); + let (headers, table_rows, table_constraints) = self.prepare_table(data.0, data.1); + let mut statuses: Vec> = + table.iter().map(|item| item.status()).collect(); + + // Ensure we have as many statuses as rows to zip them properly + statuses.resize_with(table_rows.len(), Default::default); + + let rows = table_rows + .iter() + .zip(statuses.iter()) + .map(|(data, status)| { + let color = match EntryStatus::from(status.as_ref()) { + EntryStatus::Error => Some(Color::Red), + EntryStatus::Pending => Some(Color::Yellow), + EntryStatus::Inactive => Some(Color::Cyan), + _ => None, + }; + data.iter().map(move |cell| { + if let Some(color) = color { + Cell::new(cell).fg(color) + } else { + Cell::new(cell) + } + }) + }); + let mut table = Table::new(); + table + .load_preset(UTF8_FULL_CONDENSED) + .set_content_arrangement(ContentArrangement::from(self.table_arrangement)) + .set_header(headers) + .add_rows(rows); + + for (idx, constraint) in table_constraints.iter().enumerate() { + if let Some(constraint) = constraint + && let Some(col) = table.column_mut(idx) + { + col.set_constraint(*constraint); + } + } + + println!("{table}"); + Ok(()) + } + _ => self.output_machine(serde_json::from_value(serde_json::Value::Array(data))?), + } + } + + /// Output List of resources + pub fn output_single(&self, data: serde_json::Value) -> Result<(), OpenStackCliError> + where + T: StructTable, + T: DeserializeOwned, + { + match self.target { + OutputFor::Human => { + let table: T = serde_json::from_value(data.clone()).map_err(|err| { + OpenStackCliError::deserialize( + err, + serde_json::to_string(&data.clone()).unwrap_or_default(), + ) + })?; + + self.output_human(&table) + } + _ => self.output_machine(serde_json::from_value(data)?), + } + } + + /// Produce output for humans (table) for a single resource + pub fn output_human(&self, data: &T) -> Result<(), OpenStackCliError> { + let (headers, table_rows) = structable::build_table(data, &OutputConfig::default()); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL_CONDENSED) + .set_content_arrangement(ContentArrangement::from(self.table_arrangement)) + .set_header(headers) + .add_rows(table_rows); + println!("{table}"); + Ok(()) + } + + /// Produce output for machine + /// Return machine readable output with the API side names + pub fn output_machine(&self, data: serde_json::Value) -> Result<(), OpenStackCliError> { + if self.pretty { + serde_json::to_writer_pretty(io::stdout(), &data)?; + } else { + serde_json::to_writer(io::stdout(), &data)?; + } + io::stdout().write_all(b"\n")?; + Ok(()) + } + + /// Show hints + pub fn show_command_hint(&self) -> Result<(), OpenStackCliError> { + if rand::random_bool(1.0 / 2.0) { + self.hints.as_ref().and_then(|hints| { + hints.choose(&mut rand::rng()).map(|hint| { + eprintln!( + "\n{} {}", + "Hint:".if_supports_color(Stderr, |text| text.green()), + hint.if_supports_color(Stderr, |text| text.blue()) + ); + }) + }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use std::io::Write; + use tempfile::Builder; + + use crate::{cli::*, config::*}; + + #[test] + fn test_wide_mode() { + assert!(!OutputProcessor::default().wide_mode()); + assert!( + OutputProcessor { + wide: true, + ..Default::default() + } + .wide_mode() + ); + assert!( + OutputProcessor { + config: Some(ViewConfig { + wide: Some(true), + ..Default::default() + }), + ..Default::default() + } + .wide_mode() + ); + } + + #[test] + fn test_field_returned_no_selection() { + let out = OutputProcessor::default(); + + assert!( + out.should_return_field("dummy", false), + "default field returned in non-wide mode with empty fields selector" + ); + assert!( + !out.should_return_field("dummy", true), + "wide field not returned in non-wide mode with empty fields selector" + ); + + let out = OutputProcessor { + wide: true, + ..Default::default() + }; + + assert!( + out.should_return_field("dummy", false), + "default field returned in wide mode with empty fields selector" + ); + assert!( + out.should_return_field("dummy", true), + "wide field returned in wide mode with empty fields selector" + ); + } + + #[test] + fn test_field_returned_selection_no_config() { + let out = OutputProcessor { + fields: BTreeSet::from(["foo".to_string()]), + ..Default::default() + }; + + assert!( + !out.should_return_field("dummy", false), + "default field not returned in non-wide mode with mismatching fields selector" + ); + assert!( + !out.should_return_field("dummy", true), + "wide field not returned in non-wide mode with mismatching fields selector" + ); + assert!( + out.should_return_field("foo", false), + "default field returned in non-wide mode with matching fields selector" + ); + assert!( + out.should_return_field("foo", true), + "wide field returned in non-wide mode with matching fields selector" + ); + + let out = OutputProcessor { + fields: BTreeSet::from(["foo".to_string()]), + wide: true, + ..Default::default() + }; + + assert!( + !out.should_return_field("dummy", false), + "default field not returned in wide mode with mismatching fields selector" + ); + assert!( + !out.should_return_field("dummy", true), + "wide field not returned in wide mode with mismatching fields selector" + ); + } + + #[test] + fn test_field_returned_selection_empty_config() { + let out = OutputProcessor { + config: Some(ViewConfig::default()), + target: OutputFor::Human, + table_arrangement: TableArrangement::Disabled, + fields: BTreeSet::new(), + wide: false, + pretty: false, + ..Default::default() + }; + + assert!( + out.should_return_field("dummy", false), + "default field returned in non-wide mode with mismatching fields selector and empty config" + ); + assert!( + !out.should_return_field("dummy", true), + "wide field not returned in non-wide mode with mismatching fields selector and empty config" + ); + } + + #[test] + fn test_field_returned_selection_with_config_with_filters() { + let out = OutputProcessor { + config: Some(ViewConfig { + default_fields: vec!["foo".to_string()], + ..Default::default() + }), + fields: BTreeSet::from(["bar".to_string()]), + ..Default::default() + }; + + assert!( + !out.should_return_field("dummy", false), + "default field not returned in non-wide mode with mismatching fields selector" + ); + assert!( + !out.should_return_field("dummy", true), + "wide field not returned in non-wide mode with mismatching fields selector" + ); + assert!( + !out.should_return_field("foo", false), + "default field not returned in non-wide mode with mismatching fields selector" + ); + assert!( + !out.should_return_field("foo", true), + "wide field not returned in non-wide mode with mismatching fields selector" + ); + assert!( + out.should_return_field("bar", false), + "default field returned in non-wide mode with matching fields selector" + ); + assert!( + out.should_return_field("bar", true), + "wide field returned in non-wide mode with matching fields selector" + ); + + let out = OutputProcessor { + config: Some(ViewConfig { + default_fields: vec!["foo".to_string()], + ..Default::default() + }), + fields: BTreeSet::from(["bar".to_string()]), + wide: true, + ..Default::default() + }; + + assert!( + !out.should_return_field("dummy", false), + "default field not returned in wide mode with mismatching fields selector" + ); + assert!( + !out.should_return_field("dummy", true), + "wide field not returned in wide mode with mismatching fields selector" + ); + assert!( + !out.should_return_field("foo", false), + "config field not returned in wide mode with mismatching fields selector" + ); + assert!( + !out.should_return_field("foo", true), + "wide config field not returned in wide mode with mismatching fields selector" + ); + assert!( + out.should_return_field("bar", false), + "default field returned in wide mode with matching fields selector" + ); + assert!( + out.should_return_field("bar", true), + "wide field returned in wide mode with matching fields selector" + ); + } + + #[test] + fn test_field_returned_selection_with_config_no_filters() { + let out = OutputProcessor { + config: Some(ViewConfig { + default_fields: vec!["foo".to_string()], + ..Default::default() + }), + ..Default::default() + }; + + assert!( + !out.should_return_field("dummy", false), + "default field not returned in non-wide mode with empty fields selector and not in config" + ); + assert!( + out.should_return_field("foo", false), + "default field not returned in non-wide mode with empty fields selector, but in config" + ); + assert!( + !out.should_return_field("dummy", true), + "wide field not returned in non-wide mode with empty fields selector and not in config" + ); + assert!( + out.should_return_field("foo", true), + "wide field returned in non-wide mode with empty fields selector, but in config" + ); + + let out = OutputProcessor { + config: Some(ViewConfig { + default_fields: vec!["foo".to_string()], + ..Default::default() + }), + wide: true, + ..Default::default() + }; + + assert!( + !out.should_return_field("dummy", false), + "default field not returned in wide mode with empty fields selector and not in config" + ); + assert!( + out.should_return_field("foo", false), + "default field returned in wide mode with empty fields selector, but in config" + ); + assert!( + out.should_return_field("dummy", true), + "wide field returned in wide mode with empty fields selector and not in config" + ); + assert!( + out.should_return_field("foo", true), + "wide field returned in wide mode with empty fields selector, but in config" + ); + } + + #[test] + fn test_prepare_table() { + let out = OutputProcessor { + config: Some(ViewConfig { + default_fields: vec![ + "foo".to_string(), + "bar".to_string(), + "baz".to_string(), + "dummy".to_string(), + ], + fields: vec![FieldConfig { + name: "bar".to_string(), + min_width: Some(15), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + let (hdr, rows, constraints) = out.prepare_table( + vec![ + "dummy".to_string(), + "bar".to_string(), + "foo".to_string(), + "baz".to_string(), + ], + vec![ + vec![ + "11".to_string(), + "12".to_string(), + "13".to_string(), + "14".to_string(), + ], + vec![ + "21".to_string(), + "22".to_string(), + "23".to_string(), + "24".to_string(), + ], + ], + ); + assert_eq!( + vec![ + "foo".to_string(), + "bar".to_string(), + "baz".to_string(), + "dummy".to_string() + ], + hdr, + "headers in the correct sort order" + ); + assert_eq!( + vec![ + vec![ + "13".to_string(), + "12".to_string(), + "14".to_string(), + "11".to_string(), + ], + vec![ + "23".to_string(), + "22".to_string(), + "24".to_string(), + "21".to_string(), + ], + ], + rows, + "row columns sorted properly" + ); + assert_eq![ + vec![ + None, + Some(ColumnConstraint::LowerBoundary(Width::Fixed(15))), + None, + None + ], + constraints + ]; + + let (hdr, rows, _constraints) = out.prepare_table( + vec![ + "dummy".to_string(), + "bar2".to_string(), + "foo".to_string(), + "baz2".to_string(), + ], + vec![ + vec![ + "11".to_string(), + "12".to_string(), + "13".to_string(), + "14".to_string(), + ], + vec![ + "21".to_string(), + "22".to_string(), + "23".to_string(), + "24".to_string(), + ], + ], + ); + assert_eq!( + vec![ + "foo".to_string(), + "dummy".to_string(), + "bar2".to_string(), + "baz2".to_string(), + ], + hdr, + "headers with unknown fields in the correct sort order" + ); + assert_eq!( + vec![ + vec![ + "13".to_string(), + "11".to_string(), + "12".to_string(), + "14".to_string(), + ], + vec![ + "23".to_string(), + "21".to_string(), + "22".to_string(), + "24".to_string(), + ], + ], + rows, + "row columns sorted properly" + ); + } + + #[test] + fn test_prepare_table_duplicated_values() { + let out = OutputProcessor { + config: Some(ViewConfig { + default_fields: vec![ + "foo".to_string(), + "bar".to_string(), + "foo".to_string(), + "baz".to_string(), + ], + ..Default::default() + }), + ..Default::default() + }; + let (hdr, rows, _constraints) = out.prepare_table( + vec!["bar".to_string(), "foo".to_string(), "baz".to_string()], + vec![ + vec!["11".to_string(), "12".to_string(), "13".to_string()], + vec!["21".to_string(), "22".to_string(), "23".to_string()], + ], + ); + assert_eq!( + vec!["foo".to_string(), "bar".to_string(), "baz".to_string(),], + hdr, + "headers in the correct sort order" + ); + assert_eq!( + vec![ + vec!["12".to_string(), "11".to_string(), "13".to_string(),], + vec!["22".to_string(), "21".to_string(), "23".to_string(),], + ], + rows, + "row columns sorted properly" + ); + } + + #[test] + fn test_prepare_table_missing_default_fields() { + let out = OutputProcessor { + config: Some(ViewConfig { + default_fields: vec![ + "foo".to_string(), + "bar1".to_string(), + "foo1".to_string(), + "baz1".to_string(), + ], + ..Default::default() + }), + ..Default::default() + }; + let (hdr, rows, _constraints) = out.prepare_table( + vec!["bar".to_string(), "foo".to_string(), "baz".to_string()], + vec![ + vec!["11".to_string(), "12".to_string(), "13".to_string()], + vec!["21".to_string(), "22".to_string(), "23".to_string()], + ], + ); + assert_eq!( + vec!["foo".to_string(), "baz".to_string(), "bar".to_string(),], + hdr, + "headers in the correct sort order" + ); + assert_eq!( + vec![ + vec!["12".to_string(), "13".to_string(), "11".to_string(),], + vec!["22".to_string(), "23".to_string(), "21".to_string(),], + ], + rows, + "row columns sorted properly" + ); + } + + #[test] + fn test_output_processor_from_args_hints() { + let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap(); + + const CONFIG_DATA: &str = r#" + views: + foo: + default_fields: ["a", "b", "c"] + bar: + fields: + - name: "b" + min_width: 1 + command_hints: + res: + cmd: + - cmd_hint1 + - cmd_hint2 + cmd2: [cmd2_hint1] + res2: + cmd: [] + hints: + - hint1 + - hint2 + enable_hints: true + "#; + + write!(config_file, "{CONFIG_DATA}").unwrap(); + + #[derive(Parser)] + struct Cli { + #[command(flatten)] + global_opts: GlobalOpts, + #[arg(long("cli-config"), value_parser = parse_config, default_value_t = Config::new().unwrap())] + config: Config, + } + + impl CliArgs for Cli { + fn global_opts(&self) -> &GlobalOpts { + &self.global_opts + } + + fn config(&self) -> &Config { + &self.config + } + } + + let op = OutputProcessor::from_args( + &Cli::parse_from([ + "osc", + "--cli-config", + &config_file.path().as_os_str().to_string_lossy(), + ]), + Some("res"), + Some("cmd"), + ); + assert_eq!( + Some(vec![ + "hint1".to_string(), + "hint2".to_string(), + "cmd_hint1".to_string(), + "cmd_hint2".to_string() + ]), + op.hints + ); + } +} diff --git a/openstack_cli/src/tracing_stats.rs b/cli-core/src/tracing_stats.rs similarity index 97% rename from openstack_cli/src/tracing_stats.rs rename to cli-core/src/tracing_stats.rs index e0e7acbf4..629154340 100644 --- a/openstack_cli/src/tracing_stats.rs +++ b/cli-core/src/tracing_stats.rs @@ -26,8 +26,8 @@ use tracing_subscriber::layer::Context; /// HTTP Request statistics container #[derive(Default)] -pub(crate) struct HttpRequestStats { - pub requests: Vec, +pub struct HttpRequestStats { + requests: Vec, } impl HttpRequestStats { @@ -59,13 +59,13 @@ impl HttpRequestStats { /// /// Added as a `tracing` layer it captures all events with name "request" and mandatory fields: [url, /// duration_ms, method] (additional optional fields: [status, request_id] -pub(crate) struct RequestTracingCollector { +pub struct RequestTracingCollector { pub stats: Arc>, } /// Single HTTP request profile record #[derive(Debug, Default)] -pub(crate) struct HttpRequest { +struct HttpRequest { pub url: String, pub method: String, pub duration: u128, diff --git a/openstack_cli/Cargo.toml b/openstack_cli/Cargo.toml index c9862b5b9..be0725e7f 100644 --- a/openstack_cli/Cargo.toml +++ b/openstack_cli/Cargo.toml @@ -61,23 +61,17 @@ _test_net_vpn = [] [dependencies] base64 = { workspace = true } bytes = { workspace = true } -config.workspace = true chrono = { workspace= true } clap = { workspace = true, features = ["color", "derive", "env"] } clap_complete = { workspace = true } color-eyre = { workspace = true } -comfy-table = { version = "^7.2" } dialoguer = { workspace = true, features=["fuzzy-select"] } -dirs = { workspace = true } eyre = { workspace = true } http = { workspace = true } -itertools = { workspace = true } json-patch = { workspace = true } +openstack-cli-core = { path="../cli-core", version = "^0.13" } openstack_sdk = { path="../openstack_sdk", version = "^0.22", default-features = false, features = ["async", "identity"] } openstack_types = { path="../openstack_types", version = "^0.22" } -owo-colors = { version = "^4.3", features = ["supports-colors"] } -indicatif = "^0.18" -rand = { version = "^0.10" } regex = { workspace = true } reqwest = { workspace = true } secrecy.workspace = true @@ -85,8 +79,7 @@ serde = { workspace = true } serde_json = {workspace = true} strip-ansi-escapes = { workspace = true } structable = { workspace = true } -tokio = { workspace = true, features = ["fs", "macros", "net", "sync", "rt-multi-thread", "io-std"]} -tokio-util = {workspace = true} +tokio = { workspace = true, features = ["macros", "net", "sync", "rt-multi-thread"]} thiserror = { workspace = true } tracing = { workspace = true} tracing-subscriber = { workspace = true } diff --git a/openstack_cli/src/cli.rs b/openstack_cli/src/cli.rs index fd36be3c6..c242626fc 100644 --- a/openstack_cli/src/cli.rs +++ b/openstack_cli/src/cli.rs @@ -12,14 +12,9 @@ // // SPDX-License-Identifier: Apache-2.0 //! CLI top level command and processing -//! -use clap::builder::{ - Styles, - styling::{AnsiColor, Effects}, -}; -use clap::{Args, Parser, ValueEnum, ValueHint}; -use clap_complete::Shell; +use clap::Parser; +use openstack_cli_core::cli::{CliArgs, CompletionCommand, GlobalOpts, parse_config, styles}; use openstack_sdk::AsyncOpenStack; use crate::error::OpenStackCliError; @@ -29,7 +24,7 @@ use crate::auth; use crate::block_storage::v3 as block_storage; use crate::catalog; use crate::compute::v2 as compute; -use crate::config::{Config, ConfigError}; +use crate::config::Config; use crate::container_infrastructure_management::v1 as container_infra; use crate::dns::v2 as dns; use crate::identity::v3 as identity; @@ -41,14 +36,6 @@ use crate::network::v2 as network; use crate::object_store::v1 as object_store; use crate::placement::v1 as placement; -fn styles() -> Styles { - Styles::styled() - .header(AnsiColor::Green.on_default() | Effects::BOLD) - .usage(AnsiColor::Green.on_default() | Effects::BOLD) - .literal(AnsiColor::White.on_default() | Effects::BOLD) - .placeholder(AnsiColor::Cyan.on_default()) -} - /// OpenStack command line interface. /// /// ## Configuration @@ -128,146 +115,14 @@ pub struct Cli { pub config: Config, } -/// Parse config file -pub fn parse_config(s: &str) -> Result { - let mut builder = Config::builder()?; - if !s.is_empty() { - builder = builder.add_source(s).map_err(ConfigError::builder)?; +impl CliArgs for Cli { + fn global_opts(&self) -> &GlobalOpts { + &self.global_opts } - Ok(builder.build()?) -} - -/// Connection options. -#[derive(Args)] -#[command(next_display_order = 800, next_help_heading = "Connection options")] -pub struct ConnectionOpts { - /// Name reference to the clouds.yaml entry for the cloud configuration. - #[arg(long, env = "OS_CLOUD", global = true, display_order = 801, conflicts_with_all(["cloud_config_from_env", "os_cloud_name"]))] - pub os_cloud: Option, - - /// Get the cloud config from environment variables. - /// - /// Conflicts with the `--os-cloud` option. No merging of environment variables with the - /// options from the `clouds.yaml` file done. It is possible to rely on the `--auth-helper-cmd` - /// command, but than the `--os-cloud-name` should be specified to give a reasonable connection - /// name. - #[arg(long, global = true, action = clap::ArgAction::SetTrue, display_order = 802)] - pub cloud_config_from_env: bool, - - /// Cloud name used when configuration is retrieved from environment variables. When not - /// specified the `envvars` would be used as a default. This value will be used eventually by - /// the authentication helper when data need to be provided dynamically. - #[arg(long, env = "OS_CLOUD_NAME", global = true, display_order = 802)] - pub os_cloud_name: Option, - - /// Project ID to use instead of the one in connection profile. - #[arg(long, env = "OS_PROJECT_ID", global = true, display_order = 803)] - pub os_project_id: Option, - - /// Project Name to use instead of the one in the connection profile. - #[arg(long, env = "OS_PROJECT_NAME", global = true, display_order = 803)] - pub os_project_name: Option, - - /// Region Name to use instead of the one in the connection profile. - #[arg(long, env = "OS_REGION_NAME", global = true, display_order = 804)] - pub os_region_name: Option, - - /// Custom path to the `clouds.yaml` config file. - #[arg( - long, - env = "OS_CLIENT_CONFIG_FILE", - global = true, - value_hint = ValueHint::FilePath, - display_order = 805 - )] - pub os_client_config_file: Option, - - /// Custom path to the `secure.yaml` config file. - #[arg( - long, - env = "OS_CLIENT_SECURE_FILE", - global = true, - value_hint = ValueHint::FilePath, - display_order = 805 - )] - pub os_client_secure_file: Option, - - /// External authentication helper command. - /// - /// Invoke external command to obtain necessary connection parameters. This is a path to the - /// executable, which is called with first parameter being the attribute key (i.e. `password`) - /// and a second parameter a cloud name (whatever is used in `--os-cloud` or - /// `--os-cloud-name`). - #[arg(long, global = true, value_hint = ValueHint::ExecutablePath, display_order = 810)] - pub auth_helper_cmd: Option, -} - -/// Output configuration. -#[derive(Args)] -#[command(next_display_order = 900, next_help_heading = "Output options")] -pub struct OutputOpts { - /// Output format. - #[arg(short, long, global = true, value_enum, display_order = 910)] - pub output: Option, - - /// Fields to return in the output (only in normal and wide mode). - #[arg(short, long, global=true, action=clap::ArgAction::Append, display_order = 910)] - pub fields: Vec, - - /// Pretty print the output. - #[arg(short, long, global=true, action = clap::ArgAction::SetTrue, display_order = 910)] - pub pretty: bool, - /// Verbosity level. Repeat to increase level. - #[arg(short, long, global=true, action = clap::ArgAction::Count, display_order = 920)] - pub verbose: u8, - - /// Output Table arrangement. - #[arg(long, global=true, default_value_t = TableArrangement::Dynamic, value_enum, display_order = 930)] - pub table_arrangement: TableArrangement, - - /// Record HTTP request timings. - #[arg(long, global=true, action = clap::ArgAction::SetTrue, display_order = 950)] - pub timing: bool, -} - -/// Global CLI options. -#[derive(Args)] -#[command(next_display_order = 900)] -pub struct GlobalOpts { - /// Connection options. - #[command(flatten)] - pub connection: ConnectionOpts, - - /// Output config. - #[command(flatten)] - pub output: OutputOpts, -} - -/// Output format. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -pub enum OutputFormat { - /// Json output. - Json, - /// Wide (Human readable table with extra attributes). Note: this has - /// effect only in list operations. - Wide, -} - -/// Table arrangement. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -pub enum TableArrangement { - /// Dynamically determine the width of columns in regard to terminal width and content length. - /// With this mode, the content in cells will wrap dynamically to get the the best column - /// layout for the given content. - #[default] - Dynamic, - /// This is mode is the same as the `Dynamic` arrangement, but it will always use as much space - /// as it’s given. Any surplus space will be distributed between all columns. - DynamicFullWidth, - /// Don’t do any content arrangement. Tables with this mode might become wider than your output - /// and look ugly. - Disabled, + fn config(&self) -> &Config { + &self.config + } } /// Supported Top Level commands (services). @@ -293,23 +148,6 @@ pub enum TopLevelCommands { Completion(CompletionCommand), } -/// Output shell completion code for the specified shell (bash, zsh, fish, or powershell). The -/// shell code must be evaluated to provide interactive completion of `osc` commands. This can -/// be done by sourcing it from the .bash_profile. -/// -/// Examples: -/// -/// Enable completion at a shell start: -/// -/// `echo 'source <(osc completion bash)' >>~/.bashrc` -/// -#[derive(Parser, Debug)] -pub struct CompletionCommand { - /// If provided, outputs the completion file for given shell. - #[arg(default_value_t = Shell::Bash)] - pub shell: Shell, -} - impl Cli { /// Perform command action. pub async fn take_action(&self, client: &mut AsyncOpenStack) -> Result<(), OpenStackCliError> { diff --git a/openstack_cli/src/common.rs b/openstack_cli/src/common.rs index dde185099..93b9eb860 100644 --- a/openstack_cli/src/common.rs +++ b/openstack_cli/src/common.rs @@ -13,229 +13,4 @@ // SPDX-License-Identifier: Apache-2.0 //! Common helpers. -use eyre::OptionExt; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; -use std::error::Error; -use std::io::IsTerminal; - -use indicatif::{ProgressBar, ProgressStyle}; -use std::path::Path; -use tokio::fs; -use tokio::io::{self}; -use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; -use tokio_util::io::InspectReader; - -use openstack_sdk::types::BoxedAsyncRead; -use structable::{StructTable, StructTableOptions}; - -use crate::error::OpenStackCliError; - -/// Newtype for the `HashMap` -#[derive(Deserialize, Default, Debug, Clone, Serialize)] -pub struct HashMapStringString(pub HashMap); - -impl StructTable for HashMapStringString { - fn instance_headers( - &self, - _options: &O, - ) -> Option<::std::vec::Vec<::std::string::String>> { - Some(self.0.keys().map(Into::into).collect()) - } - - fn data( - &self, - _options: &O, - ) -> ::std::vec::Vec> { - self.0.values().map(|x| Some(x.into())).collect() - } -} - -// /// Try to deserialize data and return `Default` if that fails -// pub fn deser_ok_or_default<'a, T, D>(deserializer: D) -> Result -// where -// T: Deserialize<'a> + Default, -// D: Deserializer<'a>, -// { -// let v: Value = Deserialize::deserialize(deserializer)?; -// Ok(T::deserialize(v).unwrap_or_default()) -// } - -/// Parse a single key-value pair -pub(crate) fn parse_key_val(s: &str) -> Result<(T, U), Box> -where - T: std::str::FromStr, - T::Err: Error + Send + Sync + 'static, - U: std::str::FromStr, - U::Err: Error + Send + Sync + 'static, -{ - let (k, v) = s - .split_once('=') - .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?; - Ok((k.parse()?, v.parse()?)) -} - -/// Parse a single key-value pair where value can be null -pub(crate) fn parse_key_val_opt( - s: &str, -) -> Result<(T, Option), Box> -where - T: std::str::FromStr, - T::Err: Error + Send + Sync + 'static, - U: std::str::FromStr, - U::Err: Error + Send + Sync + 'static, -{ - let (k, v) = s - .split_once('=') - .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?; - - let key = k.parse()?; - let val = (!v.is_empty()).then(|| v.parse()).transpose()?; - - Ok((key, val)) -} - -pub(crate) fn parse_json(s: &str) -> Result> -where -{ - Ok(serde_json::from_str(s)?) -} - -/// Download content from the reqwests response stream. -/// When dst_name = "-" - write content to the stdout. -/// Otherwise write into the destination and display progress_bar -pub(crate) async fn download_file( - dst_name: String, - size: u64, - data: BoxedAsyncRead, -) -> Result<(), OpenStackCliError> { - let progress_bar = ProgressBar::new(size); - - let mut inspect_reader = - InspectReader::new(data.compat(), |bytes| progress_bar.inc(bytes.len() as u64)); - if dst_name == "-" { - progress_bar.set_style( - ProgressStyle::default_bar() - .progress_chars("#>-") - .template("[{bar:40.cyan/blue}] {bytes}/{total_bytes} at {bytes_per_sec}")?, - ); - - let mut writer = io::stdout(); - io::copy(&mut inspect_reader, &mut writer).await?; - } else { - let path = Path::new(&dst_name); - let fname = path - .file_name() - .ok_or_eyre("download file name must be known")? - .to_str() - .ok_or_eyre("download file name must be a string")?; - progress_bar.set_message(String::from(fname)); - progress_bar.set_style( - ProgressStyle::default_bar() - .progress_chars("#>-") - .template( - "[{bar:40.cyan/blue}] {bytes}/{total_bytes} at {bytes_per_sec} - {msg}", - )?, - ); - - let mut writer = fs::File::create(path).await?; - io::copy(&mut inspect_reader, &mut writer).await?; - } - progress_bar.finish(); - Ok(()) -} - -/// Construct BoxedAsyncRead with progress bar from stdin -async fn build_upload_asyncread_from_stdin() -> Result { - let progress_bar = ProgressBar::new(0); - - progress_bar.set_style( - ProgressStyle::default_bar() - .progress_chars("#>-") - .template("[{bar:40.cyan/blue}] {bytes}/{total_bytes} at {bytes_per_sec}")?, - ); - - let inspect_reader = InspectReader::new(io::stdin(), move |bytes| { - progress_bar.inc(bytes.len() as u64) - }); - Ok(BoxedAsyncRead::new(inspect_reader.compat())) -} - -/// Construct BoxedAsyncRead with progress bar from the file -async fn build_upload_asyncread_from_file( - file_path: &str, -) -> Result { - let progress_bar = ProgressBar::new(0); - - progress_bar.set_style( - ProgressStyle::default_bar() - .progress_chars("#>-") - .template("[{bar:40.cyan/blue}] {bytes}/{total_bytes} at {bytes_per_sec}")?, - ); - let reader = fs::File::open(&file_path).await?; - - progress_bar.set_length(reader.metadata().await?.len()); - let inspect_reader = - InspectReader::new(reader, move |bytes| progress_bar.inc(bytes.len() as u64)); - - Ok(BoxedAsyncRead::new(inspect_reader.compat())) -} - -/// Wrap file or stdout for being uploaded with reqwests library. -/// When dst_name = "-" - write content to the stdout. -/// Otherwise write into the destination and display progress_bar -pub(crate) async fn build_upload_asyncread( - src_name: Option, -) -> Result { - if !std::io::stdin().is_terminal() && src_name.is_none() { - // Reading from stdin - build_upload_asyncread_from_stdin().await - } else { - match src_name - .ok_or(OpenStackCliError::InputParameters( - "upload source name must be provided when stdin is not being piped".into(), - ))? - .as_str() - { - "-" => build_upload_asyncread_from_stdin().await, - file_name => build_upload_asyncread_from_file(file_name).await, - } - } -} - -// #[derive(Debug, PartialEq, PartialOrd)] -// pub(crate) struct ServiceApiVersion(pub u8, pub u8); -// -// impl TryFrom for ServiceApiVersion { -// type Error = (); -// fn try_from(ver: String) -> Result { -// let parts: Vec = ver.split('.').flat_map(|v| v.parse::()).collect(); -// Ok(ServiceApiVersion(parts[0], parts[1])) -// } -// } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_key_val() { - assert_eq!( - ("foo".to_string(), "bar".to_string()), - parse_key_val::("foo=bar").unwrap() - ); - } - - #[test] - fn test_parse_key_val_opt() { - assert_eq!( - ("foo".to_string(), Some("bar".to_string())), - parse_key_val_opt::("foo=bar").unwrap() - ); - assert_eq!( - ("foo".to_string(), None), - parse_key_val_opt::("foo=").unwrap() - ); - } -} +pub use openstack_cli_core::common::*; diff --git a/openstack_cli/src/config.rs b/openstack_cli/src/config.rs index 8bafac8e3..1f727018d 100644 --- a/openstack_cli/src/config.rs +++ b/openstack_cli/src/config.rs @@ -29,311 +29,4 @@ //! wide: true //! ``` -use eyre::Result; -use serde::Deserialize; -use std::{ - collections::HashMap, - fmt, - path::{Path, PathBuf}, -}; -use thiserror::Error; -use tracing::error; - -const CONFIG: &str = include_str!("../.config/config.yaml"); - -/// Errors which may occur when dealing with OpenStack connection -/// configuration data. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum ConfigError { - /// Parsing error. - #[error("failed to parse config: {}", source)] - Parse { - /// The source of the error. - #[from] - source: config::ConfigError, - }, - - /// Config dir cannot be identified. - #[error("config dir cannot be identified")] - ConfigDirCannotBeIdentified, - - /// Parsing error. - #[error("failed to parse config: {}", source)] - Builder { - /// The source of the error. - #[from] - source: ConfigBuilderError, - }, -} - -impl ConfigError { - /// Build a `[ConfigError::Parse]` error from `[ConfigError]` - pub fn parse(source: config::ConfigError) -> Self { - ConfigError::Parse { source } - } - /// Build a `[ConfigError::Builder]` error from `[ConfigBuilderError]` - pub fn builder(source: ConfigBuilderError) -> Self { - ConfigError::Builder { source } - } -} - -/// Errors which may occur when adding sources to the [`ConfigBuilder`]. -#[derive(Error)] -#[non_exhaustive] -pub enum ConfigBuilderError { - /// File parsing error - #[error("failed to parse file {path:?}: {source}")] - FileParse { - /// Error source - source: Box, - /// Builder object - builder: ConfigBuilder, - /// Error file path - path: PathBuf, - }, - /// Config file deserialization error - #[error("failed to deserialize config {path:?}: {source}")] - ConfigDeserialize { - /// Error source - source: Box, - /// Builder object - builder: ConfigBuilder, - /// Error file path - path: PathBuf, - }, -} -/// -/// Output configuration -/// -/// This structure is controlling how the table table is being built for a structure. -#[derive(Clone, Debug, Default, Deserialize)] -pub struct ViewConfig { - /// Limit fields (their titles) to be returned - #[serde(default)] - pub default_fields: Vec, - /// Fields configurations - #[serde(default)] - pub fields: Vec, - /// Defaults to wide mode - #[serde(default)] - pub wide: Option, -} - -/// Field output configuration -#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialOrd, PartialEq)] -pub struct FieldConfig { - /// Attribute name - pub name: String, - /// Fixed width of the column - #[serde(default)] - pub width: Option, - /// Min width of the column - #[serde(default)] - pub min_width: Option, - /// Max width of the column - #[serde(default)] - pub max_width: Option, - /// [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901) to extract data from the - /// field - #[serde(default)] - pub json_pointer: Option, -} - -const fn _default_true() -> bool { - true -} - -/// OpenStackClient configuration -#[derive(Clone, Debug, Default, Deserialize)] -pub struct Config { - /// Map of views with the key being the resource key `.[/]`) - /// and the value being an `[OutputConfig]` - #[serde(default)] - pub views: HashMap, - /// List of CLI hints per resource - #[serde(default)] - pub command_hints: HashMap>>, - /// General hints for the CLI to be used independent on the command - #[serde(default)] - pub hints: Vec, - /// Enable/disable show the hints after successful command execution. Enabled by default - #[serde(default = "_default_true")] - pub enable_hints: bool, -} - -/// A builder to create a [`ConfigFile`] by specifying which files to load. -pub struct ConfigBuilder { - /// Config source files - sources: Vec, -} - -impl ConfigBuilder { - /// Add a source to the builder. This will directly parse the config and check if it is valid. - /// Values of sources added first will be overridden by later added sources, if the keys match. - /// In other words, the sources will be merged, with the later taking precedence over the - /// earlier ones. - pub fn add_source(mut self, source: impl AsRef) -> Result { - let config = match config::Config::builder() - .add_source(config::File::from(source.as_ref())) - .build() - { - Ok(config) => config, - Err(error) => { - return Err(ConfigBuilderError::FileParse { - source: Box::new(error), - builder: self, - path: source.as_ref().to_owned(), - }); - } - }; - - if let Err(error) = config.clone().try_deserialize::() { - return Err(ConfigBuilderError::ConfigDeserialize { - source: Box::new(error), - builder: self, - path: source.as_ref().to_owned(), - }); - } - - self.sources.push(config); - Ok(self) - } - - /// This will build a [`ConfigFile`] with the previously specified sources. Since - /// the sources have already been checked on errors, this will not fail. - pub fn build(self) -> Result { - let mut config = config::Config::builder(); - - for source in self.sources { - config = config.add_source(source); - } - - Ok(config.build()?.try_deserialize::()?) - } -} - -impl fmt::Debug for ConfigBuilderError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ConfigBuilderError::FileParse { source, path, .. } => f - .debug_struct("FileParse") - .field("source", source) - .field("path", path) - .finish_non_exhaustive(), - ConfigBuilderError::ConfigDeserialize { source, path, .. } => f - .debug_struct("ConfigDeserialize") - .field("source", source) - .field("path", path) - .finish_non_exhaustive(), - } - } -} - -impl Config { - /// Get the config builder - pub fn builder() -> Result { - let default_config: config::Config = config::Config::builder() - .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml)) - .build()?; - - Ok(ConfigBuilder { - sources: Vec::from([default_config]), - }) - } - - /// Instantiate new config reading default config updating it with local configuration - pub fn new() -> Result { - let default_config: config::Config = config::Config::builder() - .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml)) - .build()?; - - let config_dir = - get_config_dir().ok_or_else(|| ConfigError::ConfigDirCannotBeIdentified)?; - let mut builder = ConfigBuilder { - sources: Vec::from([default_config]), - }; - - let config_files = [ - ("config.yaml", config::FileFormat::Yaml), - ("views.yaml", config::FileFormat::Yaml), - ]; - let mut found_config = false; - for (file, _format) in &config_files { - if config_dir.join(file).exists() { - found_config = true; - - builder = match builder.add_source(config_dir.join(file)) { - Ok(builder) => builder, - Err(ConfigBuilderError::FileParse { source, .. }) => { - return Err(ConfigError::parse(*source)); - } - Err(ConfigBuilderError::ConfigDeserialize { - source, - builder, - path, - }) => { - error!( - "The file {path:?} could not be deserialized and will be ignored: {source}" - ); - builder - } - } - } - } - if !found_config { - tracing::error!("No configuration file found. Application may not behave as expected"); - } - - builder.build() - } -} - -fn get_config_dir() -> Option { - dirs::config_dir().map(|val| val.join("osc")) -} - -impl fmt::Display for Config { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::Builder; - - #[test] - fn test_parse_config() { - let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap(); - - const CONFIG_DATA: &str = r#" - views: - foo: - default_fields: ["a", "b", "c"] - bar: - fields: - - name: "b" - min_width: 1 - command_hints: - res: - cmd: - - hint1 - - hint2 - hints: - - hint1 - - hint2 - enable_hints: true - "#; - - write!(config_file, "{CONFIG_DATA}").unwrap(); - - let _cfg = Config::builder() - .unwrap() - .add_source(config_file.path()) - .unwrap() - .build(); - } -} +pub use openstack_cli_core::config::*; diff --git a/openstack_cli/src/error.rs b/openstack_cli/src/error.rs index 6e4361d1f..f9fe9c534 100644 --- a/openstack_cli/src/error.rs +++ b/openstack_cli/src/error.rs @@ -12,208 +12,4 @@ // // SPDX-License-Identifier: Apache-2.0 //! CLI Errors - -use indicatif; -use reqwest; -use thiserror::Error; - -/// CLI error type -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum OpenStackCliError { - /// Json serialization error. - #[error("failed to serialize data to json: {}", source)] - SerializeJson { - /// The source of the error. - #[from] - source: serde_json::Error, - }, - - /// Json deserialization error. - #[error( - "failed to deserialize data to json. Try using `-o json` to still see the data. \n\t{}", - data - )] - DeserializeJson { - /// The source of the error. - source: serde_json::Error, - /// Source json data - data: String, - }, - - /// OpenStack Auth error. - #[error("authentication error")] - Auth { - /// The source of the error. - source: openstack_sdk::OpenStackError, - }, - /// Re-scope error. - #[error("error changing scope to {:?}", scope)] - ReScope { - /// Target scope. - scope: openstack_sdk::auth::authtoken::AuthTokenScope, - /// The source of the error. - source: openstack_sdk::OpenStackError, - }, - - /// SDK error. - #[error(transparent)] - OpenStackSDK { - /// The source of the error. - #[from] - source: openstack_sdk::OpenStackError, - }, - /// OpenStack API error. - #[error(transparent)] - OpenStackApi { - /// The source of the error. - #[from] - source: openstack_sdk::api::ApiError, - }, - - /// Configuration error. - #[error(transparent)] - CliConfig { - /// The source of the error. - #[from] - source: crate::config::ConfigError, - }, - - /// Configuration error. - #[error(transparent)] - CloudConfig { - /// The source of the error. - #[from] - source: openstack_sdk::config::ConfigError, - }, - - /// OpenStack Service Catalog error. - #[error(transparent)] - OpenStackCatalog { - /// The source of the error. - #[from] - source: openstack_sdk::catalog::CatalogError, - }, - - /// No subcommands. - #[error("command has no subcommands")] - NoSubcommands, - - /// Resource is not found. - #[error("resource not found")] - ResourceNotFound, - - /// Resource identifier is not unique. - #[error("cannot find resource by identifier")] - IdNotUnique, - - /// Resource attribute is not present. - #[error("cannot find resource attribute {0}")] - ResourceAttributeMissing(String), - - /// Resource attribute is not string. - #[error("resource attribute {0} is not a string")] - ResourceAttributeNotString(String), - - /// IO error. - #[error("IO error: {}", source)] - IO { - /// The source of the error. - #[from] - source: std::io::Error, - }, - /// Reqwest library error. - #[error("reqwest error: {}", source)] - Reqwest { - /// The source of the error. - #[from] - source: reqwest::Error, - }, - /// Clap library error. - #[error("argument parsing error: {}", source)] - Clap { - /// The source of the error. - #[from] - source: clap::error::Error, - }, - /// Indicativ library error. - #[error("indicativ error: {}", source)] - Idinticatif { - /// The source of the error. - #[from] - source: indicatif::style::TemplateError, - }, - /// Endpoint builder error. - #[error("OpenStackSDK endpoint builder error: `{0}`")] - EndpointBuild(String), - - /// Connection error. - #[error("cloud connection `{0:?}` cannot be found")] - ConnectionNotFound(String), - - /// Invalid header name. - #[error("invalid header name `{}`", source)] - InvalidHeaderName { - /// The source of the error. - #[from] - source: http::header::InvalidHeaderName, - }, - - /// Invalid header value. - #[error("invalid header value `{}`", source)] - InvalidHeaderValue { - /// The source of the error. - #[from] - source: http::header::InvalidHeaderValue, - }, - - /// Invalid URL. - #[error("invalid url: {}", source)] - InvalidUri { - /// The source of the error. - #[from] - source: http::uri::InvalidUri, - }, - - /// User interaction using dialoguer crate failed - #[error("dialoguer error `{}`", source)] - DialoguerError { - /// The source of the error. - #[from] - source: dialoguer::Error, - }, - - /// Input parameters - #[error("input parameters error: {0}")] - InputParameters(String), - - /// Base64 decoding error. - #[error(transparent)] - Base64Decode(#[from] base64::DecodeError), - - /// Re-authorization not possible without active authentication. - #[error("valid authentication is missing to be able to rescope the session")] - MissingValidAuthenticationForRescope, - - /// URL parsing error - #[error(transparent)] - UrlParse { - /// The source of the error. - #[from] - source: url::ParseError, - }, - - /// Others. - #[error(transparent)] - Other(#[from] eyre::Report), -} - -impl OpenStackCliError { - /// Build a deserialization error - pub fn deserialize(error: serde_json::Error, data: String) -> Self { - Self::DeserializeJson { - source: error, - data, - } - } -} +pub use openstack_cli_core::error::OpenStackCliError; diff --git a/openstack_cli/src/lib.rs b/openstack_cli/src/lib.rs index c09bbde2f..cd3e9e924 100644 --- a/openstack_cli/src/lib.rs +++ b/openstack_cli/src/lib.rs @@ -32,6 +32,11 @@ use tracing::warn; use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::{Layer, prelude::*}; +use openstack_cli_core::error::OpenStackCliError; +use openstack_cli_core::{ + build_http_requests_timing_table, + tracing_stats::{HttpRequestStats, RequestTracingCollector}, +}; use openstack_sdk::{ AsyncOpenStack, auth::auth_helper::{Dialoguer, ExternalCmd, Noop}, @@ -55,22 +60,13 @@ pub mod network; pub mod object_store; pub mod placement; -mod tracing_stats; - pub mod cli; pub mod error; pub mod output; -use crate::error::OpenStackCliError; -use crate::tracing_stats::{HttpRequestStats, RequestTracingCollector}; - pub use cli::Cli; use cli::TopLevelCommands; -use comfy_table::ContentArrangement; -use comfy_table::Table; -use comfy_table::presets::UTF8_FULL_CONDENSED; - /// Entry point for the CLI wrapper pub async fn entry_point() -> Result<(), OpenStackCliError> { let cli = Cli::parse(); @@ -240,20 +236,3 @@ pub async fn entry_point() -> Result<(), OpenStackCliError> { res } - -/// Build a table of HTTP request timings -fn build_http_requests_timing_table(data: &HttpRequestStats) -> Table { - let mut table = Table::new(); - table - .load_preset(UTF8_FULL_CONDENSED) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(Vec::from(["Url", "Method", "Duration (ms)"])); - - let mut total_http_duration: u128 = 0; - for rec in data.summarize_by_url_method() { - total_http_duration += rec.2; - table.add_row(vec![rec.0, rec.1, rec.2.to_string()]); - } - table.add_row(vec!["Total", "", &total_http_duration.to_string()]); - table -} diff --git a/openstack_cli/src/output.rs b/openstack_cli/src/output.rs index 29f78f25d..65ddcfae7 100644 --- a/openstack_cli/src/output.rs +++ b/openstack_cli/src/output.rs @@ -14,871 +14,4 @@ //! Output processing module -use comfy_table::{ - Cell, Color, ColumnConstraint, ContentArrangement, Table, Width, presets::UTF8_FULL_CONDENSED, -}; -use itertools::Itertools; -use openstack_sdk::types::EntryStatus; -use owo_colors::{OwoColorize, Stream::Stderr}; -use rand::prelude::*; -use serde::de::DeserializeOwned; -use std::collections::BTreeSet; -use std::io::{self, Write}; - -use crate::OpenStackCliError; -use crate::cli::{Cli, OutputFormat, TableArrangement}; -use crate::config::ViewConfig; -use structable::{OutputConfig, StructTable, StructTableOptions}; - -/// Output Processor -#[derive(Default, Clone)] -pub(crate) struct OutputProcessor { - /// Resource output configuration - pub(crate) config: Option, - /// Whether output is for human or for machine - pub(crate) target: OutputFor, - /// Table arrangement - pub(crate) table_arrangement: TableArrangement, - /// Fields requested - pub(crate) fields: BTreeSet, - /// Wide mode - pub(crate) wide: bool, - /// Pretty mode - pub(crate) pretty: bool, - /// Command hints - hints: Option>, -} - -impl StructTableOptions for OutputProcessor { - fn wide_mode(&self) -> bool { - self.wide - || self - .config - .as_ref() - .is_some_and(|cfg| cfg.wide.is_some_and(|w| w)) - } - - fn pretty_mode(&self) -> bool { - self.pretty - } - - fn should_return_field>(&self, field: S, is_wide_field: bool) -> bool { - let is_requested = self - .fields - .iter() - .any(|x| x.to_lowercase() == field.as_ref().to_lowercase()) - || (self.fields.is_empty() - && self - .config - .as_ref() - .map(|cfg| { - cfg.default_fields - .iter() - .any(|x| x.to_lowercase() == field.as_ref().to_lowercase()) - }) - .is_some_and(|x| x)); - - if !is_wide_field { - // Return non wide field when no field filters passed or explicitly requested the field - is_requested - || (self.fields.is_empty() - && self - .config - .as_ref() - .is_none_or(|cfg| cfg.default_fields.is_empty())) - } else { - // The wide field is returned in wide mode when no filters passed or explicitly - // requested the field - (self.fields.is_empty() && self.wide_mode()) || is_requested - } - } - - fn field_data_json_pointer>(&self, field: S) -> Option { - if !self.wide_mode() { - self.config.as_ref().and_then(|config| { - config - .fields - .iter() - .find(|x| x.name.to_lowercase() == field.as_ref().to_lowercase()) - .and_then(|field_config| field_config.json_pointer.clone()) - }) - } else { - None - } - } -} - -/// Output target (human or machine) enum -#[derive(Default, Clone)] -pub(crate) enum OutputFor { - #[default] - Human, - Machine, -} - -impl From for ContentArrangement { - fn from(value: TableArrangement) -> Self { - match value { - TableArrangement::Dynamic => Self::Dynamic, - TableArrangement::DynamicFullWidth => Self::DynamicFullWidth, - TableArrangement::Disabled => Self::Disabled, - } - } -} - -impl OutputProcessor { - /// Get OutputConfig from passed arguments - pub fn from_args, A: AsRef>( - args: &Cli, - resource_key: Option, - action: Option, - ) -> Self { - let target = match args.global_opts.output.output { - None => OutputFor::Human, - Some(OutputFormat::Wide) => OutputFor::Human, - _ => OutputFor::Machine, - }; - let mut hints: Vec = args.config.hints.clone(); - - if let (Some(resource_key), Some(action)) = (&resource_key, &action) { - args.config - .command_hints - .get(resource_key.as_ref()) - .and_then(|cmd_hints| { - cmd_hints.get(action.as_ref()).map(|val| { - hints.extend(val.clone()); - }) - }); - } - - Self { - config: resource_key - .as_ref() - .and_then(|val| args.config.views.get(val.as_ref()).cloned()), - target, - table_arrangement: args.global_opts.output.table_arrangement, - fields: BTreeSet::from_iter(args.global_opts.output.fields.iter().cloned()), - wide: matches!(args.global_opts.output.output, Some(OutputFormat::Wide)), - pretty: args.global_opts.output.pretty, - hints: Some(hints), - } - } - - /// Validate command arguments with respect to the output producing - pub fn validate_args(&self, _args: &Cli) -> Result<(), OpenStackCliError> { - Ok(()) - } - - /// Re-sort table according to the configuration and determine column constraints - fn prepare_table( - &self, - headers: Vec, - data: Vec>, - ) -> (Vec, Vec>, Vec>) { - let mut headers = headers; - let mut rows = data; - let mut column_constrains: Vec> = vec![None; headers.len()]; - - if let Some(cfg) = &self.config { - // Offset from the current iteration pointer - if headers.len() > 1 { - let mut idx_offset: usize = 0; - for (default_idx, field) in cfg.default_fields.iter().unique().enumerate() { - if let Some(curr_idx) = headers - .iter() - .position(|x| x.to_lowercase() == field.to_lowercase()) - { - // Swap headers between current and should pos - if default_idx - idx_offset < headers.len() { - headers.swap(default_idx - idx_offset, curr_idx); - for row in rows.iter_mut() { - // Swap also data columns - row.swap(default_idx - idx_offset, curr_idx); - } - } - } else { - // This column is not found in the data. Perhars structable returned some - // other name. Move the column to the very end - if default_idx - idx_offset < headers.len() { - let curr_hdr = headers.remove(default_idx - idx_offset); - headers.push(curr_hdr); - for row in rows.iter_mut() { - let curr_cell = row.remove(default_idx - idx_offset); - row.push(curr_cell); - } - // Some unmatched field moved to the end. Our "current" index should respect - // the offset - idx_offset += 1; - } - } - } - } - // Find field configuration - for (idx, field) in headers.iter().enumerate() { - if let Some(field_config) = cfg - .fields - .iter() - .find(|x| x.name.to_lowercase() == field.to_lowercase()) - { - let constraint = match ( - field_config.width, - field_config.min_width, - field_config.max_width, - ) { - (Some(fixed), _, _) => { - Some(ColumnConstraint::Absolute(Width::Fixed(fixed as u16))) - } - (None, Some(lower), Some(upper)) => Some(ColumnConstraint::Boundaries { - lower: Width::Fixed(lower as u16), - upper: Width::Fixed(upper as u16), - }), - (None, Some(lower), None) => { - Some(ColumnConstraint::LowerBoundary(Width::Fixed(lower as u16))) - } - (None, None, Some(upper)) => { - Some(ColumnConstraint::UpperBoundary(Width::Fixed(upper as u16))) - } - _ => None, - }; - column_constrains[idx] = constraint; - } - } - } - (headers, rows, column_constrains) - } - - /// Output List of resources - pub fn output_list(&self, data: Vec) -> Result<(), OpenStackCliError> - where - T: StructTable, - T: DeserializeOwned, - for<'a> &'a T: StructTable, - { - match self.target { - OutputFor::Human => { - let table: Vec = serde_json::from_value(serde_json::Value::Array(data.clone())) - .map_err(|err| { - OpenStackCliError::deserialize( - err, - serde_json::to_string(&serde_json::Value::Array( - data.into_iter() - .filter(|item| { - serde_json::from_value::(item.clone()).is_err() - }) - .collect(), - )) - .unwrap_or_else(|v| format!("{v:?}")), - ) - })?; - - let data = structable::build_list_table(table.iter(), self); - let (headers, table_rows, table_constraints) = self.prepare_table(data.0, data.1); - let mut statuses: Vec> = - table.iter().map(|item| item.status()).collect(); - - // Ensure we have as many statuses as rows to zip them properly - statuses.resize_with(table_rows.len(), Default::default); - - let rows = table_rows - .iter() - .zip(statuses.iter()) - .map(|(data, status)| { - let color = match EntryStatus::from(status.as_ref()) { - EntryStatus::Error => Some(Color::Red), - EntryStatus::Pending => Some(Color::Yellow), - EntryStatus::Inactive => Some(Color::Cyan), - _ => None, - }; - data.iter().map(move |cell| { - if let Some(color) = color { - Cell::new(cell).fg(color) - } else { - Cell::new(cell) - } - }) - }); - let mut table = Table::new(); - table - .load_preset(UTF8_FULL_CONDENSED) - .set_content_arrangement(ContentArrangement::from(self.table_arrangement)) - .set_header(headers) - .add_rows(rows); - - for (idx, constraint) in table_constraints.iter().enumerate() { - if let Some(constraint) = constraint - && let Some(col) = table.column_mut(idx) - { - col.set_constraint(*constraint); - } - } - - println!("{table}"); - Ok(()) - } - _ => self.output_machine(serde_json::from_value(serde_json::Value::Array(data))?), - } - } - - /// Output List of resources - pub fn output_single(&self, data: serde_json::Value) -> Result<(), OpenStackCliError> - where - T: StructTable, - T: DeserializeOwned, - { - match self.target { - OutputFor::Human => { - let table: T = serde_json::from_value(data.clone()).map_err(|err| { - OpenStackCliError::deserialize( - err, - serde_json::to_string(&data.clone()).unwrap_or_default(), - ) - })?; - - self.output_human(&table) - } - _ => self.output_machine(serde_json::from_value(data)?), - } - } - - /// Produce output for humans (table) for a single resource - pub fn output_human(&self, data: &T) -> Result<(), OpenStackCliError> { - let (headers, table_rows) = structable::build_table(data, &OutputConfig::default()); - - let mut table = Table::new(); - table - .load_preset(UTF8_FULL_CONDENSED) - .set_content_arrangement(ContentArrangement::from(self.table_arrangement)) - .set_header(headers) - .add_rows(table_rows); - println!("{table}"); - Ok(()) - } - - /// Produce output for machine - /// Return machine readable output with the API side names - pub fn output_machine(&self, data: serde_json::Value) -> Result<(), OpenStackCliError> { - if self.pretty { - serde_json::to_writer_pretty(io::stdout(), &data)?; - } else { - serde_json::to_writer(io::stdout(), &data)?; - } - io::stdout().write_all(b"\n")?; - Ok(()) - } - - /// Show hints - pub fn show_command_hint(&self) -> Result<(), OpenStackCliError> { - if rand::random_bool(1.0 / 2.0) { - self.hints.as_ref().and_then(|hints| { - hints.choose(&mut rand::rng()).map(|hint| { - eprintln!( - "\n{} {}", - "Hint:".if_supports_color(Stderr, |text| text.green()), - hint.if_supports_color(Stderr, |text| text.blue()) - ); - }) - }); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::FieldConfig; - use clap::Parser; - use std::io::Write; - use tempfile::Builder; - - #[test] - fn test_wide_mode() { - assert!(!OutputProcessor::default().wide_mode()); - assert!( - OutputProcessor { - wide: true, - ..Default::default() - } - .wide_mode() - ); - assert!( - OutputProcessor { - config: Some(ViewConfig { - wide: Some(true), - ..Default::default() - }), - ..Default::default() - } - .wide_mode() - ); - } - - #[test] - fn test_field_returned_no_selection() { - let out = OutputProcessor::default(); - - assert!( - out.should_return_field("dummy", false), - "default field returned in non-wide mode with empty fields selector" - ); - assert!( - !out.should_return_field("dummy", true), - "wide field not returned in non-wide mode with empty fields selector" - ); - - let out = OutputProcessor { - wide: true, - ..Default::default() - }; - - assert!( - out.should_return_field("dummy", false), - "default field returned in wide mode with empty fields selector" - ); - assert!( - out.should_return_field("dummy", true), - "wide field returned in wide mode with empty fields selector" - ); - } - - #[test] - fn test_field_returned_selection_no_config() { - let out = OutputProcessor { - fields: BTreeSet::from(["foo".to_string()]), - ..Default::default() - }; - - assert!( - !out.should_return_field("dummy", false), - "default field not returned in non-wide mode with mismatching fields selector" - ); - assert!( - !out.should_return_field("dummy", true), - "wide field not returned in non-wide mode with mismatching fields selector" - ); - assert!( - out.should_return_field("foo", false), - "default field returned in non-wide mode with matching fields selector" - ); - assert!( - out.should_return_field("foo", true), - "wide field returned in non-wide mode with matching fields selector" - ); - - let out = OutputProcessor { - fields: BTreeSet::from(["foo".to_string()]), - wide: true, - ..Default::default() - }; - - assert!( - !out.should_return_field("dummy", false), - "default field not returned in wide mode with mismatching fields selector" - ); - assert!( - !out.should_return_field("dummy", true), - "wide field not returned in wide mode with mismatching fields selector" - ); - } - - #[test] - fn test_field_returned_selection_empty_config() { - let out = OutputProcessor { - config: Some(ViewConfig::default()), - target: OutputFor::Human, - table_arrangement: TableArrangement::Disabled, - fields: BTreeSet::new(), - wide: false, - pretty: false, - ..Default::default() - }; - - assert!( - out.should_return_field("dummy", false), - "default field returned in non-wide mode with mismatching fields selector and empty config" - ); - assert!( - !out.should_return_field("dummy", true), - "wide field not returned in non-wide mode with mismatching fields selector and empty config" - ); - } - - #[test] - fn test_field_returned_selection_with_config_with_filters() { - let out = OutputProcessor { - config: Some(ViewConfig { - default_fields: vec!["foo".to_string()], - ..Default::default() - }), - fields: BTreeSet::from(["bar".to_string()]), - ..Default::default() - }; - - assert!( - !out.should_return_field("dummy", false), - "default field not returned in non-wide mode with mismatching fields selector" - ); - assert!( - !out.should_return_field("dummy", true), - "wide field not returned in non-wide mode with mismatching fields selector" - ); - assert!( - !out.should_return_field("foo", false), - "default field not returned in non-wide mode with mismatching fields selector" - ); - assert!( - !out.should_return_field("foo", true), - "wide field not returned in non-wide mode with mismatching fields selector" - ); - assert!( - out.should_return_field("bar", false), - "default field returned in non-wide mode with matching fields selector" - ); - assert!( - out.should_return_field("bar", true), - "wide field returned in non-wide mode with matching fields selector" - ); - - let out = OutputProcessor { - config: Some(ViewConfig { - default_fields: vec!["foo".to_string()], - ..Default::default() - }), - fields: BTreeSet::from(["bar".to_string()]), - wide: true, - ..Default::default() - }; - - assert!( - !out.should_return_field("dummy", false), - "default field not returned in wide mode with mismatching fields selector" - ); - assert!( - !out.should_return_field("dummy", true), - "wide field not returned in wide mode with mismatching fields selector" - ); - assert!( - !out.should_return_field("foo", false), - "config field not returned in wide mode with mismatching fields selector" - ); - assert!( - !out.should_return_field("foo", true), - "wide config field not returned in wide mode with mismatching fields selector" - ); - assert!( - out.should_return_field("bar", false), - "default field returned in wide mode with matching fields selector" - ); - assert!( - out.should_return_field("bar", true), - "wide field returned in wide mode with matching fields selector" - ); - } - - #[test] - fn test_field_returned_selection_with_config_no_filters() { - let out = OutputProcessor { - config: Some(ViewConfig { - default_fields: vec!["foo".to_string()], - ..Default::default() - }), - ..Default::default() - }; - - assert!( - !out.should_return_field("dummy", false), - "default field not returned in non-wide mode with empty fields selector and not in config" - ); - assert!( - out.should_return_field("foo", false), - "default field not returned in non-wide mode with empty fields selector, but in config" - ); - assert!( - !out.should_return_field("dummy", true), - "wide field not returned in non-wide mode with empty fields selector and not in config" - ); - assert!( - out.should_return_field("foo", true), - "wide field returned in non-wide mode with empty fields selector, but in config" - ); - - let out = OutputProcessor { - config: Some(ViewConfig { - default_fields: vec!["foo".to_string()], - ..Default::default() - }), - wide: true, - ..Default::default() - }; - - assert!( - !out.should_return_field("dummy", false), - "default field not returned in wide mode with empty fields selector and not in config" - ); - assert!( - out.should_return_field("foo", false), - "default field returned in wide mode with empty fields selector, but in config" - ); - assert!( - out.should_return_field("dummy", true), - "wide field returned in wide mode with empty fields selector and not in config" - ); - assert!( - out.should_return_field("foo", true), - "wide field returned in wide mode with empty fields selector, but in config" - ); - } - - #[test] - fn test_prepare_table() { - let out = OutputProcessor { - config: Some(ViewConfig { - default_fields: vec![ - "foo".to_string(), - "bar".to_string(), - "baz".to_string(), - "dummy".to_string(), - ], - fields: vec![FieldConfig { - name: "bar".to_string(), - min_width: Some(15), - ..Default::default() - }], - ..Default::default() - }), - ..Default::default() - }; - let (hdr, rows, constraints) = out.prepare_table( - vec![ - "dummy".to_string(), - "bar".to_string(), - "foo".to_string(), - "baz".to_string(), - ], - vec![ - vec![ - "11".to_string(), - "12".to_string(), - "13".to_string(), - "14".to_string(), - ], - vec![ - "21".to_string(), - "22".to_string(), - "23".to_string(), - "24".to_string(), - ], - ], - ); - assert_eq!( - vec![ - "foo".to_string(), - "bar".to_string(), - "baz".to_string(), - "dummy".to_string() - ], - hdr, - "headers in the correct sort order" - ); - assert_eq!( - vec![ - vec![ - "13".to_string(), - "12".to_string(), - "14".to_string(), - "11".to_string(), - ], - vec![ - "23".to_string(), - "22".to_string(), - "24".to_string(), - "21".to_string(), - ], - ], - rows, - "row columns sorted properly" - ); - assert_eq![ - vec![ - None, - Some(ColumnConstraint::LowerBoundary(Width::Fixed(15))), - None, - None - ], - constraints - ]; - - let (hdr, rows, _constraints) = out.prepare_table( - vec![ - "dummy".to_string(), - "bar2".to_string(), - "foo".to_string(), - "baz2".to_string(), - ], - vec![ - vec![ - "11".to_string(), - "12".to_string(), - "13".to_string(), - "14".to_string(), - ], - vec![ - "21".to_string(), - "22".to_string(), - "23".to_string(), - "24".to_string(), - ], - ], - ); - assert_eq!( - vec![ - "foo".to_string(), - "dummy".to_string(), - "bar2".to_string(), - "baz2".to_string(), - ], - hdr, - "headers with unknown fields in the correct sort order" - ); - assert_eq!( - vec![ - vec![ - "13".to_string(), - "11".to_string(), - "12".to_string(), - "14".to_string(), - ], - vec![ - "23".to_string(), - "21".to_string(), - "22".to_string(), - "24".to_string(), - ], - ], - rows, - "row columns sorted properly" - ); - } - - #[test] - fn test_prepare_table_duplicated_values() { - let out = OutputProcessor { - config: Some(ViewConfig { - default_fields: vec![ - "foo".to_string(), - "bar".to_string(), - "foo".to_string(), - "baz".to_string(), - ], - ..Default::default() - }), - ..Default::default() - }; - let (hdr, rows, _constraints) = out.prepare_table( - vec!["bar".to_string(), "foo".to_string(), "baz".to_string()], - vec![ - vec!["11".to_string(), "12".to_string(), "13".to_string()], - vec!["21".to_string(), "22".to_string(), "23".to_string()], - ], - ); - assert_eq!( - vec!["foo".to_string(), "bar".to_string(), "baz".to_string(),], - hdr, - "headers in the correct sort order" - ); - assert_eq!( - vec![ - vec!["12".to_string(), "11".to_string(), "13".to_string(),], - vec!["22".to_string(), "21".to_string(), "23".to_string(),], - ], - rows, - "row columns sorted properly" - ); - } - - #[test] - fn test_prepare_table_missing_default_fields() { - let out = OutputProcessor { - config: Some(ViewConfig { - default_fields: vec![ - "foo".to_string(), - "bar1".to_string(), - "foo1".to_string(), - "baz1".to_string(), - ], - ..Default::default() - }), - ..Default::default() - }; - let (hdr, rows, _constraints) = out.prepare_table( - vec!["bar".to_string(), "foo".to_string(), "baz".to_string()], - vec![ - vec!["11".to_string(), "12".to_string(), "13".to_string()], - vec!["21".to_string(), "22".to_string(), "23".to_string()], - ], - ); - assert_eq!( - vec!["foo".to_string(), "baz".to_string(), "bar".to_string(),], - hdr, - "headers in the correct sort order" - ); - assert_eq!( - vec![ - vec!["12".to_string(), "13".to_string(), "11".to_string(),], - vec!["22".to_string(), "23".to_string(), "21".to_string(),], - ], - rows, - "row columns sorted properly" - ); - } - - #[test] - fn test_output_processor_from_args_hints() { - let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap(); - - const CONFIG_DATA: &str = r#" - views: - foo: - default_fields: ["a", "b", "c"] - bar: - fields: - - name: "b" - min_width: 1 - command_hints: - res: - cmd: - - cmd_hint1 - - cmd_hint2 - cmd2: [cmd2_hint1] - res2: - cmd: [] - hints: - - hint1 - - hint2 - enable_hints: true - "#; - - write!(config_file, "{CONFIG_DATA}").unwrap(); - - let op = OutputProcessor::from_args( - &Cli::parse_from([ - "osc", - "--cli-config", - &config_file.path().as_os_str().to_string_lossy(), - "auth", - "show", - ]), - Some("res"), - Some("cmd"), - ); - assert_eq!( - Some(vec![ - "hint1".to_string(), - "hint2".to_string(), - "cmd_hint1".to_string(), - "cmd_hint2".to_string() - ]), - op.hints - ); - } -} +pub use openstack_cli_core::output::*;