ocpi-tariffs-cli 0.46.1

CLI application for OCPI tariff calculation
Documentation
use std::{fmt, io::IsTerminal as _, path::PathBuf};

use clap::{Args, Parser};
use console::style;
use ocpi_tariffs::{
    json::{self, PathRef},
    tariff, warning,
};

use crate::{
    load_object_file, load_object_from_stdin,
    opts::{FilePath, Version},
    Error, ObjectKind,
};

const STDIN_NAME: &str = "<stdin>";

#[derive(Parser)]
pub struct Command {
    #[command(flatten)]
    args: Arguments,
}

#[derive(Args)]
struct Arguments {
    /// The OCPI version that should be used for the input structures.
    #[arg(short = 'o', long, value_enum)]
    ocpi_version: Version,

    /// Pretty print the tariff JSON for human consumption.
    #[arg(long)]
    pretty: bool,

    /// A path to the tariff structure in JSON format.
    #[arg(short = 'f', long)]
    file: Option<PathBuf>,
}

impl Command {
    pub fn run(self) -> Result<(), Error> {
        let Self {
            args:
                Arguments {
                    ocpi_version,
                    pretty,
                    file: tariff,
                },
        } = self;

        let ocpi_version = ocpi_version.into();
        let tariff_file_path = tariff.as_deref().map(FilePath::from_path).transpose()?;
        let tariff_name = tariff_file_path
            .as_ref()
            .map(FilePath::file_name)
            .unwrap_or_else(|| STDIN_NAME);

        eprintln!(
            "{} tariff `{}` as version `{}`",
            style("Linting").green().bold(),
            style(&tariff_name).blue(),
            style(ocpi_version).blue()
        );

        let tariff_json = if let Some(path) = tariff_file_path.as_ref() {
            load_object_file(path, ObjectKind::Tariff)?
        } else if std::io::stdin().is_terminal() {
            return Err(Error::TariffRequired);
        } else {
            load_object_from_stdin(ObjectKind::Tariff)?
        };

        let report = tariff::parse_with_version(&tariff_json, ocpi_version)?;

        let tariff::ParseReport {
            tariff,
            unexpected_fields,
        } = report;

        if pretty {
            eprintln!("{}", style("# Source Tariff:").green());
            eprintln!("{}\n", json::write::Pretty::new(tariff.as_element()));
        }

        let report = tariff::lint(&tariff);

        eprintln!("Completed {} tariff\n", style("linting").green().bold());

        print_unexpected_fields(unexpected_fields);
        print_report(tariff_file_path.as_ref(), &tariff_json, report);

        Ok(())
    }
}

/// Print the lint report cross referencing the warning with the source JSON so the report can
/// display the source JSON for each [`json::Element`] that has warnings.
fn print_report(
    file_path: Option<&FilePath>,
    tariff_json: &str,
    report: ocpi_tariffs::lint::tariff::Report,
) {
    const INDENT: &str = "  ";

    let tariff_name = file_path
        .as_ref()
        .map(|path| path.dir_and_name())
        .unwrap_or_else(|| STDIN_NAME.into());

    let ocpi_tariffs::lint::tariff::Report { warnings } = report;

    if !warnings.is_empty() {
        eprintln!("{}\n", style("# Warnings").yellow());
    }

    let list_item = style("*").yellow();
    let block = style("|").yellow();
    let report_iter = warning::SourceReportIter::new(tariff_json, warnings.iter());

    for warning::ElementReport {
        element_path,
        warnings,
        json,
        location,
    } in report_iter
    {
        eprintln!(
            "{} Warnings for `{}`\n",
            style("##").yellow(),
            style(element_path).yellow()
        );
        eprintln!(
            "{INDENT} {} {}:{}\n{INDENT} {}",
            style("|").green(),
            style(&tariff_name).green(),
            style(location).green(),
            style("|").green(),
        );

        let val_lines = json.lines().collect::<Vec<_>>();

        // Calculate the largest amount of whitespace we can trim from a multiline value.
        // `trim_ws` will be `None` if the value is not multiline.
        let trim_ws = val_lines.get(1..).and_then(|lines| {
            lines
                .iter()
                .map(|s| {
                    let trimmed = s.trim_start();
                    s.len().saturating_sub(trimmed.len())
                })
                .min()
        });

        if let Some(trim_ws) = trim_ws {
            let mut val_lines = val_lines.into_iter();

            if let Some(first_line) = val_lines.next() {
                eprint!("{INDENT} {block} {}", style(first_line).yellow());

                for line in val_lines {
                    let (_, line) = line.split_at(trim_ws);
                    eprint!("\n{INDENT} {block} {}", style(line).yellow());
                }

                eprintln!();
            }
        } else {
            eprintln!("{INDENT} {block} {}", style(json).yellow());
        }

        eprintln!();

        for warning in warnings {
            eprintln!("{INDENT} {list_item} {warning}");
        }

        eprintln!();
    }
}

/// Print the unknown fields of the tariff to `stderr`.
fn print_unexpected_fields(fields: json::UnexpectedFields<'_>) {
    if !fields.is_empty() {
        eprintln!("{}\n", style("# Unexpected fields").yellow());
    }

    let list_marker = style("*").yellow();

    for path in fields {
        eprintln!("  {list_marker} {}", PathDisplay(path));
    }

    eprintln!();
}

/// Display a `json::PathRef` with ANSI colors.
struct PathDisplay<'buf>(PathRef<'buf>);

impl fmt::Display for PathDisplay<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let components = self.0.components();
        let mut components = components.collect::<Vec<_>>().into_iter().rev();

        let dot = style('.').yellow();

        if let Some(comp) = components.next() {
            match comp.kind() {
                json::PathNodeKind::Root | json::PathNodeKind::Array => write!(f, "{comp}")?,
                json::PathNodeKind::Object => write!(f, "{}", style(comp).green())?,
            }
        }

        for comp in components {
            match comp.kind() {
                json::PathNodeKind::Root | json::PathNodeKind::Array => write!(f, "{dot}{comp}")?,
                json::PathNodeKind::Object => write!(f, "{dot}{}", style(comp).green())?,
            }
        }

        Ok(())
    }
}