use std::path::Path;
use super::{TestClient, TestResponse};
use crate::application::Application;
use crate::service_discovery::json_lite::{self, JsonValue};
pub const UPDATE_VARIABLE: &str = "RWS_UPDATE_SNAPSHOTS";
pub trait OpenApiSource {
fn openapi_json(&self) -> String;
}
impl OpenApiSource for str {
fn openapi_json(&self) -> String {
self.to_string()
}
}
impl OpenApiSource for String {
fn openapi_json(&self) -> String {
self.clone()
}
}
impl OpenApiSource for TestResponse {
fn openapi_json(&self) -> String {
String::from_utf8_lossy(self.body_bytes()).into_owned()
}
}
impl<A: Application> OpenApiSource for TestClient<A> {
fn openapi_json(&self) -> String {
let response = self.get("/openapi.json").send();
if response.status() != 200 {
panic!("GET /openapi.json answered {} — register the document with .openapi(OpenApiConfig::new(…))", response.status());
}
response.openapi_json()
}
}
#[track_caller]
pub fn assert_openapi_snapshot<S: OpenApiSource + ?Sized>(source: &S, path: impl AsRef<Path>) {
let document = source.openapi_json();
let value = match json_lite::parse(&document) {
Ok(value) => value,
Err(e) => panic!("the OpenAPI document isn't JSON ({e}): {}", excerpt(&document)),
};
if value.get("openapi").and_then(JsonValue::as_str).is_none() {
panic!("not an OpenAPI document (no \"openapi\" version field): {}", excerpt(&document));
}
if let Err(message) = check_snapshot(&value, path.as_ref(), update_requested()) {
panic!("{message}");
}
}
#[track_caller]
pub fn assert_json_snapshot(json: &str, path: impl AsRef<Path>) {
let value = match json_lite::parse(json) {
Ok(value) => value,
Err(e) => panic!("not JSON ({e}): {}", excerpt(json)),
};
if let Err(message) = check_snapshot(&value, path.as_ref(), update_requested()) {
panic!("{message}");
}
}
fn update_requested() -> bool {
std::env::var(UPDATE_VARIABLE).is_ok_and(|v| !matches!(v.trim(), "" | "0" | "false"))
}
fn excerpt(text: &str) -> String {
let mut cut: String = text.chars().take(200).collect();
if cut.len() < text.len() {
cut.push('…');
}
cut
}
#[doc(hidden)]
pub fn check_snapshot(value: &JsonValue, path: &Path, update: bool) -> Result<(), String> {
let actual = normalize(value);
let existing = std::fs::read_to_string(path);
if update {
let unchanged = existing.as_ref().ok().and_then(|text| json_lite::parse(text).ok()).is_some_and(|old| normalize(&old) == actual);
if !unchanged || existing.as_deref().ok() != Some(actual.as_str()) {
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
}
std::fs::write(path, &actual).map_err(|e| format!("can't write {}: {e}", path.display()))?;
if !unchanged {
eprintln!("updated snapshot {}", path.display());
}
}
return Ok(());
}
let text = match existing {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(format!(
"no snapshot at {} — run the test with {UPDATE_VARIABLE}=1 to write it, then commit it",
path.display()
))
}
Err(e) => return Err(format!("can't read the snapshot {}: {e}", path.display())),
};
let expected = match json_lite::parse(&text) {
Ok(old) => normalize(&old),
Err(e) => return Err(format!("the snapshot {} isn't JSON ({e}) — rewrite it with {UPDATE_VARIABLE}=1", path.display())),
};
if expected == actual {
return Ok(());
}
Err(format!(
"the document differs from the snapshot {} (- snapshot, + now):\n{}\nIf the change is intended, run the test with {UPDATE_VARIABLE}=1 and commit the file.",
path.display(),
line_diff(&expected, &actual)
))
}
#[doc(hidden)]
pub fn normalize(value: &JsonValue) -> String {
let mut out = String::new();
write_value(value, 0, &mut out);
out.push('\n');
out
}
fn write_value(value: &JsonValue, indent: usize, out: &mut String) {
let pad = |out: &mut String, n: usize| out.extend(std::iter::repeat_n(' ', n * 2));
match value {
JsonValue::Array(items) if items.is_empty() => out.push_str("[]"),
JsonValue::Object(pairs) if pairs.is_empty() => out.push_str("{}"),
JsonValue::Array(items) => {
out.push_str("[\n");
for (i, item) in items.iter().enumerate() {
pad(out, indent + 1);
write_value(item, indent + 1, out);
out.push_str(if i + 1 < items.len() { ",\n" } else { "\n" });
}
pad(out, indent);
out.push(']');
}
JsonValue::Object(pairs) => {
let mut sorted: Vec<&(String, JsonValue)> = pairs.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
out.push_str("{\n");
for (i, (key, item)) in sorted.iter().enumerate() {
pad(out, indent + 1);
out.push_str(&json_lite::quote(key));
out.push_str(": ");
write_value(item, indent + 1, out);
out.push_str(if i + 1 < sorted.len() { ",\n" } else { "\n" });
}
pad(out, indent);
out.push('}');
}
scalar => out.push_str(&json_lite::to_text(scalar)),
}
}
const DIFF_CELLS: usize = 4_000_000;
#[doc(hidden)]
pub fn line_diff(expected: &str, actual: &str) -> String {
let a: Vec<&str> = expected.lines().collect();
let b: Vec<&str> = actual.lines().collect();
let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
let suffix = a[prefix..].iter().rev().zip(b[prefix..].iter().rev()).take_while(|(x, y)| x == y).count();
let (mid_a, mid_b) = (&a[prefix..a.len() - suffix], &b[prefix..b.len() - suffix]);
let mut ops: Vec<(char, &str)> = a[..prefix].iter().map(|l| (' ', *l)).collect();
if (mid_a.len() + 1).saturating_mul(mid_b.len() + 1) <= DIFF_CELLS {
let (n, m) = (mid_a.len(), mid_b.len());
let mut lcs = vec![0u32; (n + 1) * (m + 1)];
for i in (0..n).rev() {
for j in (0..m).rev() {
lcs[i * (m + 1) + j] = if mid_a[i] == mid_b[j] {
lcs[(i + 1) * (m + 1) + j + 1] + 1
} else {
lcs[(i + 1) * (m + 1) + j].max(lcs[i * (m + 1) + j + 1])
};
}
}
let (mut i, mut j) = (0, 0);
while i < n || j < m {
if i < n && j < m && mid_a[i] == mid_b[j] {
ops.push((' ', mid_a[i]));
i += 1;
j += 1;
} else if i < n && (j == m || lcs[(i + 1) * (m + 1) + j] >= lcs[i * (m + 1) + j + 1]) {
ops.push(('-', mid_a[i]));
i += 1;
} else {
ops.push(('+', mid_b[j]));
j += 1;
}
}
} else {
ops.extend(mid_a.iter().map(|l| ('-', *l)));
ops.extend(mid_b.iter().map(|l| ('+', *l)));
}
ops.extend(a[a.len() - suffix..].iter().map(|l| (' ', *l)));
const CONTEXT: usize = 3;
let changed: Vec<usize> = ops.iter().enumerate().filter(|(_, (t, _))| *t != ' ').map(|(i, _)| i).collect();
if changed.is_empty() {
return String::new();
}
let near = |i: usize| changed.iter().any(|&c| c.abs_diff(i) <= CONTEXT);
let mut out = String::new();
let mut skipped = false;
for (i, (tag, line)) in ops.iter().enumerate() {
if *tag == ' ' && !near(i) {
skipped = true;
continue;
}
if skipped {
out.push_str(" …\n");
skipped = false;
}
out.push(*tag);
out.push(' ');
out.push_str(line);
out.push('\n');
}
if skipped {
out.push_str(" …\n");
}
out
}