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(RecordArgs),
Diff(DiffArgs),
}
#[derive(Debug, Args)]
struct RecordArgs {
#[arg(value_name = "TARGET")]
file: PathBuf,
#[arg(long, short, value_name = "FILE")]
output: PathBuf,
#[arg(long = "test", value_name = "NAME")]
case: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct DiffArgs {
#[arg(value_name = "TARGET")]
file: PathBuf,
#[arg(long, short, value_name = "FILE")]
baseline: PathBuf,
#[arg(long = "test", value_name = "NAME")]
case: Option<String>,
#[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, ¤t);
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))"));
}
}