avina-cli 2.2.0

Rust CLI client for the LRZ-specific features of the Openstack-based LRZ Compute Cloud.
use std::{
    borrow::Cow,
    fmt::Display,
    io::{Write, stdin, stdout},
};

use anyhow::{Context, anyhow};
use chrono::Datelike;
use clap::{ValueEnum, builder::PossibleValue};
use serde::Serialize;
use tabled::{
    Table, Tabled, builder::Builder, grid::records::vec_records::Text,
    settings::Style,
};

#[derive(Debug, Clone)]
pub(crate) enum TableFormat {
    Empty,
    Blank,
    Ascii,
    Psql,
    Markdown,
    Modern,
    Sharp,
    Rounded,
    ModernRounded,
    Extended,
    Dots,
    ReStructuredText,
    AsciiRounded,
}
pub(crate) use TableFormat::*;

pub(crate) fn apply_table_style(table: &mut Table, format: TableFormat) {
    match format {
        Empty => table.with(Style::empty()),
        Blank => table.with(Style::blank()),
        Ascii => table.with(Style::ascii()),
        Psql => table.with(Style::psql()),
        Markdown => table.with(Style::markdown()),
        Modern => table.with(Style::modern()),
        Sharp => table.with(Style::sharp()),
        Rounded => table.with(Style::rounded()),
        ModernRounded => table.with(Style::modern_rounded()),
        Extended => table.with(Style::extended()),
        Dots => table.with(Style::dots()),
        ReStructuredText => table.with(Style::re_structured_text()),
        AsciiRounded => table.with(Style::ascii_rounded()),
    };
}

#[allow(dead_code)]
pub(crate) fn print_json<T>(object: T) -> Result<(), Box<dyn std::error::Error>>
where
    T: Serialize,
{
    println!("{}", serde_json::to_string(&object)?);
    Ok(())
}

#[allow(dead_code)]
pub(crate) fn print_single_object<T>(
    object: T,
    format: Format,
) -> Result<(), Box<dyn std::error::Error>>
where
    T: Serialize + Tabled,
{
    match format {
        Format::Json => println!("{}", serde_json::to_string(&object)?),
        Format::Table(format) => {
            let mut keys = vec![Cow::Owned("key".to_owned())];
            keys.extend(T::headers());
            let mut values = vec![Cow::Owned("value".to_owned())];
            values.extend(object.fields());
            let data = vec![keys, values];
            let mut table = Builder::from_iter(data)
                .index()
                .column(0)
                .transpose()
                .build();
            apply_table_style(&mut table, format);
            let output = table.to_string();
            println!("{output}");
        }
    }
    Ok(())
}

#[allow(dead_code)]
pub(crate) fn print_object_list<T>(
    objects: Vec<T>,
    format: Format,
) -> Result<(), Box<dyn std::error::Error>>
where
    T: Serialize + Tabled,
{
    match format {
        Format::Json => println!("{}", serde_json::to_string(&objects)?),
        Format::Table(format) => {
            let mut table = Table::new(objects);
            apply_table_style(&mut table, format);
            let output = table.to_string();
            println!("{output}");
        }
    }
    Ok(())
}

#[allow(dead_code)]
pub(crate) fn print_hashmap<K, V>(
    hashmap: std::collections::HashMap<K, V>,
    key_name: &str,
    value_name: &str,
    format: Format,
) -> Result<(), Box<dyn std::error::Error>>
where
    K: Serialize + Display,
    V: Serialize + Display,
    (K, V): Tabled,
{
    match format {
        Format::Json => println!("{}", serde_json::to_string(&hashmap)?),
        Format::Table(format) => {
            let mut table = Table::new(hashmap);
            table.get_records_mut()[0] = vec![
                Text::new(key_name.to_owned()),
                Text::new(value_name.to_owned()),
            ];
            apply_table_style(&mut table, format);
            let output = table.to_string();
            println!("{output}");
        }
    }
    Ok(())
}

