///|
/// A kind of change between two snapshots.
pub enum DiffKind {
Added
Removed
KindChanged
CaseChanged
} derive(Eq, Debug)
///|
pub extend DiffKind with Eq::{equal, not_equal}
///|
pub extend DiffKind with @debug.Debug::{to_repr}
///|
/// Stable serialized name for a change kind.
fn diff_kind_name(kind : DiffKind) -> String {
match kind {
Added => "added"
Removed => "removed"
KindChanged => "kind"
CaseChanged => "case"
}
}
///|
/// Stable serialization rank so the change list has a fixed group order.
fn diff_kind_rank(kind : DiffKind) -> Int {
match kind {
Added => 0
Removed => 1
KindChanged => 2
CaseChanged => 3
}
}
///|
pub struct SnapshotChange {
kind : DiffKind
path : String
paired_with : String
before_kind : String
after_kind : String
} derive(Eq, Debug)
///|
pub extend SnapshotChange with Eq::{equal, not_equal}
///|
pub extend SnapshotChange with @debug.Debug::{to_repr}
///|
pub struct SnapshotDiff {
complete : Bool
changes : Array[SnapshotChange]
} derive(Eq, Debug)
///|
pub extend SnapshotDiff with Eq::{equal, not_equal}
///|
pub extend SnapshotDiff with @debug.Debug::{to_repr}
///|
fn change_order(a : SnapshotChange, b : SnapshotChange) -> Int {
let rank_a = diff_kind_rank(a.kind)
let rank_b = diff_kind_rank(b.kind)
if rank_a != rank_b {
return rank_a.compare(rank_b)
}
ordinal_compare(a.path, b.path)
}
///|
/// Compare the entries of two snapshots. Exact path pairs come first; the
/// remaining entries are then compared by ASCII folding. A case change is only
/// reported when exactly one before entry folds to exactly one after entry with
/// the same kind. Renames and content changes are never inferred.
pub fn diff_snapshots(
before : Snapshot,
after : Snapshot,
) -> Result[SnapshotDiff, InputError] {
if before.scope.length() != after.scope.length() {
return Err({
code: "INPUT_SCHEMA",
message: "Snapshot scopes differ; cannot diff them.",
})
}
for i in 0.. return Err(error)
Ok(scope) => scope
}
for entry in before.entries {
if !entry_excluded(scope, entry) {
before_kinds[entry.path] = entry.kind
}
}
for entry in after.entries {
if !entry_excluded(scope, entry) {
after_kinds[entry.path] = entry.kind
}
}
let remaining_before : Array[(String, EntryKind)] = []
let remaining_after : Array[(String, EntryKind)] = []
let changes : Array[SnapshotChange] = []
for path in sorted_keys(before_kinds) {
let kind = before_kinds[path]
match after_kinds.get(path) {
None => remaining_before.push((path, kind))
Some(after_kind) =>
if kind != after_kind {
changes.push({
kind: KindChanged,
path,
paired_with: "",
before_kind: source_kind_name(kind),
after_kind: source_kind_name(after_kind),
})
}
}
}
for path in sorted_keys(after_kinds) {
let kind = after_kinds[path]
match before_kinds.get(path) {
None => remaining_after.push((path, kind))
Some(_) => ()
}
}
let before_folds : Map[String, Array[(String, EntryKind)]] = Map([])
let after_folds : Map[String, Array[(String, EntryKind)]] = Map([])
for (path, kind) in remaining_before {
let folded = ascii_lower(path)
let list = match before_folds.get(folded) {
None => []
Some(list) => list
}
before_folds[folded] = list + [(path, kind)]
}
for (path, kind) in remaining_after {
let folded = ascii_lower(path)
let list = match after_folds.get(folded) {
None => []
Some(list) => list
}
after_folds[folded] = list + [(path, kind)]
}
for folded in sorted_keys(before_folds) {
let before_list = before_folds[folded]
match after_folds.get(folded) {
None =>
for (path, kind) in before_list {
changes.push({
kind: Removed,
path,
paired_with: "",
before_kind: source_kind_name(kind),
after_kind: "",
})
}
Some(after_list) =>
if before_list.length() == 1 &&
after_list.length() == 1 &&
before_list[0].1 == after_list[0].1 {
changes.push({
kind: CaseChanged,
path: after_list[0].0,
paired_with: before_list[0].0,
before_kind: source_kind_name(before_list[0].1),
after_kind: source_kind_name(after_list[0].1),
})
} else {
for (path, kind) in before_list {
changes.push({
kind: Removed,
path,
paired_with: "",
before_kind: source_kind_name(kind),
after_kind: "",
})
}
for (path, kind) in after_list {
changes.push({
kind: Added,
path,
paired_with: "",
before_kind: "",
after_kind: source_kind_name(kind),
})
}
}
}
}
for folded in sorted_keys(after_folds) {
if before_folds.get(folded) is None {
for (path, kind) in after_folds[folded] {
changes.push({
kind: Added,
path,
paired_with: "",
before_kind: "",
after_kind: source_kind_name(kind),
})
}
}
}
changes.sort_by(change_order)
Ok({
complete: before.complete &&
after.complete &&
before.scan_issues.is_empty() &&
after.scan_issues.is_empty(),
changes,
})
}
///|
/// Stable serialization of a snapshot diff with fixed field order.
pub fn render_snapshot_diff_json(diff : SnapshotDiff) -> String {
"{\"format\":\"moonportcheck-diff\",\"version\":1,\"complete\":" +
diff.complete.to_json().stringify() +
",\"changes\":[" +
diff.changes
.map(change => {
"{\"kind\":" +
quote(diff_kind_name(change.kind)) +
",\"path\":" +
quote(change.path) +
",\"paired_with\":" +
quote(change.paired_with) +
",\"before_kind\":" +
quote(change.before_kind) +
",\"after_kind\":" +
quote(change.after_kind) +
"}"
})
.join(",") +
"]}"
}
///|
/// Stable human-readable diff. An incomplete input is flagged on the first
/// line so callers can see why the process exits 3 for a potentially stale diff.
pub fn render_snapshot_diff_text(diff : SnapshotDiff) -> String {
let warning = if diff.complete {
""
} else {
"INCOMPLETE: the input snapshot is incomplete; the diff is not trustworthy.\n"
}
let lines = diff.changes.map(change => {
match change.kind {
Added => "+ " + change.path + " (added, " + change.after_kind + ")"
Removed => "- " + change.path + " (removed, " + change.before_kind + ")"
KindChanged =>
"~ " +
change.path +
" (kind, " +
change.before_kind +
" -> " +
change.after_kind +
")"
CaseChanged =>
"~ " +
change.path +
" (case, " +
change.paired_with +
" -> " +
change.path +
")"
}
})
if lines.length() == 0 {
return warning + "No differences.\n"
}
warning + lines.join("\n") + "\n"
}
///|
/// Escape a table cell so pipes and newline characters cannot break rows.
fn md_cell(value : String) -> String {
let chars : Array[Char] = []
for char in value {
let code = char.to_int()
if code == 124 {
chars.push('\\')
chars.push('|')
} else if code == 10 {
chars.push('\\')
chars.push('n')
} else {
chars.push(char)
}
}
String::from_iter(chars.iter())
}
///|
/// Stable Markdown rendering of a snapshot diff. An incomplete input is flagged
/// with a block quote before the table.
pub fn render_snapshot_diff_markdown(diff : SnapshotDiff) -> String {
let warning = if diff.complete {
""
} else {
"> INCOMPLETE: the input snapshot is incomplete; the diff is not trustworthy.\n\n"
}
if diff.changes.is_empty() {
return "# MoonPortCheck diff\n\n" + warning + "No differences.\n"
}
let rows = diff.changes.map(change => {
match change.kind {
Added =>
"| added | `" +
md_cell(change.path) +
"` | added as `" +
md_cell(change.after_kind) +
"` |"
Removed =>
"| removed | `" +
md_cell(change.path) +
"` | removed as `" +
md_cell(change.before_kind) +
"` |"
KindChanged =>
"| kind | `" +
md_cell(change.path) +
"` | changed from `" +
md_cell(change.before_kind) +
"` to `" +
md_cell(change.after_kind) +
"` |"
CaseChanged =>
"| case | `" +
md_cell(change.path) +
"` | cased from `" +
md_cell(change.paired_with) +
"` |"
}
})
"# MoonPortCheck diff\n\n" +
warning +
"| Kind | Path | Details |\n" +
"| --- | --- | --- |\n" +
rows.join("\n") +
"\n"
}