///|
async fn read_text(path : String) -> String {
let file = @fs.open(path, mode=ReadOnly)
defer file.close()
let buffer = @buffer.Buffer()
while file.read_some(max_len=65536) is Some(chunk) {
if buffer.length() + chunk.length() > 64000000 {
fail("Input exceeds 64000000 bytes: \{path}")
}
buffer.write_bytes(chunk)
}
@utf8.decode(buffer.to_bytes())
}
///|
async fn run(args : Array[String]) -> Int {
if args.get(0) is Some(command) && command.has_prefix("unique-") {
return unique_command(args)
}
if args.length() == 6 &&
args[0] == "convert" &&
args[2] == "--from" &&
args[4] == "--to" {
let source_format = Format::parse(args[3])
let output_format = Format::parse(args[5])
@stdio.stdout.write(
output_format.write(source_format.read(read_text(args[1]))),
)
return 0
}
if args.get(0) == Some("git-driver") {
return git_driver(args)
}
if args == ["--help"] {
@stdio.stdout.write(
"Uniqueness: review -> unique-review BUNDLE RULES.json -> unique-repair UNIQUE EDITS.json -> unique-replay BASE OURS THEIRS --key NAME --review UNIQUE.\nunique-revise edits ordinary choices; unique-report and unique-changes export full evidence.\n",
)
@stdio.stdout.write(
" mntablemerge review-changes BEFORE.json AFTER.json\nCompare recorded decisions with complete before/after source evidence; different inputs/policies fail.\n",
)
@stdio.stdout.write(
" mntablemerge review-combine BASELINE REVIEW [REVIEW ...]\nAgreeing additions coalesce; disagreement, changed baseline choices and stale inputs fail.\n",
)
@stdio.stdout.write(
" mntablemerge review-sheet-for BUNDLE TARGETS.json\n mntablemerge review-apply-sheet-for BUNDLE TARGETS.json EDITED.tsv\nAssignment is an exact array of current conflict coordinates.\n",
)
@stdio.stdout.write(
" mntablemerge review-sheet BUNDLE\n mntablemerge review-apply-sheet BUNDLE EDITED.tsv\nEdit only choice/value; preserve the binding row and every conflict's evidence.\n",
)
@stdio.stdout.write(
" mntablemerge convert INPUT --from csv|tsv --to csv|tsv\n",
)
@stdio.stdout.write(
"merge/review/replay/git-driver accept --format csv|tsv (default csv), for all three inputs and merged output. Review bundles store canonical CSV snapshots.\n",
)
@stdio.stdout.write(
"Usage: mntablemerge merge|review BASE OURS THEIRS --key NAME[,NAME] [--decisions FILE] [--report]\n mntablemerge review-resolve BUNDLE DECISIONS\n mntablemerge replay BASE OURS THEIRS --key NAME[,NAME] --review BUNDLE [--report]\n mntablemerge git-driver BASE OURS THEIRS --key NAME[,NAME]\nExit: merge/replay/driver 0 complete, 1 unresolved, 2 invalid input. review commands emit a validated bundle and exit 0 even if review is incomplete.\n",
)
@stdio.stdout.write(
"merge/review/git-driver also accept --row-order lexical|base|ours|theirs, --columns LINEAGE.json and --rows ROWS.json. replay uses the bundle's policy.\n",
)
@stdio.stdout.write(
"merge/replay accept --output FILE to publish only a complete table to a new file, without replacing existing files. Cannot be combined with --report.\n",
)
@stdio.stdout.write(
"merge/review/git-driver accept --column-evolution FILE for explicit column rename/delete correspondence; null marks an absent branch. replay binds it in the bundle.\n",
)
@stdio.stdout.write(
" mntablemerge review-revise BUNDLE EDITS\nEdits: set a recorded/current conflict choice or revoke a recorded choice; dependent invalidated choices require explicit revocation.\n",
)
return 0
}
if args.length() == 3 && args[0] == "review-resolve" {
let bundle = @tablemerge.ReviewBundle::from_json(read_text(args[1]))
let resolved = bundle.resolve(
@tablemerge.parse_decisions(read_text(args[2])),
)
@stdio.stdout.write(resolved.to_json().stringify() + "\n")
return 0
}
if args.length() == 3 && args[0] == "review-revise" {
let bundle = @tablemerge.ReviewBundle::from_json(read_text(args[1]))
let revised = bundle.revise(
@tablemerge.parse_review_edits(read_text(args[2])),
)
@stdio.stdout.write(revised.to_json().stringify() + "\n")
return 0
}
if args.length() == 3 && args[0] == "review-changes" {
let before = @tablemerge.ReviewBundle::from_json(read_text(args[1]))
let after = @tablemerge.ReviewBundle::from_json(read_text(args[2]))
@stdio.stdout.write(after.changes_report(before).stringify() + "\n")
return 0
}
if args.length() >= 3 && args[0] == "review-combine" {
if args.length() > 258 {
fail("review-combine accepts at most 256 review files")
}
let baseline = @tablemerge.ReviewBundle::from_json(read_text(args[1]))
let reviews : Array[@tablemerge.ReviewBundle] = []
for path in args[2:] {
reviews.push(@tablemerge.ReviewBundle::from_json(read_text(path)))
}
@stdio.stdout.write(baseline.combine(reviews).to_json().stringify() + "\n")
return 0
}
if args.length() == 3 && args[0] == "review-sheet-for" {
let bundle = @tablemerge.ReviewBundle::from_json(read_text(args[1]))
let targets = @tablemerge.parse_review_targets(read_text(args[2]))
@stdio.stdout.write(bundle.plan().review_sheet_for(targets).to_tsv())
return 0
}
if args.length() == 4 && args[0] == "review-apply-sheet-for" {
let bundle = @tablemerge.ReviewBundle::from_json(read_text(args[1]))
let targets = @tablemerge.parse_review_targets(read_text(args[2]))
let sheet = @tablemerge.parse_tsv(read_text(args[3]))
let updated = bundle.plan().apply_review_sheet_for(targets, sheet)
@stdio.stdout.write(
@tablemerge.ReviewBundle::from_plan(updated).to_json().stringify() + "\n",
)
return 0
}
if args.length() == 2 && args[0] == "review-sheet" {
let bundle = @tablemerge.ReviewBundle::from_json(read_text(args[1]))
@stdio.stdout.write(bundle.plan().review_sheet().to_tsv())
return 0
}
if args.length() == 3 && args[0] == "review-apply-sheet" {
let bundle = @tablemerge.ReviewBundle::from_json(read_text(args[1]))
let sheet = @tablemerge.parse_tsv(read_text(args[2]))
let updated = bundle.plan().apply_review_sheet(sheet)
@stdio.stdout.write(
@tablemerge.ReviewBundle::from_plan(updated).to_json().stringify() + "\n",
)
return 0
}
guard args.length() >= 6 &&
(args[0] == "merge" || args[0] == "review" || args[0] == "replay") &&
args[4] == "--key" else {
fail(
"Expected: merge/review/replay BASE OURS THEIRS --key NAME[,NAME]; use --help",
)
}
let keys = args[5].split(",").map(s => s.to_owned()).to_array()
let mut decisions : String? = None
let mut report = false
let mut review_path : String? = None
let mut row_order : @tablemerge.RowOrder? = None
let mut columns_path : String? = None
let mut rows_path : String? = None
let mut format : Format? = None
let mut output_path : String? = None
let mut evolution_path : String? = None
let mut i = 6
while i < args.length() {
match args[i] {
"--column-evolution" => {
if evolution_path != None || i + 1 >= args.length() {
fail("--column-evolution requires exactly one JSON file")
}
evolution_path = Some(args[i + 1])
i += 2
}
"--output" => {
if output_path != None ||
i + 1 >= args.length() ||
args[i + 1].is_empty() {
fail("--output requires exactly one nonempty destination file")
}
output_path = Some(args[i + 1])
i += 2
}
"--format" => {
if format != None || i + 1 >= args.length() {
fail("--format requires exactly one csv/tsv value")
}
format = Some(Format::parse(args[i + 1]))
i += 2
}
"--rows" => {
if rows_path != None || i + 1 >= args.length() {
fail("--rows requires exactly one row-lineage JSON file")
}
rows_path = Some(args[i + 1])
i += 2
}
"--columns" => {
if columns_path != None || i + 1 >= args.length() {
fail("--columns requires exactly one column-lineage JSON file")
}
columns_path = Some(args[i + 1])
i += 2
}
"--row-order" => {
if row_order != None || i + 1 >= args.length() {
fail(
"--row-order requires exactly one lexical/base/ours/theirs value",
)
}
row_order = Some(@tablemerge.RowOrder::parse(args[i + 1]))
i += 2
}
"--review" => {
if review_path != None || i + 1 >= args.length() {
fail("--review requires exactly one bundle file")
}
review_path = Some(args[i + 1])
i += 2
}
"--decisions" => {
if decisions != None || i + 1 >= args.length() {
fail("--decisions requires exactly one file")
}
decisions = Some(args[i + 1])
i += 2
}
"--report" => {
if report {
fail("Duplicate --report")
}
report = true
i += 1
}
_ => fail("Unknown option: \{args[i]}")
}
}
if output_path != None && (args[0] == "review" || report) {
fail(
"--output is for complete merge/replay tables; it cannot be used with review or --report",
)
}
if args[0] == "replay" {
if review_path == None ||
decisions != None ||
row_order != None ||
columns_path != None ||
rows_path != None ||
evolution_path != None {
fail(
"replay requires --review; decisions, column evolution, row/column lineage and row order are bound by the bundle",
)
}
} else if review_path != None {
fail("--review is only supported by replay")
}
if args[0] == "review" && report {
fail("review emits a bundle; --report is for merge/replay")
}
let transport = format.unwrap_or(Csv)
let base = transport.read(read_text(args[1]))
let ours = transport.read(read_text(args[2]))
let theirs = transport.read(read_text(args[3]))
let lineage = match columns_path {
None => []
Some(path) => @tablemerge.parse_column_lineage(read_text(path))
}
let rows = match rows_path {
None => []
Some(path) => @tablemerge.parse_row_lineage(read_text(path))
}
let evolution = match evolution_path {
None => []
Some(path) => @tablemerge.parse_column_evolution(read_text(path))
}
let initial = match review_path {
Some(path) =>
@tablemerge.ReviewBundle::from_json(read_text(path)).replay(
base,
ours,
theirs,
key=keys,
)
None =>
@tablemerge.merge(
base,
ours,
theirs,
key=keys,
row_order=row_order.unwrap_or(@tablemerge.Lexical),
column_lineage=lineage,
row_lineage=rows,
column_evolution=evolution,
)
}
let plan = match decisions {
None => initial
Some(path) =>
@tablemerge.resolve(initial, @tablemerge.parse_decisions(read_text(path)))
}
if args[0] == "review" {
@stdio.stdout.write(
@tablemerge.ReviewBundle::from_plan(plan).to_json().stringify() + "\n",
)
return 0
}
if report {
@stdio.stdout.write(plan.report().stringify() + "\n")
} else if plan.conflicts().is_empty() {
let text = transport.write(plan.finish().table())
match output_path {
Some(path) => write_new_output(path, text)
None => @stdio.stdout.write(text)
}
} else {
@stdio.stderr.write(plan.report().stringify() + "\n")
}
if plan.conflicts().is_empty() {
0
} else {
1
}
}
///|
async fn main {
let status = run(@env.args()[1:].to_owned()) catch {
@tablemerge.MergeError::_ as error => {
@stdio.stderr.write("Input error: " + error.diagnostic() + "\n")
2
}
error => {
@stdio.stderr.write(
"Input/IO error: \{error}; check the arguments, UTF-8 encoding and readable file paths\n",
)
2
}
}
@sys.exit(status)
}