#[derive(Debug, Clone)]
pub(crate) enum Format {
    Json,
    Table(TableFormat),
}
pub(crate) use Format::*;

impl ValueEnum for Format {
    fn value_variants<'a>() -> &'a [Self] {
        &[
            Json,
            Table(Empty),
            Table(Blank),
            Table(Ascii),
            Table(Psql),
            Table(Markdown),
            Table(Modern),
            Table(Sharp),
            Table(Rounded),
            Table(ModernRounded),
            Table(Extended),
            Table(Dots),
            Table(ReStructuredText),
            Table(AsciiRounded),
        ]
    }

    fn to_possible_value(&self) -> Option<PossibleValue> {
        match self {
            Format::Json => Some(PossibleValue::new("json")),
            Format::Table(format) => match format {
                Empty => Some(PossibleValue::new("empty")),
                Blank => Some(PossibleValue::new("blank")),
                Ascii => Some(PossibleValue::new("ascii")),
                Psql => Some(PossibleValue::new("psql")),
                Markdown => Some(PossibleValue::new("markdown")),
                Modern => Some(PossibleValue::new("modern")),
                Sharp => Some(PossibleValue::new("sharp")),
                Rounded => Some(PossibleValue::new("rounded")),
                ModernRounded => Some(PossibleValue::new("modern-rounded")),
                Extended => Some(PossibleValue::new("extended")),
                Dots => Some(PossibleValue::new("dots")),
                ReStructuredText => {
                    Some(PossibleValue::new("re-structured-text"))
                }
                AsciiRounded => Some(PossibleValue::new("ascii-rounded")),
            },
        }
    }

    fn from_str(value: &str, _ignore_case: bool) -> Result<Self, String> {
        match value {
            "json" => Ok(Json),
            "empty" => Ok(Table(Empty)),
            "blank" => Ok(Table(Blank)),
            "ascii" => Ok(Table(Ascii)),
            "psql" => Ok(Table(Psql)),
            "markdown" => Ok(Table(Markdown)),
            "modern" => Ok(Table(Modern)),
            "sharp" => Ok(Table(Sharp)),
            "rounded" => Ok(Table(Rounded)),
            "modern-rounded" => Ok(Table(ModernRounded)),
            "extended" => Ok(Table(Extended)),
            "dots" => Ok(Table(Dots)),
            "re-structured-text" => Ok(Table(ReStructuredText)),
            "ascii-rounded" => Ok(Table(AsciiRounded)),
            _ => Err(format!("Invalid format: {value}")),
        }
    }
}

pub(crate) trait Execute {
    async fn execute(
        &self,
        api: avina::Api,
        format: Format,
    ) -> Result<(), Box<dyn std::error::Error>>;
}

pub(crate) fn ask_for_confirmation() -> Result<(), anyhow::Error> {
    let confirmation = "Yes, I really really mean it!".to_owned();
    print!("This is dangerous. Are you sure? Type: {confirmation}\n> ");
    let _ = stdout().flush();
    let mut input = String::new();
    stdin()
        .read_line(&mut input)
        .context("Confirmation input was not a valid string.")?;
    if let Some('\n') = input.chars().next_back() {
        input.pop();
    }
    if let Some('\n') = input.chars().next_back() {
        input.pop();
    }
    if input != confirmation {
        return Err(anyhow!("No confirmation provided! Aborting!"));
    }
    Ok(())
}

#[allow(dead_code)]
pub(crate) fn find_id(
    _api: &avina::Api,
    name_or_id: &str,
) -> Result<u32, anyhow::Error> {
    if let Ok(id) = name_or_id.parse::<u32>() {
        return Ok(id);
    }
    Err(anyhow!(
        "This is not a valid integer ID: {name_or_id}. Name or OpenStack \
        UUIDv4 lookup is unavailable as the respective module is disabled \
        in this client."
    ))
}

pub(crate) fn current_year() -> i32 {
    chrono::Utc::now().year()
}