diff --git a/dub.json b/dub.json index d9c5233..90807f2 100644 --- a/dub.json +++ b/dub.json @@ -16,5 +16,33 @@ "repository": "git+https://git.zhirov.kz/dlang/xdiff.git", "version": "e2396bc172eba813cdcd1a96c494e35d687f576a" } + }, + "configurations": [ + { + "name": "lib", + "targetType": "library", + "sourcePaths": [ + "source" + ], + "excludedSourceFiles": [ + "source/app.d" + ] + }, + { + "name": "app", + "targetType": "executable", + "mainSourceFile": "source/app.d", + "sourcePaths": [ + "source" + ] + } + ], + "buildTypes": { + "unittest": { + "dflags": [ + "-unittest", + "-g" + ] + } } } \ No newline at end of file diff --git a/dub.selections.json b/dub.selections.json new file mode 100644 index 0000000..0b7884b --- /dev/null +++ b/dub.selections.json @@ -0,0 +1,6 @@ +{ + "fileVersion": 1, + "versions": { + "xdiff": {"version":"e2396bc172eba813cdcd1a96c494e35d687f576a","repository":"git+https://git.zhirov.kz/dlang/xdiff.git"} + } +} diff --git a/source/app.d b/source/app.d index 30e9f34..aaec5e6 100644 --- a/source/app.d +++ b/source/app.d @@ -1,25 +1,16 @@ -import std.stdio : writeln, stderr; -import std.exception : collectException; +import std.stdio; +import xdiff_mmfile : MMFile; -import diff; - -int main(string[] args) +void main() { - if (args.length != 3) - { - stderr.writeln("use: ", args[0], " OLD NEW"); - return 1; - } + auto a = MMFile.fromBytes(cast(ubyte[])"hello world\n"); + auto b = MMFile.fromBytes(cast(ubyte[])"hello world!\n"); - auto eng = new DiffEngine(3); + auto patch = a.computePatch(b); + writeln("patch size: ", patch.size()); - string result; // сюда положим результат - auto ex = collectException(result = eng.diffFiles(args[1], args[2])); - if (ex !is null) - { - stderr.writeln(ex.msg); - return 2; - } - writeln(result); - return 0; + auto res = a.applyPatch(patch); + writeln("apply success: ", res.success); + write(res.patched.asSlice()); // печатаем результат + writeln(); } diff --git a/source/diff.d b/source/diff.d deleted file mode 100644 index d3b1310..0000000 --- a/source/diff.d +++ /dev/null @@ -1,197 +0,0 @@ -module diff; - -import std.exception : enforce; -import std.file : read, exists; // бинарное чтение -import std.array : Appender, appender; -import std.string : representation, format; -import std.datetime : Clock, UTC; - -import core.stdc.string : memcpy; -import core.stdc.stdlib : malloc, free, realloc; - -import xdiff; - -//----------------------------- -// Аллокатор для libxdiff (обязателен на ряде сборок) -//----------------------------- -private extern (C) void* _wrap_malloc(void*, uint size) -{ - return malloc(size); -} - -private extern (C) void _wrap_free(void*, void* ptr) -{ - free(ptr); -} - -private extern (C) void* _wrap_realloc(void*, void* ptr, uint size) -{ - return realloc(ptr, size); -} - -private __gshared bool _allocatorReady = false; - -private void ensureAllocator() -{ - if (_allocatorReady) - return; - memallocator_t malt; - malt.priv = null; - malt.malloc = &_wrap_malloc; - malt.free = &_wrap_free; - malt.realloc = &_wrap_realloc; - enforce(xdl_set_allocator(&malt) == 0, "xdl_set_allocator failed"); - _allocatorReady = true; -} - -//----------------------------- -// RAII для mmfile_t -//----------------------------- -private struct MmFile -{ - mmfile_t mf; - - static MmFile fromBytes(const(ubyte)[] data, - long bsize = 8 * 1024, - ulong flags = XDL_MMF_ATOMIC) - { - ensureAllocator(); // критично: до init/writeallocate - MmFile m; - enforce(xdl_init_mmfile(&m.mf, bsize, flags) == 0, "xdl_init_mmfile failed"); - - if (data.length) - { - auto dst = cast(char*) xdl_mmfile_writeallocate(&m.mf, data.length); - enforce(dst !is null, "xdl_mmfile_writeallocate failed"); - memcpy(dst, data.ptr, data.length); - } - return m; - } - - ~this() - { - xdl_free_mmfile(&mf); - } -} - -//----------------------------- -// Синк-коллектор вывода libxdiff -//----------------------------- -private class BufferSink -{ - Appender!(ubyte[]) outbuf; - - this() - { - outbuf = appender!(ubyte[])(); - } - - extern (C) static int writeCB(void* priv, mmbuffer_t* mb, int nbuf) - { - auto self = cast(BufferSink) priv; - foreach (i; 0 .. nbuf) - { - size_t sz = cast(size_t) mb[i].size; - if (sz == 0) - continue; - self.outbuf.put((cast(ubyte*) mb[i].ptr)[0 .. sz]); - } - return 0; - } - - string toStringOwned() @trusted - { - return cast(string) outbuf.data.idup; - } -} - -//----------------------------- -// Публичный движок unified-diff -//----------------------------- -final class DiffEngine -{ - private long _ctxlen; - private bool _stripTrailingNewline; - - this(long ctxlen = 3, bool stripTrailingNewline = false) - { - _ctxlen = ctxlen; - _stripTrailingNewline = stripTrailingNewline; - } - - /// Diff двух текстов (UTF-8 строки) - string diffText(string oldText, string newText, - string oldLabel = "old", string newLabel = "new") - { - const(ubyte)[] a = _prep(oldText); - const(ubyte)[] b = _prep(newText); - return diffBytes(a, b, oldLabel, newLabel); - } - - /// Diff по путям (файлы читаются целиком, бинарно) - string diffFiles(string oldPath, string newPath, - string oldLabel = "", string newLabel = "") - { - enforce(exists(oldPath), "no such file: " ~ oldPath); - enforce(exists(newPath), "no such file: " ~ newPath); - - auto a = cast(const(ubyte)[]) read(oldPath); // бинарное чтение - auto b = cast(const(ubyte)[]) read(newPath); - - if (oldLabel.length == 0) - oldLabel = oldPath; - if (newLabel.length == 0) - newLabel = newPath; - - return diffBytes(a, b, oldLabel, newLabel); - } - - /// Базовый метод: diff двух буферов - string diffBytes(const(ubyte)[] oldBytes, const(ubyte)[] newBytes, - string oldLabel = "old", string newLabel = "new") - { - auto a = MmFile.fromBytes(oldBytes); - auto b = MmFile.fromBytes(newBytes); - - auto sink = new BufferSink; - xdemitcb_t ecb; - ecb.priv = cast(void*) sink; - ecb.outf = &BufferSink.writeCB; - - xpparam_t xpp; - xpp.flags = 0; - - xdemitconf_t xecfg; - xecfg.ctxlen = _ctxlen; - - auto pre = formatHeaders(oldLabel, newLabel); - - auto rc = xdl_diff(&a.mf, &b.mf, &xpp, &xecfg, &ecb); - enforce(rc >= 0, format("xdl_diff failed (rc=%s)", rc)); - - return pre ~ sink.toStringOwned(); - } - - static string formatHeaders(string oldLabel, string newLabel) - { - auto ts = Clock.currTime(UTC()).toISOExtString(); // YYYY-MM-DDTHH:MM:SS - return "--- " ~ oldLabel ~ "\t" ~ ts ~ "\n" - ~ "+++ " ~ newLabel ~ "\t" ~ ts ~ "\n"; - } - -private: - const(ubyte)[] _prep(string s) @trusted - { - auto bytes = cast(const(ubyte)[]) s.representation; - if (_stripTrailingNewline && (bytes.length && bytes[$ - 1] != '\n')) - { - ubyte[] tmp; - tmp.length = bytes.length + 1; - if (bytes.length) - memcpy(tmp.ptr, bytes.ptr, bytes.length); - tmp[$ - 1] = cast(ubyte) '\n'; - return cast(const(ubyte)[]) tmp; - } - return bytes; - } -} diff --git a/source/xdiff_init.d b/source/xdiff_init.d new file mode 100644 index 0000000..d73cc09 --- /dev/null +++ b/source/xdiff_init.d @@ -0,0 +1,33 @@ +module xdiff_init; + +import core.stdc.stdlib : malloc, free, realloc; +import std.exception : enforce; +import xdiff; // твой низкоуровневый биндинг (xdiff.d) + +extern (C) private void* wrap_malloc(void*, uint size) { return malloc(size); } +extern (C) private void wrap_free (void*, void* p) { free(p); } +extern (C) private void* wrap_realloc(void*, void* p, uint size){ return realloc(p, size); } + +void ensureInit() +{ + // локальная статика инициализируется один раз на процесс + static bool done = false; + if (done) return; + + memallocator_t m; + m.priv = null; + m.malloc = &wrap_malloc; + m.free = &wrap_free; + m.realloc = &wrap_realloc; + + enforce(xdl_set_allocator(&m) == 0, "xdl_set_allocator failed"); + done = true; +} + +mmfile_t initMmfile(size_t len) +{ + ensureInit(); + mmfile_t mf; + enforce(xdl_init_mmfile(&mf, cast(long)len, XDL_MMF_ATOMIC) == 0, "xdl_init_mmfile failed"); + return mf; +} diff --git a/source/xdiff_mmblocks.d b/source/xdiff_mmblocks.d new file mode 100644 index 0000000..81eeec0 --- /dev/null +++ b/source/xdiff_mmblocks.d @@ -0,0 +1,108 @@ +module xdiff_mmblocks; + +import std.exception : enforce; +import xdiff_init : ensureInit, initMmfile; +import xdiff; + +final class MMBlocks +{ +private: + mmfile_t _inner; + bool _owned = true; // владеем ли блоками? + enum DEFAULT_BSIZE = 8 * 1024; + + // Глубокая копия: читаем все блоки и записываем в dst + static void deepCopyAll(mmfile_t* src, MMBlocks dst) + { + enforce(xdl_seek_mmfile(src, 0) == 0, "seek(0) failed"); + + long sz = 0; + auto p = xdl_mmfile_first(src, &sz); + while (p !is null && sz > 0) + { + auto wrote = xdl_write_mmfile(dst.mmfilePtr(), p, sz); + enforce(wrote == sz, "write failed during deep copy"); + p = xdl_mmfile_next(src, &sz); + } + } + +public: + this() + { + _inner = initMmfile(DEFAULT_BSIZE); + } + + static MMBlocks fromBytes(const(ubyte)[] data) + { + auto b = new MMBlocks; + if (data.length > 0) + { + auto wrote = xdl_write_mmfile(&b._inner, data.ptr, cast(long)data.length); + enforce(wrote == cast(long)data.length, "xdl_write_mmfile wrote less than requested"); + } + return b; + } + + ~this() + { + if (_owned && _inner.head !is null) + xdl_free_mmfile(&_inner); + + // обнулим на всякий случай + _inner.head = null; _inner.tail = null; + _inner.rcur = null; _inner.wcur = null; + _inner.fsize = 0; _inner.bsize = 0; + } + + // доступ к внутреннему mmfile_t* + mmfile_t* mmfilePtr() @trusted { return &_inner; } + + size_t size() { return cast(size_t)xdl_mmfile_size(&_inner); } + bool isCompact() const { return xdl_mmfile_iscompact(cast(mmfile_t*)&_inner) != 0; } + + int writeBuf(const(ubyte)[] buf) + { + auto wrote = xdl_write_mmfile(&_inner, buf.ptr, cast(long)buf.length); + return (wrote == cast(long)buf.length) ? 0 : -1; + } + + /// Глубокая копия (новое владение) + MMBlocks clone() const + { + auto dst = new MMBlocks; + // deepCopyAll принимает mmfile_t*, поэтому аккуратно снимаем const с заголовка: + deepCopyAll(cast(mmfile_t*)&_inner, dst); + return dst; + } + + /// Компактизация через пересборку содержимого + void toCompact() + { + if (isCompact()) return; + + auto dst = new MMBlocks; + deepCopyAll(&_inner, dst); + + // перехватываем владение содержимым dst + if (_owned && _inner.head !is null) + xdl_free_mmfile(&_inner); + + _inner = *dst.mmfilePtr(); + dst._owned = false; // dst больше не владеет + dst._inner.head = null; // чтобы финалайзер dst ничего не освободил + dst._inner.tail = null; + dst._inner.rcur = null; + dst._inner.wcur = null; + dst._inner.fsize = 0; + dst._inner.bsize = 0; + } + + /// Снять владение (используется при move в MMFile) + void disarm() + { + _owned = false; + _inner.head = null; _inner.tail = null; + _inner.rcur = null; _inner.wcur = null; + _inner.fsize = 0; _inner.bsize = 0; + } +} diff --git a/source/xdiff_mmfile.d b/source/xdiff_mmfile.d new file mode 100644 index 0000000..bbd5251 --- /dev/null +++ b/source/xdiff_mmfile.d @@ -0,0 +1,166 @@ +module xdiff_mmfile; + +import std.exception : enforce; +import xdiff_init : ensureInit, initMmfile; +import xdiff_mmblocks : MMBlocks; +import xdiff; + +final class MMFile +{ +private: + mmfile_t _inner; + bool _owned = true; + +public: + this() + { + _inner = initMmfile(0); + } + + static MMFile fromBytes(const(ubyte)[] data) + { + auto f = new MMFile; + if (data.length > 0) + { + auto wrote = xdl_write_mmfile(&f._inner, data.ptr, cast(long)data.length); + enforce(wrote == cast(long)data.length, "xdl_write_mmfile wrote less than requested"); + } + return f; + } + + ~this() + { + if (_owned && _inner.head !is null) + xdl_free_mmfile(&_inner); + + _inner.head = null; _inner.tail = null; + _inner.rcur = null; _inner.wcur = null; + _inner.fsize = 0; _inner.bsize = 0; + } + + size_t size() { return cast(size_t)xdl_mmfile_size(&_inner); } + bool isCompact() const { return xdl_mmfile_iscompact(cast(mmfile_t*)&_inner) != 0; } + + const(ubyte)[] asSlice() const + { + enforce(isCompact(), "MMFile must be compact for asSlice"); + auto h = _inner.head; + if (h is null || h.size <= 0) return (cast(ubyte*)null)[0 .. 0]; + return (cast(const(ubyte)*)h.ptr)[0 .. cast(size_t)h.size]; + } + + ubyte[] asSliceMut() + { + enforce(isCompact(), "MMFile must be compact for asSliceMut"); + auto h = _inner.head; + if (h is null || h.size <= 0) return (cast(ubyte*)null)[0 .. 0]; + return (cast(ubyte*)h.ptr)[0 .. cast(size_t)h.size]; + } + + alias MMPatch = MMBlocks; + + /// Сформировать patch (diff self → other) + MMPatch computePatch(ref MMFile other) + { + auto patch = new MMBlocks(); + + static extern(C) int emitToPatch(void* priv, mmbuffer_t* bufs, int num) + { + auto target = cast(MMBlocks)priv; // класс + foreach (i; 0 .. num) + { + auto b = bufs + i; + auto wrote = xdl_writem_mmfile(target.mmfilePtr(), b, 1); + if (wrote != (*b).size) return -1; + } + return 0; + } + + xpparam_t xpp; xpp.flags = 0; + xdemitconf_t xec; xec.ctxlen = 3; + xdemitcb_t ecb; ecb.priv = cast(void*)patch; ecb.outf = &emitToPatch; + + auto rc = xdl_diff(&_inner, &other._inner, &xpp, &xec, &ecb); + enforce(rc == 0, "xdl_diff failed"); + return patch; + } + + struct PatchResult { bool success; MMFile patched; MMFile rejected; } + + /// Применить patch к self + PatchResult applyPatch(ref MMPatch patch) + { + patch.toCompact(); + + auto acc = new MMBlocks(); + auto rej = new MMBlocks(); + + static extern(C) int emitTo(void* priv, mmbuffer_t* bufs, int num) + { + auto target = cast(MMBlocks)priv; // класс + long expect = 0; foreach (i; 0 .. num) expect += (bufs + i).size; + auto wrote = xdl_writem_mmfile(target.mmfilePtr(), bufs, num); + return (wrote == expect) ? 0 : -1; + } + + xdemitcb_t accCb; accCb.priv = cast(void*)acc; accCb.outf = &emitTo; + xdemitcb_t rejCb; rejCb.priv = cast(void*)rej; rejCb.outf = &emitTo; + + auto rc = xdl_patch(&_inner, patch.mmfilePtr(), XDL_PATCH_NORMAL, &accCb, &rejCb); + enforce(rc == 0 || rc == 1, "xdl_patch failed"); + + auto accFile = MMFile.fromBlocksMoved(acc); + auto rejFile = MMFile.fromBlocksMoved(rej); + + bool ok = (rejFile.size() == 0); + return PatchResult(ok, accFile, rejFile); + } + + /// 3-way merge: коллбэки nothrow, чтобы не бросать через C + void merge3Raw( + ref MMFile f1, + ref MMFile f2, + scope int delegate(const(ubyte)[]) nothrow acceptCb, + scope int delegate(const(ubyte)[]) nothrow rejectCb) + { + static extern(C) int emitAccept(void* priv, mmbuffer_t* bufs, int num) + { + auto d = cast(int delegate(const(ubyte)[]) nothrow*)priv; + foreach (i; 0 .. num) + { + auto b = bufs + i; + auto slice = (cast(const(ubyte)*)((*b).ptr))[0 .. cast(size_t)((*b).size)]; + auto r = (*d)(slice); if (r != 0) return r; + } + return 0; + } + static extern(C) int emitReject(void* priv, mmbuffer_t* bufs, int num) + { + auto d = cast(int delegate(const(ubyte)[]) nothrow*)priv; + foreach (i; 0 .. num) + { + auto b = bufs + i; + auto slice = (cast(const(ubyte)*)((*b).ptr))[0 .. cast(size_t)((*b).size)]; + auto r = (*d)(slice); if (r != 0) return r; + } + return 0; + } + + auto a = acceptCb, r = rejectCb; + xdemitcb_t accCb; accCb.priv = cast(void*)&a; accCb.outf = &emitAccept; + xdemitcb_t rejCb; rejCb.priv = cast(void*)&r; rejCb.outf = &emitReject; + + auto rc = xdl_merge3(&_inner, &f1._inner, &f2._inner, &accCb, &rejCb); + enforce(rc == 0, "xdl_merge3 failed"); + } + + /// «Move» из MMBlocks: забрать владение внутренними блоками + static MMFile fromBlocksMoved(MMBlocks b) + { + b.toCompact(); + auto f = new MMFile; + f._inner = *b.mmfilePtr(); // копируем заголовок (указатели на блоки) + b.disarm(); // источник больше не владеет + return f; + } +} diff --git a/source/xdiff_unittests.d b/source/xdiff_unittests.d new file mode 100644 index 0000000..8646f42 --- /dev/null +++ b/source/xdiff_unittests.d @@ -0,0 +1,246 @@ +module xdiff_unittests; + +import std.algorithm : equal; +import std.array : appender; +import std.exception : assertThrown; +import std.range : iota, cycle, take; +import xdiff_mmfile : MMFile; +import xdiff_mmblocks : MMBlocks; + +// -------------------- Вспомогалки -------------------- + +/// Сгенерировать массив длиной n, цикляя значения 0..239 +ubyte[] genCycled(size_t n) +{ + auto acc = appender!(ubyte[])(); + foreach (v; iota(0, 240).cycle.take(n)) + acc.put(cast(ubyte)v); + return acc.data; +} + +/// Вытянуть контент MMBlocks в байты (не разрушая оригинал) +ubyte[] blocksToBytes(const MMBlocks b) +{ + auto tmp = b.clone(); // глубокая копия + auto mf = MMFile.fromBlocksMoved(tmp); + return (cast(const(ubyte)[]) mf.asSlice()).dup; +} + +/// Сравнить содержимое двух MMFile по байтам +bool sameBytes(const MMFile a, const MMFile b) +{ + return a.asSlice().equal(b.asSlice()); +} + +// -------------------- ТЕСТЫ -------------------- + +unittest // new_empty +{ + auto f = new MMFile; + assert(f.size() == 0); + assert(f.isCompact()); +} + +unittest // new_from_bytes +{ + auto data = cast(ubyte[])"hello world"; + auto f = MMFile.fromBytes(data); + assert(f.size() == data.length); + assert(f.isCompact()); + assert(f.asSlice().equal(data)); +} + +unittest // large_from_bytes +{ + auto data = genCycled(15_000); + auto f = MMFile.fromBytes(data); + assert(f.size() == data.length); + assert(f.isCompact()); + assert(f.asSlice().equal(data)); +} + +unittest // "clone" для MMFile: через байты +{ + auto data = genCycled(15_000); + auto f = MMFile.fromBytes(data); + auto f2 = MMFile.fromBytes(f.asSlice().dup); + assert(sameBytes(f, f2)); +} + +unittest // clone_blocks +{ + auto data = genCycled(15_000); + auto b = MMBlocks.fromBytes(data); + auto b2 = b.clone(); // глубокий + auto bytes = blocksToBytes(b); + auto bytes2 = blocksToBytes(b2); + assert(bytes.equal(bytes2)); +} + +unittest // eq для MMFile (по байтам) +{ + auto data = genCycled(15_000); + auto f = MMFile.fromBytes(data); + auto f2 = MMFile.fromBytes(data.dup); + assert(sameBytes(f, f2)); +} + +unittest // as_slice +{ + assert((new MMFile).asSlice().length == 0); + + auto data = genCycled(15_000); + auto f = MMFile.fromBytes(data); + assert(f.asSlice().equal(data)); +} + +unittest // as_slice_mut +{ + auto empty = new MMFile; + assert(empty.asSlice().length == 0); + + auto data = genCycled(15_000); + auto f = MMFile.fromBytes(data); + assert(f.asSlice()[0] == data[0]); + + auto mut = f.asSliceMut(); + mut[0] = cast(ubyte) (mut[0] + 1); + assert(f.asSlice()[0] == data[0] + 1); +} + +unittest // diff_simple → проверяем applyPatch +{ + auto data = cast(ubyte[])"hello world\n"; + auto data2 = cast(ubyte[])"hello world!\n"; + auto f = MMFile.fromBytes(data); + auto f2 = MMFile.fromBytes(data2); + + auto patch = f.computePatch(f2); + auto r = f.applyPatch(patch); + assert(r.success); + assert(r.patched.asSlice().equal(data2)); + assert(r.rejected.asSlice().length == 0); +} + +unittest // diff_with_mutation +{ + auto f = MMFile.fromBytes(cast(ubyte[])"hello world\n"); + auto f2 = MMFile.fromBytes(cast(ubyte[])"hello world!\n"); + + // 1-й прогон + auto p1 = f.computePatch(f2); + auto r1 = f.applyPatch(p1); + assert(r1.success); + assert(r1.patched.asSlice().equal(f2.asSlice())); + + // Меняем первую букву во втором файле + auto m = f2.asSliceMut(); + m[0] = cast(ubyte)'j'; + + // 2-й прогон + auto p2 = f.computePatch(f2); + auto r2 = f.applyPatch(p2); + assert(r2.success); + assert(r2.patched.asSlice().equal(f2.asSlice())); +} + +unittest // merge3_simple (без конфликтов) +{ + auto base = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world\n"); + auto a = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world changed\n"); + auto b = MMFile.fromBytes(cast(ubyte[])"header_changed\nline2\nline3\nline4\nhello world\n"); + + auto lines = appender!(string[])(); + auto rej = appender!(string[])(); + + int acceptCb(const(ubyte)[] s) nothrow + { + lines.put(cast(string) s.idup); + return 0; + } + int rejectCb(const(ubyte)[] s) nothrow + { + rej.put(cast(string) s.idup); + return 0; + } + + base.merge3Raw(a, b, &acceptCb, &rejectCb); + + auto expected = [ + "header_changed\n", + "line2\n", + "line3\n", + "line4\n", + "hello world changed\n", + ]; + assert(lines.data.equal(expected)); + assert(rej.data.length == 0); +} + +unittest // merge3_conflicts (есть rejected) +{ + auto base = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world\n"); + auto a = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world changed\n"); + auto b = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world also changed\n"); + + auto lines = appender!(string[])(); + auto rej = appender!(string[])(); + + int acceptCb(const(ubyte)[] s) nothrow { lines.put(cast(string)s.idup); return 0; } + int rejectCb(const(ubyte)[] s) nothrow { rej.put(cast(string)s.idup); return 0; } + + base.merge3Raw(a, b, &acceptCb, &rejectCb); + + assert(lines.data.length > 0); + assert(rej.data.length > 0); +} + +unittest // merge3_panic_* в Rust → у нас error-ветка через nothrow-коды: вернём -1 +{ + auto base = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world\n"); + auto a = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world changed\n"); + auto b = MMFile.fromBytes(cast(ubyte[])"header_changed\nline2\nline3\nline4\nhello world\n"); + + auto lines = appender!(string[])(); + + int acceptCb(const(ubyte)[] s) nothrow + { + static size_t cnt = 0; + ++cnt; + if (cnt > 3) return -1; // сигнализируем об ошибке + lines.put(cast(string)s.idup); + return 0; + } + int rejectCb(const(ubyte)[] s) nothrow { return 0; } + + assertThrown!Exception( base.merge3Raw(a, b, &acceptCb, &rejectCb) ); +} + +unittest // patch_simple (computePatch→applyPatch) +{ + auto base = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world\n"); + auto want = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world changed\n"); + + auto patch = base.computePatch(want); + auto res = base.applyPatch(patch); + + assert(res.success); + assert(sameBytes(res.patched, want)); + assert(res.rejected.asSlice().length == 0); +} + +unittest // patch_reject (искажаем базу перед apply) +{ + auto base = MMFile.fromBytes(cast(ubyte[])"header\nline2\nline3\nline4\nhello world\n"); + auto want = MMFile.fromBytes(cast(ubyte[])"header changed\nline2\nline3\nline4\nhello world changed\n"); + + auto patch = base.computePatch(want); + + auto m = base.asSliceMut(); + m[0] = cast(ubyte)'b'; + + auto res = base.applyPatch(patch); + + assert(!res.success); + assert(res.rejected.asSlice().length > 0); +}