wavepeek 0.5.0

Command-line tool for RTL waveform inspection with deterministic machine-friendly output.
Documentation
#![allow(dead_code)]

use std::fs;
use std::path::{Path, PathBuf};

use serde::Deserialize;
use serde_json::Value;

use super::wavepeek_cmd;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandCaseCommand {
    Change,
    Property,
}

impl CommandCaseCommand {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Change => "change",
            Self::Property => "property",
        }
    }

    pub fn snapshot_suite_prefix(self) -> &'static str {
        match self {
            Self::Change => "change_cli",
            Self::Property => "property_cli",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputMode {
    Json,
    Human,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PositiveManifest {
    pub cases: Vec<PositiveCase>,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PositiveCase {
    pub name: String,
    pub command: CommandCaseCommand,
    pub args: Vec<String>,
    pub output_mode: OutputMode,
    pub exit_code: i32,
    #[serde(default)]
    pub expected_data: Option<Value>,
    #[serde(default)]
    pub expected_warnings: Vec<String>,
    #[serde(default)]
    pub stdout_snapshot: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NegativeManifest {
    pub cases: Vec<NegativeCase>,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NegativeCase {
    pub name: String,
    pub command: CommandCaseCommand,
    pub args: Vec<String>,
    pub exit_code: i32,
    pub stderr_prefix: String,
    #[serde(default)]
    pub stderr_snapshot: Option<String>,
}

pub fn fixture_cli_path(file_name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("cli")
        .join(file_name)
}

pub fn command_snapshot_path(file_name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("snapshots")
        .join(file_name)
}

pub fn command_manifest_file_names() -> Vec<String> {
    let mut entries = fs::read_dir(
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests")
            .join("fixtures")
            .join("cli"),
    )
    .expect("command fixture directory should be readable")
    .map(|entry| entry.expect("fixture entry should be readable"))
    .filter_map(|entry| {
        entry
            .file_type()
            .expect("fixture entry type should be readable")
            .is_file()
            .then(|| entry.file_name().to_string_lossy().into_owned())
    })
    .collect::<Vec<_>>();
    entries.sort();
    entries
}

pub fn command_snapshot_file_names() -> Vec<String> {
    let mut entries = fs::read_dir(
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests")
            .join("snapshots"),
    )
    .expect("snapshot directory should be readable")
    .map(|entry| entry.expect("snapshot entry should be readable"))
    .filter_map(|entry| {
        let file_name = entry.file_name().to_string_lossy().into_owned();
        (entry
            .file_type()
            .expect("snapshot entry type should be readable")
            .is_file()
            && ((file_name.starts_with("change_cli__expr_runtime_")
                || file_name.starts_with("property_cli__expr_runtime_"))
                && file_name.ends_with(".snap")))
        .then_some(file_name)
    })
    .collect::<Vec<_>>();
    entries.sort();
    entries
}

pub fn load_positive_manifest(file_name: &str) -> PositiveManifest {
    let payload = fs::read_to_string(fixture_cli_path(file_name))
        .unwrap_or_else(|error| panic!("manifest '{file_name}' should be readable: {error}"));
    let manifest = serde_json::from_str::<PositiveManifest>(&payload)
        .unwrap_or_else(|error| panic!("manifest '{file_name}' should be valid JSON: {error}"));
    validate_positive_manifest(manifest).unwrap_or_else(|error| {
        panic!("manifest '{file_name}' should match shared contract: {error}")
    })
}

pub fn load_negative_manifest(file_name: &str) -> NegativeManifest {
    let payload = fs::read_to_string(fixture_cli_path(file_name))
        .unwrap_or_else(|error| panic!("manifest '{file_name}' should be readable: {error}"));
    let manifest = serde_json::from_str::<NegativeManifest>(&payload)
        .unwrap_or_else(|error| panic!("manifest '{file_name}' should be valid JSON: {error}"));
    validate_negative_manifest(manifest).unwrap_or_else(|error| {
        panic!("manifest '{file_name}' should match shared contract: {error}")
    })
}

pub fn snapshot_file_name(command: CommandCaseCommand, snapshot_name: &str) -> String {
    format!(
        "{}__{}.snap",
        command.snapshot_suite_prefix(),
        snapshot_name
    )
}

pub fn referenced_snapshot_file_names(
    positive: &PositiveManifest,
    negative: &NegativeManifest,
) -> Vec<String> {
    let mut names = Vec::new();
    for case in &positive.cases {
        if let Some(snapshot) = case.stdout_snapshot.as_deref() {
            names.push(snapshot_file_name(case.command, snapshot));
        }
    }
    for case in &negative.cases {
        if let Some(snapshot) = case.stderr_snapshot.as_deref() {
            names.push(snapshot_file_name(case.command, snapshot));
        }
    }
    names.sort();
    names.dedup();
    names
}

pub fn assert_positive_case(case: &PositiveCase) {
    let output = run_case(case.command, case.args.as_slice());
    assert_eq!(
        output.status.code(),
        Some(case.exit_code),
        "case '{}' exit code",
        case.name
    );
    match case.output_mode {
        OutputMode::Json => {
            assert!(output.stderr.is_empty(), "case '{}' stderr", case.name);
            let value: Value = serde_json::from_slice(&output.stdout)
                .unwrap_or_else(|error| panic!("case '{}' stdout json: {error}", case.name));
            assert_eq!(
                value["command"],
                case.command.as_str(),
                "case '{}' command",
                case.name
            );
            assert_eq!(
                value["data"],
                case.expected_data
                    .clone()
                    .expect("json cases must declare expected_data"),
                "case '{}' data",
                case.name
            );
            assert_eq!(
                value["warnings"],
                serde_json::json!(case.expected_warnings),
                "case '{}' warnings",
                case.name
            );
        }
        OutputMode::Human => {
            let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
            let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
            if let Some(snapshot_name) = case.stdout_snapshot.as_deref() {
                let identifier = case_snapshot_identifier(case.command, snapshot_name);
                insta::with_settings!({
                    prepend_module_to_snapshot => false,
                    snapshot_path => "../snapshots",
                }, {
                    insta::assert_snapshot!(identifier.as_str(), stdout);
                });
            }
            let warning_lines = stderr
                .lines()
                .filter_map(|line| line.strip_prefix("warning: ").map(ToString::to_string))
                .collect::<Vec<_>>();
            assert_eq!(
                warning_lines, case.expected_warnings,
                "case '{}' warnings",
                case.name
            );
        }
    }
}

pub fn assert_negative_case(case: &NegativeCase) {
    let output = run_case(case.command, case.args.as_slice());
    assert_eq!(
        output.status.code(),
        Some(case.exit_code),
        "case '{}' exit code",
        case.name
    );
    assert!(output.stdout.is_empty(), "case '{}' stdout", case.name);
    let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
    assert!(
        stderr.starts_with(case.stderr_prefix.as_str()),
        "case '{}' stderr prefix mismatch: {stderr}",
        case.name
    );
    if let Some(snapshot_name) = case.stderr_snapshot.as_deref() {
        let identifier = case_snapshot_identifier(case.command, snapshot_name);
        insta::with_settings!({
            prepend_module_to_snapshot => false,
            snapshot_path => "../snapshots",
        }, {
            insta::assert_snapshot!(identifier.as_str(), stderr);
        });
    }
}

fn run_case(command: CommandCaseCommand, args: &[String]) -> std::process::Output {
    let mut full_args = vec![command.as_str().to_string()];
    full_args.extend(resolve_manifest_args(args));
    wavepeek_cmd()
        .args(full_args)
        .output()
        .expect("command fixture case should execute")
}

fn resolve_manifest_args(args: &[String]) -> Vec<String> {
    let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let mut resolved = Vec::with_capacity(args.len());
    let mut previous_was_waves = false;
    for arg in args {
        let resolved_arg = if previous_was_waves && Path::new(arg).is_relative() {
            repo_root.join(arg).display().to_string()
        } else {
            arg.clone()
        };
        previous_was_waves = arg == "--waves";
        resolved.push(resolved_arg);
    }
    resolved
}

fn case_snapshot_identifier(command: CommandCaseCommand, snapshot_name: &str) -> String {
    format!("{}__{}", command.snapshot_suite_prefix(), snapshot_name)
}

fn validate_positive_manifest(manifest: PositiveManifest) -> Result<PositiveManifest, String> {
    for case in &manifest.cases {
        match case.output_mode {
            OutputMode::Json => {
                if case.expected_data.is_none() {
                    return Err(format!(
                        "positive case '{}' with json output must declare expected_data",
                        case.name
                    ));
                }
                if case.stdout_snapshot.is_some() {
                    return Err(format!(
                        "positive case '{}' with json output must not declare stdout_snapshot",
                        case.name
                    ));
                }
            }
            OutputMode::Human => {
                if case.stdout_snapshot.is_none() {
                    return Err(format!(
                        "positive case '{}' with human output must declare stdout_snapshot",
                        case.name
                    ));
                }
            }
        }
        if case.args.is_empty() {
            return Err(format!("positive case '{}' must declare args", case.name));
        }
    }
    Ok(manifest)
}

fn validate_negative_manifest(manifest: NegativeManifest) -> Result<NegativeManifest, String> {
    for case in &manifest.cases {
        if case.args.is_empty() {
            return Err(format!("negative case '{}' must declare args", case.name));
        }
        if !case.stderr_prefix.starts_with("error:") {
            return Err(format!(
                "negative case '{}' stderr_prefix must start with 'error:'",
                case.name
            ));
        }
    }
    Ok(manifest)
}