tessellate 0.3.0

Command-line interface for the Tess rule language
use clap::{Args, Subcommand};
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
use tess::{
    DECISION_SNAPSHOT_SCHEMA_VERSION, DecisionSnapshot, Diagnostic, SnapshotChange,
    SnapshotDecisionStatus, capture_decision_snapshot, diff_decision_snapshots,
};

use super::{
    LANGUAGE_FAILURE, SUCCESS, USAGE_OR_IO_FAILURE, atomic_replace, load_compiled,
    normalize_identifier, render_diagnostics, report_load_failure, write_envelope, write_stdout,
};

#[derive(Debug, Args)]
pub(crate) struct SnapshotArgs {
    #[command(subcommand)]
    command: SnapshotCommand,
}

#[derive(Debug, Subcommand)]
enum SnapshotCommand {
    /// Record current declared-test decision results as a stable JSON baseline.
    Record(RecordArgs),
    /// Compare current declared-test decision results with a recorded baseline.
    Diff(DiffArgs),
}

#[derive(Debug, Args)]
struct RecordArgs {
    /// Entry `.tes` file, `tess.toml`, or project directory.
    #[arg(value_name = "TARGET")]
    file: PathBuf,
    /// Snapshot file to create or atomically replace.
    #[arg(long, short, value_name = "FILE")]
    output: PathBuf,
    /// Record only this declared test.
    #[arg(long = "test", value_name = "NAME")]
    case: Option<String>,
    /// Emit command metadata as one JSON document.
    #[arg(long)]
    json: bool,
}

#[derive(Debug, Args)]
struct DiffArgs {
    /// Entry `.tes` file, `tess.toml`, or project directory.
    #[arg(value_name = "TARGET")]
    file: PathBuf,
    /// Previously recorded snapshot to use as the baseline.
    #[arg(long, short, value_name = "FILE")]
    baseline: PathBuf,
    /// Compare only this declared test in both snapshots.
    #[arg(long = "test", value_name = "NAME")]
    case: Option<String>,
    /// Emit the semantic diff as one JSON document.
    #[arg(long)]
    json: bool,
}

pub(crate) fn run(arguments: &SnapshotArgs) -> u8 {
    match &arguments.command {
        SnapshotCommand::Record(arguments) => record(arguments),
        SnapshotCommand::Diff(arguments) => diff(arguments),
    }
}

fn record(arguments: &RecordArgs) -> u8 {
    let loaded = match load_compiled(&arguments.file) {
        Ok(loaded) => loaded,
        Err(failure) => return report_load_failure(failure, "snapshot.record", arguments.json),
    };
    let selected_case = arguments.case.as_deref().map(normalize_identifier);
    let snapshot = match capture_decision_snapshot(&loaded.program, selected_case.as_deref()) {
        Ok(snapshot) => snapshot,
        Err(error) => {
            let diagnostic = Diagnostic::error("C1102", error.to_string(), tess::Span::default());
            return report_snapshot_language_failure(
                "snapshot.record",
                arguments.json,
                &loaded,
                diagnostic,
            );
        }
    };
    if snapshot.cases.is_empty() {
        let message = selected_case.as_ref().map_or_else(
            || "no tests are declared in this target".to_owned(),
            |case| format!("test `{case}` was not found"),
        );
        return report_snapshot_language_failure(
            "snapshot.record",
            arguments.json,
            &loaded,
            Diagnostic::error("C1101", message, tess::Span::default()),
        );
    }
    let mut serialized = match serde_json::to_string_pretty(&snapshot) {
        Ok(serialized) => serialized,
        Err(error) => {
            eprintln!("error: could not encode decision snapshot: {error}");
            return USAGE_OR_IO_FAILURE;
        }
    };
    serialized.push('\n');
    if let Err(error) = atomic_replace(&arguments.output, serialized.as_bytes()) {
        eprintln!(
            "error: could not write snapshot `{}`: {error}",
            arguments.output.display()
        );
        return USAGE_OR_IO_FAILURE;
    }
    if arguments.json {
        return write_envelope(
            "snapshot.record",
            true,
            &loaded.diagnostics,
            &json!({
                "schema_version": snapshot.schema_version,
                "module": snapshot.module,
                "cases": snapshot.cases.len(),
                "decisions": snapshot.decision_count(),
                "output": arguments.output,
            }),
        );
    }
    render_diagnostics(&loaded.diagnostics, &loaded.source);
    match write_stdout(&format!(
        "recorded {} decision result(s) from {} test(s) to {}\n",
        snapshot.decision_count(),
        snapshot.cases.len(),
        arguments.output.display()
    )) {
        Ok(()) => SUCCESS,
        Err(error) => {
            eprintln!("error: could not write stdout: {error}");
            USAGE_OR_IO_FAILURE
        }
    }
}

