moonlight-cli 0.1.0

Compare command behavior against one or two reference commands.
Documentation
use crate::{
    config::{build_compare_config, DEFAULT_MAX_BODY_CAPTURE_BYTES},
    types::{BatchCase, Case, PreparedCase, TargetCommand},
};
use anyhow::{bail, Context};
use std::{path::PathBuf, sync::Arc};
use tokio::{
    fs,
    io::{self, AsyncBufReadExt, BufReader},
};

pub(crate) async fn read_batch_cases(input: &PathBuf) -> anyhow::Result<Vec<Case>> {
    let lines = if input.as_os_str() == "-" {
        read_stdin_lines().await?
    } else {
        fs::read_to_string(input)
            .await
            .with_context(|| format!("failed to read batch input {}", input.display()))?
            .lines()
            .map(ToOwned::to_owned)
            .collect()
    };

    let mut cases = Vec::new();
    for (index, line) in lines.into_iter().enumerate() {
        let line_number = index + 1;
        if line.trim().is_empty() {
            continue;
        }
        let case: BatchCase = serde_json::from_str(&line)
            .with_context(|| format!("invalid batch JSONL on line {line_number}"))?;
        cases.push(case_from_batch(line_number, case)?);
    }

    Ok(cases)
}

pub(crate) fn prepare_cases(cases: Vec<Case>) -> Vec<PreparedCase> {
    let default_compare_config = Arc::new(build_compare_config(&[], &[], false));
    cases
        .into_iter()
        .map(|case| {
            let compare_config = if case.uses_default_compare_config() {
                Arc::clone(&default_compare_config)
            } else {
                Arc::new(build_compare_config(
                    &case.ignored_json_paths,
                    &case.ignored_headers,
                    case.ignore_stderr,
                ))
            };
            PreparedCase {
                case,
                compare_config,
            }
        })
        .collect()
}

fn case_from_batch(line_number: usize, case: BatchCase) -> anyhow::Result<Case> {
    Ok(Case {
        primary: parse_required_command_form(
            line_number,
            "primary",
            case.primary,
            case.primary_argv,
        )?,
        candidate: parse_required_command_form(
            line_number,
            "candidate",
            case.candidate,
            case.candidate_argv,
        )?,
        secondary: parse_optional_command_form(
            line_number,
            "secondary",
            case.secondary,
            case.secondary_argv,
        )?,
        max_body_capture_bytes: case
            .max_body_capture_bytes
            .unwrap_or(DEFAULT_MAX_BODY_CAPTURE_BYTES),
        ignored_json_paths: case.ignored_json_paths,
        ignored_headers: case.ignored_headers,
        ignore_stderr: case.ignore_stderr,
    })
}

fn parse_required_command_form(
    line_number: usize,
    role: &str,
    shell: Option<String>,
    argv: Option<Vec<String>>,
) -> anyhow::Result<TargetCommand> {
    parse_command_form(line_number, role, shell, argv)?.with_context(|| {
        format!("invalid batch JSONL on line {line_number}: {role} command form is required")
    })
}

fn parse_optional_command_form(
    line_number: usize,
    role: &str,
    shell: Option<String>,
    argv: Option<Vec<String>>,
) -> anyhow::Result<Option<TargetCommand>> {
    parse_command_form(line_number, role, shell, argv)
}

fn parse_command_form(
    line_number: usize,
    role: &str,
    shell: Option<String>,
    argv: Option<Vec<String>>,
) -> anyhow::Result<Option<TargetCommand>> {
    match (shell, argv) {
        (Some(_), Some(_)) => bail!(
            "invalid batch JSONL on line {line_number}: provide exactly one of {role} or {role}_argv"
        ),
        (Some(command), None) => {
            if command.trim().is_empty() {
                bail!("invalid batch JSONL on line {line_number}: {role} must not be empty");
            }
            Ok(Some(TargetCommand::Shell(command)))
        }
        (None, Some(argv)) => {
            if argv.is_empty() {
                bail!("invalid batch JSONL on line {line_number}: {role}_argv must not be empty");
            }
            if argv[0].trim().is_empty() {
                bail!(
                    "invalid batch JSONL on line {line_number}: {role}_argv command must not be empty"
                );
            }
            Ok(Some(TargetCommand::Argv(argv)))
        }
        (None, None) => Ok(None),
    }
}

async fn read_stdin_lines() -> anyhow::Result<Vec<String>> {
    let mut lines = BufReader::new(io::stdin()).lines();
    let mut output = Vec::new();
    while let Some(line) = lines.next_line().await? {
        output.push(line);
    }
    Ok(output)
}