use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::time::Duration;
use serde::Serialize;
use serde_json::{Map, Value};
use super::layout::TAG_TYPES;
use super::manifest::Manifest;
use super::plan::{PlanStatus, unit_key};
use super::render::RenderOutcome;
use crate::client::scan::ScanStatus;
pub const VERIFY_FAILED_SLUG: &str = "deploy_verify_failed";
pub const PROBE_DEADLINE: Duration = Duration::from_secs(30);
pub const PROBE_INTERVAL: Duration = Duration::from_secs(1);
pub const LOG_WINDOW_SLACK_MS: i64 = 1_000;
pub const LOG_WINDOW_FALLBACK_MS: i64 = 30_000;
pub const LOG_SETTLE_MS: i64 = 5_000;
pub const WILL_NOT_BE_LOADED: &str = "will not be loaded";
pub const NO_READ_ROUTE: &[(&str, &str)] = &[
("com.inductiveautomation.opcua", "one-time"),
("ignition", "images"),
];
pub const SYSTEM_COLLECTION: &str = "system";
const MAX_PATHS: usize = 20;
const MAX_CODE_CHARS: usize = 96;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckId {
Scan,
Mode,
ReadBack,
Files,
Loaded,
Probes,
}
impl CheckId {
pub fn word(self) -> &'static str {
match self {
Self::Scan => "scan",
Self::Mode => "mode",
Self::ReadBack => "read_back",
Self::Files => "files",
Self::Loaded => "loaded",
Self::Probes => "probes",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Pass,
Fail,
Warn,
}
impl CheckStatus {
pub fn word(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Fail => "fail",
Self::Warn => "warn",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Finding {
pub code: &'static str,
pub subject: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Check {
pub id: CheckId,
pub status: CheckStatus,
pub detail: String,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct VerifyReport {
pub ok: bool,
pub slug: Option<&'static str>,
pub checks: Vec<Check>,
pub resources_checked: usize,
pub warnings: Vec<String>,
}
#[derive(Clone, PartialEq)]
pub enum Compare {
Exact(Value),
NameKeyedSubset(Value),
Attributes(Map<String, Value>),
}
impl fmt::Debug for Compare {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = match self {
Compare::Exact(_) => "Exact",
Compare::NameKeyedSubset(_) => "NameKeyedSubset",
Compare::Attributes(_) => "Attributes",
};
write!(f, "{kind}(<withheld>)")
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExpectedResource {
pub key: String,
pub module: String,
pub ty: String,
pub name: Option<String>,
pub compare: Compare,
}
impl ExpectedResource {
pub fn exact(module: &str, ty: &str, name: Option<&str>, config: Value) -> Self {
Self::new(module, ty, name, Compare::Exact(config))
}
pub fn new(module: &str, ty: &str, name: Option<&str>, compare: Compare) -> Self {
Self {
key: resource_id(module, ty, name),
module: module.to_string(),
ty: ty.to_string(),
name: name.map(str::to_string),
compare,
}
}
}
pub fn resource_id(module: &str, ty: &str, name: Option<&str>) -> String {
match name {
Some(name) => format!("{module}/{ty}/{name}"),
None => format!("{module}/{ty}"),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Expected {
pub mode: String,
pub resources: Vec<ExpectedResource>,
pub not_checked: Vec<(String, String)>,
pub declared_ids: Vec<String>,
pub tag_units: Vec<String>,
pub probes: Vec<String>,
}
impl Expected {
pub fn from_render(target_mode: &str, outcome: &RenderOutcome, manifest: &Manifest) -> Self {
let files = &outcome.tree.files;
let is_tag = |module: &str, ty: &str| module == "ignition" && TAG_TYPES.contains(&ty);
let mut tag_units = BTreeSet::new();
for path in files.keys() {
let mut parts = path.components();
if let (Some(module), Some(ty)) = (parts.next(), parts.next())
&& is_tag(module, ty)
{
tag_units.insert(unit_key(path, files));
}
}
let mut resources = BTreeMap::new();
let mut not_checked = BTreeMap::new();
let mut declared_ids = BTreeSet::new();
for res in &outcome.resources {
if is_tag(&res.module, &res.ty) {
continue;
}
let key = resource_id(&res.module, &res.ty, res.name.as_deref());
declared_ids.insert(key.clone());
if NO_READ_ROUTE
.iter()
.any(|(m, t)| *m == res.module && *t == res.ty)
{
not_checked.insert(key, "no REST read route".to_string());
continue;
}
let file = |name: &str| {
super::layout::RelPath::new(&format!("{key}/{name}"))
.ok()
.and_then(|p| files.get(&p))
};
let compare = if let Some(bytes) = file("config.json") {
match serde_json::from_slice::<Value>(bytes) {
Ok(value) if res.module == "ignition" && res.ty == "security-levels" => {
Compare::NameKeyedSubset(value)
}
Ok(value) => Compare::Exact(value),
Err(_) => {
not_checked.insert(key, "config.json is not JSON".to_string());
continue;
}
}
} else if let Some(attributes) = file("resource.json")
.and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok())
.and_then(|v| v.get("attributes").and_then(Value::as_object).cloned())
{
Compare::Attributes(attributes)
} else {
not_checked.insert(key, "no config.json to compare".to_string());
continue;
};
resources.insert(
key.clone(),
ExpectedResource {
key,
module: res.module.clone(),
ty: res.ty.clone(),
name: res.name.clone(),
compare,
},
);
}
Expected {
mode: target_mode.to_string(),
resources: resources.into_values().collect(),
not_checked: not_checked.into_iter().collect(),
declared_ids: declared_ids.into_iter().collect(),
tag_units: tag_units.into_iter().collect(),
probes: manifest
.verify
.as_ref()
.map(|v| v.tag_probes.clone())
.unwrap_or_default(),
}
}
}
#[derive(Clone, PartialEq)]
pub enum Remote {
Missing,
Config(Value),
System,
}
impl fmt::Debug for Remote {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Remote::Missing => f.write_str("Missing"),
Remote::Config(_) => f.write_str("Config(<withheld>)"),
Remote::System => f.write_str("System"),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct LogLine {
pub timestamp: i64,
pub logger: String,
pub message: String,
}
impl fmt::Debug for LogLine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LogLine")
.field("timestamp", &self.timestamp)
.field("logger", &self.logger)
.field("message", &"<withheld>")
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ProbeReading {
pub path: String,
pub quality: String,
}
impl fmt::Debug for ProbeReading {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProbeReading")
.field("path", &self.path)
.field("quality", &quality_code(&self.quality))
.finish()
}
}
#[derive(Debug, Clone)]
pub struct ObservedState {
pub scan: ScanStatus,
pub mode: Option<String>,
pub read_back: BTreeMap<String, Remote>,
pub files: Vec<(String, PlanStatus)>,
pub logs: Result<Vec<LogLine>, String>,
pub probes: Option<Result<Vec<ProbeReading>, String>>,
pub unreadable: Vec<(String, bool)>,
}
pub fn log_window_start(scan: &ScanStatus) -> i64 {
let duration = scan.last_scan_duration.unwrap_or(LOG_WINDOW_FALLBACK_MS);
(scan.last_scan_timestamp - duration - LOG_WINDOW_SLACK_MS).max(0)
}
pub fn evaluate(expected: &Expected, observed: &ObservedState) -> VerifyReport {
let mut warnings = Vec::new();
let (read_back, resources_checked) = check_read_back(expected, observed, &mut warnings);
let checks = vec![
check_scan(observed),
check_mode(expected, observed),
read_back,
check_files(expected, observed, &mut warnings),
check_loaded(expected, observed),
check_probes(expected, observed),
];
for check in &checks {
if check.status == CheckStatus::Warn {
warnings.push(format!("{}: {}", check_word(check.id), check.detail));
}
}
let ok = checks.iter().all(|c| c.status != CheckStatus::Fail);
VerifyReport {
ok,
slug: (!ok).then_some(VERIFY_FAILED_SLUG),
checks,
resources_checked,
warnings,
}
}
fn check_word(id: CheckId) -> &'static str {
match id {
CheckId::Scan => "scan",
CheckId::Mode => "mode",
CheckId::ReadBack => "read_back",
CheckId::Files => "files",
CheckId::Loaded => "loaded",
CheckId::Probes => "probes",
}
}
fn finding(code: &'static str, subject: impl Into<String>, detail: impl Into<String>) -> Finding {
Finding {
code,
subject: subject.into(),
detail: detail.into(),
}
}
fn sort_findings(findings: &mut [Finding]) {
findings.sort_by(|a, b| {
a.subject
.as_bytes()
.cmp(b.subject.as_bytes())
.then_with(|| a.code.cmp(b.code))
});
}
fn finished(id: CheckId, detail: String, findings: Vec<Finding>) -> Check {
Check {
id,
status: if findings.is_empty() {
CheckStatus::Pass
} else {
CheckStatus::Fail
},
detail,
findings,
}
}
fn check_scan(observed: &ObservedState) -> Check {
if observed.scan.scan_active {
return finished(
CheckId::Scan,
"a config scan is running".to_string(),
vec![finding(
"scan_active",
"config",
"the gateway reports an active config scan; verify reads settled state only",
)],
);
}
finished(
CheckId::Scan,
"no config scan is running".to_string(),
Vec::new(),
)
}
fn check_mode(expected: &Expected, observed: &ObservedState) -> Check {
mode_check(expected, observed.mode.as_deref())
}
pub(crate) fn mode_check(expected: &Expected, mode: Option<&str>) -> Check {
if mode == Some(expected.mode.as_str()) {
return finished(
CheckId::Mode,
format!("the gateway runs mode `{}`", expected.mode),
Vec::new(),
);
}
let seen = match mode {
Some(mode) => format!("`{}`", printable(mode)),
None => "no mode".to_string(),
};
finished(
CheckId::Mode,
"the gateway does not run the target's mode".to_string(),
vec![finding(
"mode_mismatch",
"mode",
format!("expected `{}`, the gateway reports {seen}", expected.mode),
)],
)
}
fn check_read_back(
expected: &Expected,
observed: &ObservedState,
warnings: &mut Vec<String>,
) -> (Check, usize) {
read_back_check(expected, &observed.read_back, warnings)
}
pub(crate) fn read_back_check(
expected: &Expected,
read_back: &BTreeMap<String, Remote>,
warnings: &mut Vec<String>,
) -> (Check, usize) {
let mut findings = Vec::new();
let mut checked = 0usize;
for res in &expected.resources {
match read_back.get(&res.key).unwrap_or(&Remote::Missing) {
Remote::Missing => {
checked += 1;
findings.push(finding(
"resource_missing",
res.key.clone(),
"not in the gateway's effective view",
));
}
Remote::System => warnings.push(format!(
"read_back: {} not compared: the effective copy is in the `{SYSTEM_COLLECTION}` collection (gateway-provided, not file-backed)",
res.key
)),
Remote::Config(rest) => {
checked += 1;
let mut paths = Vec::new();
match &res.compare {
Compare::Exact(file) => {
let rest = strip_nulls(rest);
diff_exact(&strip_nulls(file), &rest, "", &mut paths);
}
Compare::NameKeyedSubset(file) => {
let rest = strip_nulls(rest);
diff_subset(&strip_nulls(file), &rest, "", &mut paths);
}
Compare::Attributes(attrs) => diff_attributes(attrs, rest, &mut paths),
}
if !paths.is_empty() {
findings.push(finding(
"resource_differs",
res.key.clone(),
format!("differs at: {}", join_paths(paths)),
));
}
}
}
}
for (key, reason) in &expected.not_checked {
warnings.push(format!("read_back: {key} not compared: {reason}"));
}
if checked == 0 && findings.is_empty() {
warnings.push(
"read_back: no declared non-tag resource was compared (nothing to read back)"
.to_string(),
);
}
sort_findings(&mut findings);
let detail = format!("{checked} declared resource(s) compared with the REST effective view");
(finished(CheckId::ReadBack, detail, findings), checked)
}
fn strip_nulls(value: &Value) -> Value {
match value {
Value::Object(map) => Value::Object(
map.iter()
.filter(|(_, v)| !v.is_null())
.map(|(k, v)| (k.clone(), strip_nulls(v)))
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(strip_nulls).collect()),
other => other.clone(),
}
}
fn child_path(path: &str, key: &str) -> String {
let key = printable(key);
if path.is_empty() {
key
} else {
format!("{path}.{key}")
}
}
fn root_or(path: &str) -> String {
if path.is_empty() {
"(root)".to_string()
} else {
path.to_string()
}
}
fn same_scalar(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => {
if x.is_f64() || y.is_f64() {
matches!((x.as_f64(), y.as_f64()), (Some(x), Some(y)) if x == y)
} else {
x == y
}
}
_ => a == b,
}
}
fn diff_exact(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
match (file, rest) {
(Value::Object(f), Value::Object(r)) => {
for (key, fv) in f {
match r.get(key) {
Some(rv) => diff_exact(fv, rv, &child_path(path, key), out),
None => out.push(child_path(path, key)),
}
}
for key in r.keys().filter(|k| !f.contains_key(*k)) {
out.push(child_path(path, key));
}
}
(Value::Array(f), Value::Array(r)) if f.len() == r.len() => {
for (i, (fv, rv)) in f.iter().zip(r).enumerate() {
diff_exact(fv, rv, &child_path(path, &i.to_string()), out);
}
}
(Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
out.push(root_or(path));
}
(f, r) => {
if !same_scalar(f, r) {
out.push(root_or(path));
}
}
}
}
fn diff_subset(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
match (file, rest) {
(Value::Object(f), Value::Object(r)) => {
for (key, fv) in f {
match r.get(key) {
Some(rv) => diff_subset(fv, rv, &child_path(path, key), out),
None => out.push(child_path(path, key)),
}
}
}
(Value::Array(f), Value::Array(r)) => {
for (i, fv) in f.iter().enumerate() {
let name = fv.get("name").and_then(Value::as_str);
if let (Some(name), true) = (name, fv.is_object()) {
let at = format!("{path}[{}]", printable(name));
match r
.iter()
.find(|rv| rv.get("name").and_then(Value::as_str) == Some(name))
{
Some(rv) => diff_subset(fv, rv, &at, out),
None => out.push(at),
}
} else {
let found = r.iter().any(|rv| {
let mut inner = Vec::new();
diff_exact(fv, rv, "", &mut inner);
inner.is_empty()
});
if !found {
out.push(child_path(path, &i.to_string()));
}
}
}
}
(Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
out.push(root_or(path));
}
(f, r) => {
if !same_scalar(f, r) {
out.push(root_or(path));
}
}
}
}
fn diff_attributes(attrs: &Map<String, Value>, rest: &Value, out: &mut Vec<String>) {
let Some(rest) = rest.as_object() else {
out.push("(root)".to_string());
return;
};
for (key, expected) in attrs {
if let Some(seen) = rest.get(key) {
let mut inner = Vec::new();
diff_exact(&strip_nulls(expected), &strip_nulls(seen), "", &mut inner);
if !inner.is_empty() {
out.push(key.clone());
}
}
}
}
fn join_paths(mut paths: Vec<String>) -> String {
paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
paths.dedup();
let more = paths.len().saturating_sub(MAX_PATHS);
paths.truncate(MAX_PATHS);
let mut text = paths.join(", ");
if more > 0 {
text.push_str(&format!(" (+{more} more)"));
}
text
}
const UNREADABLE_DETAIL: &str =
"the unit has a live path the host's sftp server cannot read; its live copy is unknown";
fn check_files(expected: &Expected, observed: &ObservedState, warnings: &mut Vec<String>) -> Check {
let mut findings = Vec::new();
for unit in &expected.tag_units {
if observed.unreadable.iter().any(|(key, _)| key == unit) {
findings.push(finding("unreadable", unit.clone(), UNREADABLE_DETAIL));
continue;
}
let status = observed
.files
.iter()
.find(|(key, _)| key == unit)
.map(|(_, status)| *status);
match status {
Some(PlanStatus::Same) => {}
Some(other) => findings.push(finding(
"files_differ",
unit.clone(),
format!(
"the effective live copy is `{}` against the render",
other.word()
),
)),
None => findings.push(finding(
"files_differ",
unit.clone(),
"the unit was not found among the live files",
)),
}
}
for (key, declared) in &observed.unreadable {
if expected.tag_units.contains(key) {
continue;
}
if *declared {
findings.push(finding("unreadable", key.clone(), UNREADABLE_DETAIL));
} else {
warnings.push(format!(
"files: {key} is unreadable on the host and is not declared; it was not compared"
));
}
}
sort_findings(&mut findings);
finished(
CheckId::Files,
format!(
"{} declared tag unit(s) compared byte for byte with the live files",
expected.tag_units.len()
),
findings,
)
}
fn check_loaded(expected: &Expected, observed: &ObservedState) -> Check {
if observed.scan.scan_active {
return Check {
id: CheckId::Loaded,
status: CheckStatus::Warn,
detail: "not observed: a scan is running, so its log lines are not readable yet"
.to_string(),
findings: Vec::new(),
};
}
let lines = match &observed.logs {
Ok(lines) => lines,
Err(slug) => {
return finished(
CheckId::Loaded,
"the gateway log could not be read".to_string(),
vec![finding("logs_unavailable", "logs", printable(slug))],
);
}
};
let mut hits: BTreeMap<&str, (i64, &str)> = BTreeMap::new();
for line in lines {
if !line.message.contains(WILL_NOT_BE_LOADED) {
continue;
}
let owner = expected
.declared_ids
.iter()
.filter(|id| line.message.contains(&format!("Resource {id} ")))
.max_by_key(|id| id.len());
if let Some(id) = owner {
let entry = hits
.entry(id.as_str())
.or_insert((line.timestamp, line.logger.as_str()));
if line.timestamp < entry.0 {
*entry = (line.timestamp, line.logger.as_str());
}
}
}
let mut findings: Vec<Finding> = hits
.into_iter()
.map(|(id, (timestamp, logger))| {
finding(
"unloaded_resource",
id,
format!("logged at {timestamp} by `{}`", printable(logger)),
)
})
.collect();
sort_findings(&mut findings);
let detail = if findings.is_empty() {
format!(
"no `{WILL_NOT_BE_LOADED}` line for a declared resource since the last scan (the gateway logs it once per scan that finds the resource, and not for every type, so this covers the last scan only)"
)
} else {
format!("{} declared resource(s) were not loaded", findings.len())
};
finished(CheckId::Loaded, detail, findings)
}
fn check_probes(expected: &Expected, observed: &ObservedState) -> Check {
if expected.probes.is_empty() {
return Check {
id: CheckId::Probes,
status: CheckStatus::Warn,
detail: "the manifest declares no `verify.tag_probes`; nothing was probed".to_string(),
findings: vec![finding(
"tag_probes_absent",
"verify.tag_probes",
"add tag paths to the manifest to probe them after a deploy",
)],
};
}
let readings = match &observed.probes {
Some(Ok(readings)) => readings,
Some(Err(slug)) => {
return finished(
CheckId::Probes,
"the probes could not be read".to_string(),
vec![finding("tag_probes_unavailable", "tags", printable(slug))],
);
}
None => {
return finished(
CheckId::Probes,
"the probes could not be read".to_string(),
vec![finding("tag_probes_unavailable", "tags", "not read")],
);
}
};
let mut findings = Vec::new();
for path in &expected.probes {
match readings.iter().find(|r| &r.path == path) {
Some(r) if r.quality.starts_with("Good") => {}
Some(r) => findings.push(finding(
"tag_probe_not_good",
path.clone(),
format!("quality {}", quality_code(&r.quality)),
)),
None => findings.push(finding("tag_probe_not_good", path.clone(), "no reading")),
}
}
finished(
CheckId::Probes,
format!("{} tag probe(s) read", expected.probes.len()),
findings,
)
}
fn quality_code(quality: &str) -> String {
if quality.is_empty() {
return "empty".to_string();
}
let (class, rest) = match quality.split_once('_') {
Some((class, rest)) => (class, Some(rest)),
None => (quality, None),
};
if !matches!(class, "Good" | "Bad" | "Uncertain" | "Error") {
return "unrecognised".to_string();
}
match rest {
Some(rest)
if !rest.is_empty()
&& rest.len() <= 48
&& rest.chars().all(|c| c.is_ascii_alphabetic() || c == '_') =>
{
format!("{class}_{rest}")
}
_ => class.to_string(),
}
}
fn printable(text: &str) -> String {
let mut out: String = text
.chars()
.filter(|c| !c.is_control())
.take(MAX_CODE_CHARS)
.collect();
if text.chars().count() > MAX_CODE_CHARS {
out.push_str("...");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn words_equal_the_serialized_forms() {
for id in [
CheckId::Scan,
CheckId::Mode,
CheckId::ReadBack,
CheckId::Files,
CheckId::Loaded,
CheckId::Probes,
] {
assert_eq!(json!(id), json!(id.word()));
}
for status in [CheckStatus::Pass, CheckStatus::Fail, CheckStatus::Warn] {
assert_eq!(json!(status), json!(status.word()));
}
}
#[test]
fn quality_codes_carry_no_free_text() {
assert_eq!(quality_code("Good"), "Good");
assert_eq!(quality_code("Good_Provisional"), "Good_Provisional");
assert_eq!(quality_code("Bad_NotFound"), "Bad_NotFound");
assert_eq!(quality_code("Bad_secret-123"), "Bad");
assert_eq!(quality_code("Weird"), "unrecognised");
assert_eq!(quality_code(""), "empty");
}
#[test]
fn printable_drops_controls_and_caps_length() {
assert_eq!(printable("a\nb\u{7}c"), "abc");
let long = "x".repeat(MAX_CODE_CHARS + 10);
assert_eq!(printable(&long).len(), MAX_CODE_CHARS + 3);
}
#[test]
fn gateway_supplied_keys_are_sanitised_in_diff_paths() {
let hostile = format!("evil\u{1b}[31m{}", "k".repeat(MAX_CODE_CHARS * 2));
let mut rest = serde_json::Map::new();
rest.insert(hostile, json!(1));
let mut out = Vec::new();
diff_exact(&json!({}), &Value::Object(rest), "", &mut out);
assert_eq!(out.len(), 1);
assert!(!out[0].contains('\u{1b}'), "{:?}", out[0]);
assert!(
out[0].chars().count() <= MAX_CODE_CHARS + 3,
"{}",
out[0].len()
);
}
#[test]
fn large_integers_compare_exactly() {
let mut out = Vec::new();
diff_exact(
&json!({"id": 9_007_199_254_740_993_i64}),
&json!({"id": 9_007_199_254_740_992_i64}),
"",
&mut out,
);
assert_eq!(out, vec!["id".to_string()]);
let mut out = Vec::new();
diff_exact(
&json!({"id": u64::MAX}),
&json!({"id": u64::MAX - 1}),
"",
&mut out,
);
assert_eq!(out, vec!["id".to_string()]);
let mut out = Vec::new();
diff_exact(
&json!({"id": 9_007_199_254_740_993_i64}),
&json!({"id": 9_007_199_254_740_993_i64}),
"",
&mut out,
);
assert!(out.is_empty());
}
#[test]
fn numbers_compare_by_value() {
let mut out = Vec::new();
diff_exact(
&json!({"a": 1, "b": 2.5}),
&json!({"a": 1.0, "b": 2.5}),
"",
&mut out,
);
assert!(out.is_empty(), "{out:?}");
diff_exact(&json!({"a": 1}), &json!({"a": 2}), "", &mut out);
assert_eq!(out, vec!["a".to_string()]);
}
#[test]
fn path_list_is_sorted_deduplicated_and_capped() {
let paths: Vec<String> = (0..MAX_PATHS + 3)
.rev()
.map(|i| format!("p{i:02}"))
.collect();
let text = join_paths(paths);
assert!(text.starts_with("p00, p01"), "{text}");
assert!(text.ends_with("(+3 more)"), "{text}");
}
}