///|
/// An offline review owns all three exact parsed snapshots, keys and decisions.
/// It is a revision guard, not a signature or trust boundary.
pub struct ReviewBundle {
priv base : Table
priv ours : Table
priv theirs : Table
priv keys : Array[String]
priv choices : Array[Decision]
priv ordering : RowOrder
priv lineage : Array[ColumnLineage]
priv rows : Array[RowLineage]
priv evolution : Array[ColumnEvolution]
}
///|
pub fn ReviewBundle::from_plan(plan : MergePlan) -> ReviewBundle {
{
base: plan.base.snapshot(),
ours: plan.ours.snapshot(),
theirs: plan.theirs.snapshot(),
keys: plan.key_columns.copy(),
choices: plan.decisions(),
ordering: plan.ordering,
lineage: plan.column_lineage(),
rows: plan.row_lineage(),
evolution: plan.column_evolution(),
}
}
///|
/// Recomputes every conflict and checks every supplied decision.
pub fn ReviewBundle::plan(self : ReviewBundle) -> MergePlan raise MergeError {
calculate(
self.base,
self.ours,
self.theirs,
self.keys,
self.choices,
self.ordering,
lineage=self.lineage,
rows=self.rows,
evolution=self.evolution,
)
}
///|
/// Adds decisions without modifying the existing bundle. Structure can be
/// resolved and serialized before newly exposed cell conflicts are reviewed.
pub fn ReviewBundle::resolve(
self : ReviewBundle,
decisions : Array[Decision],
) -> ReviewBundle raise MergeError {
ReviewBundle::from_plan(resolve(self.plan(), decisions))
}
///|
/// Only replays against the snapshots actually reviewed. CSV whitespace/quote
/// style/BOM differences are tolerated after parsing; row/header order is bound.
pub fn ReviewBundle::replay(
self : ReviewBundle,
base : Table,
ours : Table,
theirs : Table,
key~ : Array[String],
) -> MergePlan raise MergeError {
if base != self.base ||
ours != self.ours ||
theirs != self.theirs ||
key != self.keys {
raise InvalidReview(
"input snapshots or key selection changed; regenerate review and inspect conflicts again",
)
}
self.plan()
}
///|
/// Refuses an oversized export rather than emitting a bundle it cannot read.
pub fn ReviewBundle::to_json(self : ReviewBundle) -> Json raise MergeError {
let value : Json = {
"format": "mntablemerge/review-1",
"base": self.base.to_csv(),
"ours": self.ours.to_csv(),
"theirs": self.theirs.to_csv(),
"key": Json::array(self.keys.map(Json::string)),
"decisions": decisions_json(self.choices),
}
if self.ordering != Lexical {
guard value is Object(fields) else { abort("object literal") }
fields["format"] = "mntablemerge/review-2"
fields["row_order"] = Json::string(self.ordering.name())
}
if !self.lineage.is_empty() {
guard value is Object(fields) else { abort("object literal") }
fields["format"] = "mntablemerge/review-3"
fields["row_order"] = Json::string(self.ordering.name())
fields["column_lineage"] = lineage_json(self.lineage)
}
if !self.rows.is_empty() {
guard value is Object(fields) else { abort("object literal") }
fields["format"] = "mntablemerge/review-4"
fields["row_order"] = Json::string(self.ordering.name())
fields["column_lineage"] = lineage_json(self.lineage)
fields["row_lineage"] = row_lineage_json_unchecked(self.rows)
}
if !self.evolution.is_empty() {
guard value is Object(fields) else { abort("object literal") }
fields["format"] = "mntablemerge/review-5"
fields["row_order"] = Json::string(self.ordering.name())
fields["column_lineage"] = lineage_json(self.lineage)
fields["row_lineage"] = row_lineage_json_unchecked(self.rows)
fields["column_evolution"] = evolution_json(self.evolution)
}
if value.stringify().length() > max_chars {
raise LimitExceeded("review bundle exceeds 16000000 UTF-16 code units")
}
value
}
///|
/// Strict, versioned import. Revalidates snapshots, keys and cumulative
/// decisions; accepts incomplete review but never an unsupported field/version.
pub fn ReviewBundle::from_json(
source : String,
) -> ReviewBundle raise MergeError {
let value = checked_json(source)
guard value is Object(fields) else {
raise InvalidReview(
"expected exactly format/base/ours/theirs/key/decisions",
)
}
let ordering = match fields.get("format") {
Some(String("mntablemerge/review-1")) if fields.length() == 6 => Lexical
Some(String("mntablemerge/review-2")) if fields.length() == 7 => {
guard fields.get("row_order") is Some(String(order)) else {
raise InvalidReview("review-2 requires a string row_order")
}
RowOrder::parse(order)
}
Some(String("mntablemerge/review-3")) if fields.length() == 8 => {
guard fields.get("row_order") is Some(String(order)) else {
raise InvalidReview("review-3 requires a string row_order")
}
RowOrder::parse(order)
}
Some(String("mntablemerge/review-4")) if fields.length() == 9 => {
guard fields.get("row_order") is Some(String(order)) else {
raise InvalidReview("review-4 requires a string row_order")
}
RowOrder::parse(order)
}
Some(String("mntablemerge/review-5")) if fields.length() == 10 => {
guard fields.get("row_order") is Some(String(order)) else {
raise InvalidReview("review-5 requires a string row_order")
}
RowOrder::parse(order)
}
_ =>
raise InvalidReview(
"unsupported review format/fields; use review-1/review-2/review-3/review-4/review-5",
)
}
guard fields.get("base") is Some(String(b)) &&
fields.get("ours") is Some(String(o)) &&
fields.get("theirs") is Some(String(t)) &&
fields.get("key") is Some(Array(keys)) &&
fields.get("decisions") is Some(decisions) else {
raise InvalidReview(
"snapshots must be CSV strings, key a string array, decisions an array",
)
}
let keys = keys.map(k => {
match k {
String(name) => name
_ => raise InvalidReview("key columns must be strings")
}
})
let lineage = if fields.get("format") ==
Some(Json::string("mntablemerge/review-3")) ||
fields.get("format") == Some(Json::string("mntablemerge/review-4")) ||
fields.get("format") == Some(Json::string("mntablemerge/review-5")) {
guard fields.get("column_lineage") is Some(value) else {
raise InvalidReview("review-3 requires column_lineage")
}
parse_column_lineage(value.stringify())
} else {
[]
}
let rows = if fields.get("format") ==
Some(Json::string("mntablemerge/review-4")) ||
fields.get("format") == Some(Json::string("mntablemerge/review-5")) {
guard fields.get("row_lineage") is Some(value) else {
raise InvalidReview("review-4 requires row_lineage")
}
parse_row_lineage(value.stringify())
} else {
[]
}
let plan = merge(
parse_csv(b),
parse_csv(o),
parse_csv(t),
key=keys,
row_order=ordering,
column_lineage=lineage,
row_lineage=rows,
column_evolution=if fields.get("format") ==
Some(Json::string("mntablemerge/review-5")) {
guard fields.get("column_evolution") is Some(value) else {
raise InvalidReview("review-5 requires column_evolution")
}
parse_column_evolution(value.stringify())
} else {
[]
},
)
ReviewBundle::from_plan(resolve(plan, parse_decisions(decisions.stringify())))
}