fmtview 0.4.1

Fast CLI viewer for highlighting, search, and diffs across JSON, JSONL, markup, Markdown, TOML, text, and Jinja
Documentation
use anyhow::Result;

use crate::{
    input::InputSource,
    profile::TypeProfile,
    transform::{self, FormatKind, FormatOptions, TransformStrategy},
};

use super::{IndexedTempFile, LazyTransformedRecordsFile, LoadPlan, ViewFile};

pub struct OpenedViewFile {
    pub file: Box<dyn ViewFile>,
    pub content: FormatKind,
    pub notice: Option<String>,
}

pub fn open_view_file(
    input: &InputSource,
    options: &FormatOptions,
    profile: TypeProfile,
) -> Result<OpenedViewFile> {
    open_view_file_with_fallback(input, options, profile, false)
}

pub fn open_view_file_with_fallback(
    input: &InputSource,
    options: &FormatOptions,
    profile: TypeProfile,
    allow_plain_fallback: bool,
) -> Result<OpenedViewFile> {
    match profile.load {
        LoadPlan::LazyTransformedRecords => Ok(OpenedViewFile {
            file: Box::new(LazyTransformedRecordsFile::new(input, *options)?),
            content: profile.content,
            notice: None,
        }),
        LoadPlan::EagerTransformedDocument | LoadPlan::EagerIndexedSource => {
            match open_indexed(input, options, profile.transform) {
                Ok(file) => Ok(OpenedViewFile {
                    file,
                    content: profile.content,
                    notice: None,
                }),
                Err(_)
                    if allow_plain_fallback
                        && profile.transform != TransformStrategy::Passthrough =>
                {
                    let fallback_options = FormatOptions {
                        kind: FormatKind::Plain,
                        indent: options.indent,
                    };
                    Ok(OpenedViewFile {
                        file: open_indexed(
                            input,
                            &fallback_options,
                            TransformStrategy::Passthrough,
                        )?,
                        content: FormatKind::Plain,
                        notice: Some(fallback_notice(profile.content)),
                    })
                }
                Err(error) => Err(error),
            }
        }
    }
}

fn open_indexed(
    input: &InputSource,
    options: &FormatOptions,
    transform: TransformStrategy,
) -> Result<Box<dyn ViewFile>> {
    let formatted = transform::transform_source_to_temp(input, options, transform)?;
    Ok(Box::new(IndexedTempFile::new(
        input.label().to_owned(),
        formatted,
    )?))
}

fn fallback_notice(kind: FormatKind) -> String {
    format!(
        "auto-detected {} could not be formatted; showing plain text. Use --type to choose a type",
        kind.label()
    )
}

trait FormatKindLabel {
    fn label(self) -> &'static str;
}

impl FormatKindLabel for FormatKind {
    fn label(self) -> &'static str {
        match self {
            FormatKind::Auto => "input",
            FormatKind::Json => "JSON",
            FormatKind::Jsonl => "JSONL",
            FormatKind::Xml => "XML",
            FormatKind::Toml => "TOML",
            FormatKind::Markdown => "Markdown",
            FormatKind::Plain => "plain text",
            FormatKind::Jinja => "Jinja",
        }
    }
}

#[cfg(test)]
mod tests {
    use std::io::Write;

    use tempfile::Builder as TempFileBuilder;

    use super::*;
    use crate::{input::InputSource, profile::TypeProfile};

    fn invalid_json_source() -> (tempfile::NamedTempFile, InputSource) {
        let mut temp = TempFileBuilder::new().suffix(".json").tempfile().unwrap();
        write!(temp, "not json\nstill useful text\n").unwrap();
        temp.flush().unwrap();
        let source = InputSource::from_arg(temp.path().to_str().unwrap(), None).unwrap();
        (temp, source)
    }

    #[test]
    fn auto_inferred_format_error_can_fallback_to_plain_view_file() {
        let (_temp, source) = invalid_json_source();
        let options = FormatOptions {
            kind: FormatKind::Auto,
            indent: 2,
        };
        let profile = TypeProfile::resolve(&source, &options).unwrap();
        let resolved_options = profile.format_options(options.indent);

        let opened =
            open_view_file_with_fallback(&source, &resolved_options, profile, true).unwrap();

        assert_eq!(opened.content, FormatKind::Plain);
        assert!(
            opened
                .notice
                .as_deref()
                .is_some_and(|notice| notice.contains("--type"))
        );
        assert_eq!(
            opened.file.read_window(0, 3).unwrap(),
            vec!["not json", "still useful text"]
        );
    }

    #[test]
    fn explicit_or_non_fallback_format_error_still_fails() {
        let (_temp, source) = invalid_json_source();
        let options = FormatOptions {
            kind: FormatKind::Auto,
            indent: 2,
        };
        let profile = TypeProfile::resolve(&source, &options).unwrap();
        let resolved_options = profile.format_options(options.indent);

        let error = match open_view_file_with_fallback(&source, &resolved_options, profile, false) {
            Ok(_) => panic!("invalid inferred JSON should fail when fallback is disabled"),
            Err(error) => error,
        };

        assert!(error.to_string().contains("failed to format"));
    }
}