treease-cli 1.2.4

Command-line interface for querying, transforming, and previewing structured documents with Treease
Documentation
use std::fs;
use std::io::{Read, Write};

use treease_core::core::CodecService;
use treease_core::evaluator::{
    CanonicalFraming, CanonicalOutcome, CanonicalOutputFormat, CanonicalPayload, execute_canonical,
};
use treease_core::formats::FormatPreferences;

use crate::args::{CliError, InputPayload, ParsedArgs, StreamingInput, StreamingInputSource};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ExecutionStatus {
    printed: bool,
    has_truthy_result: bool,
}

struct CanonicalSource<'a> {
    input_format: &'a str,
    display_name: &'a str,
    source: &'a str,
}

impl ExecutionStatus {
    #[cfg(test)]
    pub(super) fn printed(self) -> bool {
        self.printed
    }

    pub(super) fn exit_code(self, enabled: bool) -> u8 {
        if enabled && !self.has_truthy_result {
            1
        } else {
            0
        }
    }
}

pub(super) fn canonical_cli_format(value: &str) -> Result<String, CliError> {
    treease_core::core::find_cli_format_spec(value)
        .map(|spec| spec.name.to_string())
        .ok_or_else(|| CliError::UnsupportedFormat(value.to_string()))
}

pub(super) fn configured_output_format(
    parsed: &ParsedArgs,
    first_input: Option<&InputPayload>,
) -> Result<String, CliError> {
    let input = first_input
        .map(|payload| super::cli_io::input::resolve_input_format(parsed, payload))
        .transpose()?;
    configured_output_format_from_input_format(parsed, input.as_deref())
}

pub(super) fn execute_command(
    parsed: &ParsedArgs,
    inputs: &[InputPayload],
) -> Result<Vec<u8>, CliError> {
    let output_format = configured_output_format(parsed, inputs.first())?;
    let mut output = Vec::new();
    let mut printed = false;
    let mut has_truthy_result = false;
    if parsed.null_input || inputs.is_empty() {
        execute_canonical_source(
            parsed,
            CanonicalSource {
                input_format: "json",
                display_name: "<null-input>",
                source: "null",
            },
            &output_format,
            &mut output,
            &mut printed,
            &mut has_truthy_result,
        )?;
        return Ok(output);
    }
    for payload in inputs {
        let input_format = super::cli_io::input::resolve_input_format(parsed, payload)?;
        let source = std::str::from_utf8(&payload.bytes)
            .map_err(|_| CliError::Eval("input is not valid UTF-8".to_owned()))?;
        execute_canonical_source(
            parsed,
            CanonicalSource {
                input_format: &input_format,
                display_name: payload.display_name(),
                source,
            },
            &output_format,
            &mut output,
            &mut printed,
            &mut has_truthy_result,
        )?;
    }
    Ok(output)
}

#[cfg(test)]
pub(super) fn execute_command_to_writer<W: Write>(
    parsed: &ParsedArgs,
    inputs: &[StreamingInput],
    output: &mut W,
) -> Result<bool, CliError> {
    Ok(execute_command_to_writer_status(parsed, inputs, output)?.printed())
}

pub(super) fn execute_command_to_writer_status<W: Write>(
    parsed: &ParsedArgs,
    inputs: &[StreamingInput],
    output: &mut W,
) -> Result<ExecutionStatus, CliError> {
    let output_format = configured_output_format_from_input_format(
        parsed,
        inputs.first().map(|input| input.input_format.as_str()),
    )?;
    let mut printed = false;
    let mut has_truthy_result = false;
    if parsed.null_input || inputs.is_empty() {
        execute_canonical_source(
            parsed,
            CanonicalSource {
                input_format: "json",
                display_name: "<null-input>",
                source: "null",
            },
            &output_format,
            output,
            &mut printed,
            &mut has_truthy_result,
        )?;
    } else {
        for input in inputs {
            let source_bytes = match &input.source {
                StreamingInputSource::Stdin(bytes) => bytes.clone(),
                StreamingInputSource::FilePath(path) => {
                    let mut bytes = Vec::new();
                    fs::File::open(path)?.read_to_end(&mut bytes)?;
                    bytes
                }
            };
            let source = std::str::from_utf8(&source_bytes)
                .map_err(|_| CliError::Eval("input is not valid UTF-8".to_owned()))?;
            execute_canonical_source(
                parsed,
                CanonicalSource {
                    input_format: &input.input_format,
                    display_name: input.display_name(),
                    source,
                },
                &output_format,
                output,
                &mut printed,
                &mut has_truthy_result,
            )?;
        }
    }
    Ok(ExecutionStatus {
        printed,
        has_truthy_result,
    })
}