fn diff(arguments: &DiffArgs) -> u8 {
    let loaded = match load_compiled(&arguments.file) {
        Ok(loaded) => loaded,
        Err(failure) => return report_load_failure(failure, "snapshot.diff", arguments.json),
    };
    let selected_case = arguments.case.as_deref().map(normalize_identifier);
    let mut baseline = match read_snapshot(&arguments.baseline) {
        Ok(snapshot) => snapshot,
        Err(message) => {
            eprintln!("error: {message}");
            return USAGE_OR_IO_FAILURE;
        }
    };
    if baseline.schema_version != DECISION_SNAPSHOT_SCHEMA_VERSION {
        eprintln!(
            "error: unsupported snapshot schema `{}` in `{}`; expected `{DECISION_SNAPSHOT_SCHEMA_VERSION}`",
            baseline.schema_version,
            arguments.baseline.display()
        );
        return USAGE_OR_IO_FAILURE;
    }
    let current = match capture_decision_snapshot(&loaded.program, selected_case.as_deref()) {
        Ok(snapshot) => snapshot,
        Err(error) => {
            let diagnostic = Diagnostic::error("C1102", error.to_string(), tess::Span::default());
            return report_snapshot_language_failure(
                "snapshot.diff",
                arguments.json,
                &loaded,
                diagnostic,
            );
        }
    };
    if current.cases.is_empty() {
        let message = selected_case.as_ref().map_or_else(
            || "no tests are declared in this target".to_owned(),
            |case| format!("test `{case}` was not found"),
        );
        return report_snapshot_language_failure(
            "snapshot.diff",
            arguments.json,
            &loaded,
            Diagnostic::error("C1101", message, tess::Span::default()),
        );
    }
    if let Some(case) = selected_case.as_deref() {
        baseline.cases.retain(|candidate| candidate.case == case);
    }
    let report = diff_decision_snapshots(&baseline, &current);
    let matches = report.matches();
    if arguments.json {
        return write_envelope(
            "snapshot.diff",
            matches,
            &loaded.diagnostics,
            &json!({
                "baseline": arguments.baseline,
                "diff": report,
            }),
        );
    }
    render_diagnostics(&loaded.diagnostics, &loaded.source);
    let rendered = render_diff(&report, &arguments.baseline);
    match write_stdout(&rendered) {
        Ok(()) if matches => SUCCESS,
        Ok(()) => LANGUAGE_FAILURE,
        Err(error) => {
            eprintln!("error: could not write stdout: {error}");
            USAGE_OR_IO_FAILURE
        }
    }
}

fn read_snapshot(path: &Path) -> Result<DecisionSnapshot, String> {
    let text = fs::read_to_string(path)
        .map_err(|error| format!("could not read snapshot `{}`: {error}", path.display()))?;
    serde_json::from_str(&text)
        .map_err(|error| format!("invalid decision snapshot `{}`: {error}", path.display()))
}

fn report_snapshot_language_failure(
    command: &str,
    json_output: bool,
    loaded: &super::CompiledFile,
    diagnostic: Diagnostic,
) -> u8 {
    let mut diagnostics = loaded.diagnostics.clone();
    diagnostics.push(diagnostic);
    if json_output {
        write_envelope(command, false, &diagnostics, &serde_json::Value::Null)
    } else {
        render_diagnostics(&diagnostics, &loaded.source);
        LANGUAGE_FAILURE
    }
}

fn render_diff(report: &tess::SnapshotDiff, baseline: &Path) -> String {
    if report.matches() {
        return format!(
            "snapshot matches {} ({} unchanged decision result(s))\n",
            baseline.display(),
            report.unchanged_decisions
        );
    }
    let mut output = format!(
        "snapshot differs from {}: {} change(s), {} unchanged decision result(s)\n",
        baseline.display(),
        report.changes.len(),
        report.unchanged_decisions
    );
    for change in &report.changes {
        output.push_str("  ");
        output.push_str(&render_change(change));
        output.push('\n');
    }
    output
}

fn render_change(change: &SnapshotChange) -> String {
    match change {
        SnapshotChange::ModuleChanged { before, after } => {
            format!("MODULE {before} -> {after}")
        }
        SnapshotChange::CaseAdded { case, decisions } => {
            format!("ADDED test {case} ({} decision result(s))", decisions.len())
        }
        SnapshotChange::CaseRemoved { case, decisions } => {
            format!(
                "REMOVED test {case} ({} decision result(s))",
                decisions.len()
            )
        }
        SnapshotChange::DecisionAdded { case, decision } => format!(
            "ADDED {case} :: {}#{} = {}",
            decision.query,
            decision.occurrence,
            decision.result.render_text()
        ),
        SnapshotChange::DecisionRemoved { case, decision } => format!(
            "REMOVED {case} :: {}#{} = {}",
            decision.query,
            decision.occurrence,
            decision.result.render_text()
        ),
        SnapshotChange::DecisionChanged {
            case,
            before,
            after,
        } => format!(
            "CHANGED {case} :: {}#{}: {} -> {}",
            before.query,
            before.occurrence,
            render_observation(before.cardinality, &before.result),
            render_observation(after.cardinality, &after.result)
        ),
    }
}

fn render_observation(
    cardinality: tess::ast::Cardinality,
    result: &SnapshotDecisionStatus,
) -> String {
    format!("{cardinality:?} {}", result.render_text())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tess::{DecisionObservation, SnapshotDiff};

    #[test]
    fn human_diff_is_stable_and_specific() {
        let observation = DecisionObservation {
            query: "result(s)".to_owned(),
            occurrence: 1,
            cardinality: tess::ast::Cardinality::ExactlyOne,
            result: SnapshotDecisionStatus::Undefined,
        };
        let report = SnapshotDiff {
            schema_version: tess::SNAPSHOT_DIFF_SCHEMA_VERSION.to_owned(),
            baseline_module: "sample".to_owned(),
            current_module: "sample".to_owned(),
            unchanged_decisions: 0,
            changes: vec![SnapshotChange::CaseAdded {
                case: "example".to_owned(),
                decisions: vec![observation],
            }],
        };
        let rendered = render_diff(&report, Path::new("baseline.json"));
        assert!(rendered.contains("1 change(s)"));
        assert!(rendered.contains("ADDED test example (1 decision result(s))"));
    }
}