tomq 0.1.2

jq, but from TOML
use clap::builder::TypedValueParser;
use clap::{Arg, Command, Error, ErrorKind, Parser, PossibleValue};
use std::ffi::OsStr;
use std::str::FromStr;

#[derive(Parser, Clone)]
pub(crate) struct TomlOpts {
    /// jq filter to pass to `jq` binary.
    ///
    /// Example: `'.dependencies'`.
    #[clap(index = 1)]
    pub(crate) jq_filter: Option<String>,

    /// Special operation mode, takes JSON input and outputs TOML.
    #[clap(short = 'T', long = "tomlit")]
    pub(crate) toml_it: bool,

    /// Show a cute bat output, with paging and highlight.
    ///
    /// Needs to have `bat` installed, if `-R` is used, `bat` is launched with `toml` highlight
    /// and will imply `-B` (beautify), otherwise it is launched with `json` highlight with pretty-printing,
    /// unless `-c` (compact mode) is provided in *jq_args*.
    ///
    /// `bat` will be launched using the `--paging=always` mode, so piping though it using this option
    /// is not possible. If what you really want is to pipe it to `bat` (maybe to render an HTML page
    /// with highlights using `aha`, for example), you should pipe it directly using the shell.
    /// This option is for launching `bat` with paging and highlight by default.
    #[clap(long)]
    pub(crate) bat: bool,

    /// Takes the output of `jq`, transcode back to TOML and output it (read about pretty print).
    #[clap(short = 'R', long)]
    pub(crate) retranscode: bool,

    /// Skips invalid toml documents when re-transcoding from multi-document streams.
    #[clap(short = '!', long)]
    pub(crate) skip_invalid: bool,

    /// Forces to treat the input as a single document.
    ///
    /// tomq always tries to parse the input as a single document first, then
    /// as a multi-document.
    #[clap(short = '1', long)]
    pub(crate) single_doc: bool,

    /// Convert null json values to empty documents.
    ///
    /// When re-transcoding into Toml, there are cases that *jq* outputs JSON
    /// null values, because it cannot match the filter. In those cases, *tomq*
    /// just fails, this switches to producing empty documents using the *multi-doc separator*.
    ///
    /// Even though it would be interesting to see which documents are not matching the filter,
    /// *tomq* uses streaming to read from multiple files and pipe to *jq*, and it reads from *jq*
    /// output in a separated thread (unless the feature is disable at build-time), so the error may
    /// happen while *jq* is already consuming any of the next documents.
    ///
    /// The only way to deal with this is to process one document at a time, which is way slower.
    #[clap(short = 'E', long = "null-to-empty-doc")]
    pub(crate) null_to_empty_doc: bool,

    /// The multi-document separator.
    ///
    /// Note that, TOML, by specification, does not support multi documents,
    /// this acts like an extension to the format. This was only added to be more
    /// in line with jq tool, which supports reading and writing multiple json documents.
    ///
    /// `---` is chosen as it does not conflicts with TOML format and can be safely handled
    /// without disturbing an individual document (whether it is inside a stream with multiple
    /// documents or not), but one can provide a custom separator.
    ///
    /// Read more in this issue: https://github.com/toml-lang/toml/issues/583
    #[clap(short = 'S', long, default_value = "---")]
    pub(crate) multi_doc_separator: String,

    /// The root key to use to produce multi-document output.
    ///
    /// By default, it flattens the output to a non-compliant TOML format,
    /// using the `multi-doc-separator` to separate each document. Although one
    /// can provide a custom string like `documents` to produce a compliant TOML format
    /// which uses the configured value as the root key for all documents.
    ///
    /// It is important to note that the root key is used only for outputting the TOML,
    /// for reading, you can feed the TOML back to tomq and use a jq filter to create multi-documents
    /// back.
    #[clap(short = 'U', long, value_parser = RootKeyParser, default_value = "flatten")]
    pub(crate) root_key: RootKey,

    /// Fallback key to use when the value is not an object.
    ///
    /// Some jq filter can output basic types like strings, numbers, arrays, etc,
    /// those cannot be directly converted to TOML, they need to be value of a field,
    /// this option defines the fallback key to use as a field for those values..
    #[clap(short = 'K', long)]
    pub(crate) fallback_key: Option<String>,

    /// Pretty print the output (always enabled for ttys).
    ///
    /// This can be useful when piping to other tools like bat (https://github.com/sharkdp/bat).
    ///
    /// There is no way to disable it for ttys (or tools that acts like ttys), to workaround
    /// this, you can pipe the output to `cat`.
    #[clap(short = 'B', long = "pp")]
    pub(crate) pretty_print: bool,

    /// Additional arguments to pass to *jq*.
    ///
    /// You can use `--` to separate jq arguments from the tomq arguments (but it is not required),
    /// for example: `tomq --pp --input-files nightly.toml -- -c`.
    #[clap(last = true, index = 3, allow_hyphen_values = true, multiple = true)]
    pub(crate) jq_args: Vec<String>,
}

#[derive(Debug, Clone)]
pub(crate) enum RootKey {
    Flatten,
    Key(String),
}

#[derive(Clone, Copy)]
struct RootKeyParser;
const VARIANTS: &[&str] = &["flatten", "string"];

impl TypedValueParser for RootKeyParser {
    type Value = RootKey;

    fn parse_ref(
        &self,
        cmd: &Command,
        arg: Option<&Arg>,
        value: &OsStr,
    ) -> Result<Self::Value, Error> {
        let value = value.to_str().ok_or_else(|| {
            invalid_value(
                cmd,
                value.to_string_lossy().into_owned(),
                arg.map(ToString::to_string)
                    .unwrap_or_else(|| "...".to_owned()),
                "",
            )
        })?;

        let value = value.parse::<Self::Value>().map_err(|e| {
            invalid_value(
                cmd,
                value.to_owned(),
                arg.map(ToString::to_string)
                    .unwrap_or_else(|| "...".to_owned()),
                &format!("Parse error: {}.", e),
            )
        })?;

        Ok(value)
    }

    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = PossibleValue<'static>> + '_>> {
        Some(Box::new(
            VARIANTS.iter().filter_map(|v| Some(PossibleValue::new(v))),
        ))
    }
}

fn invalid_value(cmd: &Command, bad_val: String, arg: String, ctx: &str) -> Error {
    Error::raw(ErrorKind::InvalidValue, format!(
        "{ctx}bad value `{bad_val}` provided to argument `{arg}` of command `{cmd}`. You should use `flatten` (or `\\flatten` to escape it as a key) or a valid UTF-8 string.",
        bad_val = bad_val,
        arg = arg,
        cmd = cmd.get_name(),
    ))
}

impl FromStr for RootKey {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "flatten" => Ok(RootKey::Flatten),
            "\\flatten" => Ok(RootKey::Key(s[1..].to_string())),
            _ => Ok(RootKey::Key(wrap_key_if_needed(s))),
        }
    }
}

fn wrap_key_if_needed(s: &str) -> String {
    for c in s.chars() {
        if !(c.is_alphabetic() || c.is_numeric() || c == '_' || c == '-') {
            return format!("{:?}", s);
        }
    }

    return s.to_string();
}