///|
fn sheet_headers() -> Array[String] {
["format", "target", "kind", "base", "ours", "theirs", "choice", "value"]
}
///|
fn conflict_sheet_row(conflict : Conflict) -> Array[String] {
guard conflict.to_json() is Object(fields) &&
fields.get("kind") is Some(String(kind)) else {
abort("conflict object")
}
[
"mntablemerge/sheet-1",
conflict.target.to_json().stringify(),
kind,
values_json(conflict.base).stringify(),
values_json(conflict.ours).stringify(),
values_json(conflict.theirs).stringify(),
"",
"",
]
}
///|
/// Editable table of all current conflicts. Only choice/value may change.
/// The first binding record owns full canonical input snapshots and policies.
pub fn MergePlan::review_sheet(self : MergePlan) -> Table raise MergeError {
make_review_sheet(self, self.pending, None)
}
///|
fn make_review_sheet(
plan : MergePlan,
conflicts : Array[Conflict],
scope : Json?,
) -> Table raise MergeError {
if conflicts.length() >= max_rows {
raise LimitExceeded(
"review sheet supports at most 99999 conflicts plus its binding record; use the JSON workflow",
)
}
let binding = ReviewBundle::from_plan(plan).to_json().stringify()
let format = if scope == None {
"mntablemerge/sheet-1"
} else {
"mntablemerge/sheet-2"
}
let rows = [
[
format,
"",
"binding",
binding,
"",
"",
"",
scope.map(j => j.stringify()).unwrap_or(""),
],
]
for conflict in conflicts {
let row = conflict_sheet_row(conflict)
row[0] = format
rows.push(row)
}
// Refuse oversized evidence rather than omitting conflicts/source values.
Table::from_rows(sheet_headers(), rows)
}
///|
/// Atomically applies selected current conflicts from an input-bound sheet.
/// Blank choice means pending; choice=value with empty value is explicit empty.
/// Reordering conflict rows is safe; deletion/duplication/evidence edits fail.
pub fn MergePlan::apply_review_sheet(
self : MergePlan,
sheet : Table,
) -> MergePlan raise MergeError {
self.apply_checked_sheet(self.review_sheet(), sheet)
}
///|
fn MergePlan::apply_checked_sheet(
self : MergePlan,
expected : Table,
sheet : Table,
) -> MergePlan raise MergeError {
if sheet.columns != expected.columns ||
sheet.records.length() != expected.records.length() {
raise InvalidReview(
"sheet headers/conflict coverage changed; export a fresh sheet and keep all records",
)
}
if sheet.records[0] != expected.records[0] {
raise InvalidReview(
"sheet input binding changed or is stale; export a fresh sheet from the current bundle",
)
}
let evidence : Map[String, Array[String]] = Map([])
for row in expected.records[1:] {
evidence[row[1]] = row
}
let seen : Map[String, Bool] = Map([])
let choices : Map[String, Choice] = Map([])
for row in sheet.records[1:] {
let target = row[1]
if seen.contains(target) {
raise InvalidReview(
"duplicate sheet target \{target}; retain one row per conflict",
)
}
seen[target] = true
guard evidence.get(target) is Some(original) else {
raise InvalidReview(
"unknown sheet target \{target}; do not change coordinates",
)
}
for i in 0..<6 {
if row[i] != original[i] {
raise InvalidReview(
"sheet evidence changed for \{target}; edit only choice/value",
)
}
}
if row[6] == "value" {
choices[target] = Value(row[7])
} else {
if row[7] != "" {
raise InvalidReview(
"custom text requires choice=value at \{target}; otherwise clear value explicitly",
)
}
if row[6] != "" {
choices[target] = parse_choice(Json::string(row[6]))
}
}
}
let decisions : Array[Decision] = []
// Canonical conflict order makes the result independent of sheet row order.
for conflict in self.pending {
if choices.get(conflict.target.to_json().stringify()) is Some(choice) {
decisions.push({ target: conflict.target, choice, })
}
}
resolve(self, decisions)
}