///|
/// Stable review coordinates. Key components remain an array.
pub fn Target::to_json(self : Target) -> Json {
match self {
Column(name) => { "column": name }
Row(key) => { "row": Json::array(key.parts.map(Json::string)) }
Cell(key, name) =>
{ "row": Json::array(key.parts.map(Json::string)), "column": name }
}
}
///|
fn Choice::to_json(self : Choice) -> Json {
match self {
Keep => "keep"
Drop => "drop"
Base => "base"
Ours => "ours"
Theirs => "theirs"
Value(value) => { "value": value }
}
}
///|
fn values_json(values : Array[CellValue]) -> Json {
Json::array(
values.map(v => {
"row": Json::array(v.key.parts.map(Json::string)),
"column": v.column,
"value": v.value,
}),
)
}
///|
fn Conflict::to_json(self : Conflict) -> Json {
let kind = match self.kind {
ColumnDeleteEdit => "ColumnDeleteEdit"
RowDeleteEdit => "RowDeleteEdit"
ConcurrentInsert => "ConcurrentInsert"
CellEdit => "CellEdit"
MissingCell => "MissingCell"
}
let hint = match self.kind {
ColumnDeleteEdit | RowDeleteEdit =>
"Choose keep/drop for the whole structure; inspect all affected source values."
ConcurrentInsert => "Choose ours/theirs/drop for the entire inserted row."
CellEdit => "Choose base/ours/theirs or supply {value: string}."
MissingCell =>
"This intersection has no selected source value; supply a value or explicitly choose an available source."
}
{
"target": self.target.to_json(),
"kind": kind,
"base": values_json(self.base),
"ours": values_json(self.ours),
"theirs": values_json(self.theirs),
"hint": hint,
}
}
///|
/// JSON audit report; incomplete plans contain no candidate CSV output.
pub fn MergePlan::report(self : MergePlan) -> Json {
let report : Json = {
"format": "mntablemerge/report-1",
"complete": self.output != None,
"conflicts": Json::array(self.pending.map(c => c.to_json())),
"decisions": decisions_json(self.decisions_data),
"resolutions": Json::array(
self.evidence.map(r => {
"conflict": r.conflict.to_json(),
"choice": r.choice.to_json(),
}),
),
}
if !self.lineage_data.is_empty() {
guard report is Object(fields) else { abort("object literal") }
fields["format"] = "mntablemerge/report-2"
fields["column_lineage"] = lineage_json(self.lineage_data)
fields["row_order"] = Json::string(self.ordering.name())
}
if !self.row_lineage_data.is_empty() {
guard report is Object(fields) else { abort("object literal") }
fields["format"] = "mntablemerge/report-3"
fields["column_lineage"] = lineage_json(self.lineage_data)
fields["row_lineage"] = row_lineage_json_unchecked(self.row_lineage_data)
fields["row_order"] = Json::string(self.ordering.name())
}
if !self.evolution_data.is_empty() {
guard report is Object(fields) else { abort("object literal") }
fields["format"] = "mntablemerge/report-4"
fields["column_lineage"] = lineage_json(self.lineage_data)
fields["row_lineage"] = row_lineage_json_unchecked(self.row_lineage_data)
fields["row_order"] = Json::string(self.ordering.name())
fields["column_evolution"] = evolution_json(self.evolution_data)
}
report
}
///|
pub fn decisions_json(decisions : Array[Decision]) -> Json {
Json::array(
decisions.map(d => {
"target": d.target.to_json(),
"choice": d.choice.to_json(),
}),
)
}
///|
/// Unknown fields and non-string values are rejected instead of ignored.
pub fn parse_decisions(source : String) -> Array[Decision] raise MergeError {
if source.length() > max_chars {
raise LimitExceeded("decision JSON is too large")
}
let value = checked_json(source)
guard value is Array(items) else {
raise InvalidDecision("expected a JSON array")
}
if items.length() > max_cells {
raise LimitExceeded("too many decisions")
}
items.map(item => {
guard item is Object(fields) else {
raise InvalidDecision("each decision must be an object")
}
guard fields.length() == 2 &&
fields.get("target") is Some(target) &&
fields.get("choice") is Some(choice) else {
raise InvalidDecision(
"each decision must have exactly target and choice fields",
)
}
let target = parse_target(target)
let choice = parse_choice(choice)
Decision::{ target, choice, }
})
}
///|
fn parse_choice(choice : Json) -> Choice raise MergeError {
match choice {
String("keep") => Keep
String("drop") => Drop
String("base") => Base
String("ours") => Ours
String("theirs") => Theirs
Object(fields) => {
guard fields.length() == 1 && fields.get("value") is Some(String(value)) else {
raise InvalidDecision("custom choice must be exactly {value: string}")
}
Value(value)
}
_ =>
raise InvalidDecision(
"unknown choice; use keep/drop/base/ours/theirs or {value: string}",
)
}
}
///|
fn parse_target(value : Json) -> Target raise MergeError {
guard value is Object(fields) else {
raise InvalidDecision("target must be an object")
}
let key = match fields.get("row") {
None => None
Some(Array(parts)) => {
if parts.is_empty() {
raise InvalidDecision("row key must contain components")
}
Some(
RowKey(
parts.map(p => {
match p {
String(s) => s
_ => raise InvalidDecision("row key components must be strings")
}
}),
),
)
}
_ => raise InvalidDecision("row must be an array of key strings")
}
let column = match fields.get("column") {
None => None
Some(String(name)) => Some(name)
_ => raise InvalidDecision("column must be a string")
}
match (key, column, fields.length()) {
(None, Some(name), 1) => Column(name)
(Some(key), None, 1) => Row(key)
(Some(key), Some(name), 2) => Cell(key, name)
_ => raise InvalidDecision("target requires only row and/or column fields")
}
}