fn execute_canonical_source<W: Write>(
    parsed: &ParsedArgs,
    source: CanonicalSource<'_>,
    output_format: &str,
    output: &mut W,
    printed: &mut bool,
    has_truthy_result: &mut bool,
) -> Result<(), CliError> {
    let format = canonical_output_format(output_format)?;
    let prefs = configured_preferences(parsed, output_format)?;
    let codec = CodecService::new();
    for (document_index, mut document) in codec
        .decode_all(source.input_format, source.source)
        .map_err(|error| {
            CliError::Canonical(treease_core::evaluator::CanonicalFailure::source_decode(
                error,
            ))
        })?
        .into_iter()
        .enumerate()
    {
        document.store.set_document_meta_if_absent(
            document_index as u32,
            source.display_name,
            document_index as i32,
        );
        let outcome = execute_canonical(
            &document.store,
            document.root,
            &parsed.expression,
            format,
            prefs.indent,
            CanonicalFraming::Documents,
        );
        let success = match outcome {
            CanonicalOutcome::Success(success) => success,
            CanonicalOutcome::Failure(failure) => return Err(CliError::Canonical(failure)),
        };
        *has_truthy_result |= success.has_truthy_result();
        let CanonicalPayload::Documents(texts) = success.payload() else {
            unreachable!("documents framing must return documents");
        };
        if !texts.is_empty()
            && format == CanonicalOutputFormat::Yaml
            && prefs.print_doc_separators
            && *printed
        {
            output.write_all(b"---\n")?;
        }
        for text in texts {
            output.write_all(text.as_bytes())?;
            *printed = true;
            if !text.ends_with('\n') {
                output.write_all(b"\n")?;
            }
        }
    }
    Ok(())
}

fn canonical_output_format(format: &str) -> Result<CanonicalOutputFormat, CliError> {
    match format {
        "json" => Ok(CanonicalOutputFormat::Json),
        "yaml" => Ok(CanonicalOutputFormat::Yaml),
        "toml" => Ok(CanonicalOutputFormat::Toml),
        _ => Err(CliError::UnsupportedFormat(format.to_owned())),
    }
}

pub(super) fn configured_preferences(
    parsed: &ParsedArgs,
    output_format: &str,
) -> Result<FormatPreferences, CliError> {
    let spec = treease_core::core::find_cli_format_spec(output_format)
        .ok_or_else(|| CliError::UnsupportedFormat(output_format.to_string()))?;
    let language = spec
        .format_language
        .ok_or_else(|| CliError::UnsupportedFormat(output_format.to_string()))?;
    let mut prefs = treease_core::formats::configured_language_preferences().effective(language);
    let pretty_print = parsed.pretty_print.unwrap_or(spec.default_pretty_print);
    prefs.indent = if pretty_print {
        parsed.indent.unwrap_or(prefs.indent)
    } else {
        0
    };
    prefs.unwrap_scalar = parsed.unwrap_scalar;
    if parsed.no_doc {
        prefs.print_doc_separators = false;
    }
    Ok(prefs)
}

fn configured_output_format_from_input_format(
    parsed: &ParsedArgs,
    first_input_format: Option<&str>,
) -> Result<String, CliError> {
    let input = first_input_format.unwrap_or("json").to_string();
    let output = match parsed.output_format.as_deref() {
        Some(value) => canonical_cli_format(value)?,
        None if first_input_format.is_some() => input.clone(),
        None => "yaml".to_string(),
    };
    Ok(output)
}