localharness 0.45.0

Agents that own themselves: one Rust crate that's both an agent SDK (streaming, tools, hooks, policies, triggers, MCP) and a wallet-owning, self-sovereign agent that runs in the browser.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
//! Pure, deterministic CONVERGENT reconcile for the cross-device shared-folder
//! sync (`crate::app::sharedfs_sync` — cfg-gated out of non-browser doc
//! builds, so plain code formatting here, not links).
//!
//! ## The bug this fixes
//! The v1 sync merged two devices' folders by FILENAME only: each peer
//! announced its list of names, requested the names it lacked, and wrote them.
//! When two devices held the SAME filename with DIFFERENT content, neither side
//! ever re-requested it — the folders DIVERGED silently and never healed (no
//! content hash, no last-write-wins). Not corruption, but a permanent split.
//!
//! ## Why content-hash + conflict-copy (NOT last-write-wins)
//! LWW needs a clock. Our data model has NONE: a shared file is just
//! `{name, bytes}` — `crate::app::shared_fs::SharedEntry` carries only
//! `name + size`, OPFS [`crate::filesystem::Metadata`] carries only `kind +
//! size`, and the seal format stores no timestamp. There is no mtime/version to
//! compare, so LWW is unimplementable here without inventing (and trusting) a
//! per-file clock across devices that never talk except during a sync.
//!
//! So resolution is driven by the ONE identity a file does have: a hash of its
//! content. The rule is **deterministic + symmetric**:
//!   - Same name, same content (equal hash) → no-op (one copy survives).
//!   - Same name, DIFFERENT content → the file whose content hash is
//!     **lexicographically greater** keeps the plain name (the "winner"); the
//!     other is preserved as a CONFLICT COPY named `name.conflict-<shorthash>`
//!     (the short hash of the LOSER's content, [`CONFLICT_HASH_HEX_LEN`](crate::sharedfs_reconcile::CONFLICT_HASH_HEX_LEN) hex
//!     chars). No edit is silently lost.
//!   - Distinct names → union, exactly as before.
//!
//! Both devices compute the same content hashes over the same bytes, so both
//! independently pick the same winner AND derive the same conflict-copy name —
//! the merged SET is identical on both sides. That is the convergence property:
//! `reconcile(A, B)` and `reconcile(B, A)` yield the same set of
//! `{name -> content}` entries. After one exchange the two devices hold byte-
//! identical folders, and a re-sync is a pure no-op (idempotent).
//!
//! ## Pure by construction
//! This module is target-independent and dependency-free: it never touches
//! OPFS, WebRTC, or the seal key, and it does NOT hash — the caller supplies
//! each file's content hash (the wasm app computes keccak256 via `sha3`, which
//! it already depends on; the tests pass arbitrary bytes). That keeps the
//! convergence logic unit-testable under a plain native `cargo test`, the same
//! way [`crate::encoding`] was hoisted out of `app::events`. The wasm sync path
//! (`crate::app::sharedfs_sync`) calls
//! [`plan_pulls`](crate::sharedfs_reconcile::plan_pulls) to decide what to
//! fetch from a peer and what conflict-copies to write.

/// Max hex chars of the loser's content hash appended to a conflict copy
/// (`name.conflict-<hash>`). 32 hex = 16 bytes (128 bits) of the hash —
/// cryptographically collision-resistant, so distinct losing contents under one
/// filename get distinct conflict names and "no edit is silently lost" holds in
/// practice. The previous 8 hex (32 bits) let two different losing contents whose
/// hashes shared a 4-byte prefix collide onto ONE conflict name — birthday-bound
/// at ~2^32, or adversarially craftable by a hostile peer to DROP your edit (the
/// name is content-derived, so both devices still agree on it → the set stays
/// convergent; the collision just aliased two edits). 16 bytes raises that to
/// ~2^128 (infeasible) while keeping `NAME_MAX_LEN - CONFLICT_SUFFIX_MAX_LEN`
/// headroom reasonable. A deterministic label, not a secret; shorter caller
/// hashes (the tests) are used in full.
pub const CONFLICT_HASH_HEX_LEN: usize = 32;

/// The fixed literal a conflict copy inserts between the base name and the short
/// hash: `<name>` + `CONFLICT_SEP` + `<shorthash>`. Pinned so the name guard in
/// `crate::app::shared_fs` can recognise (and size-budget for) a well-formed
/// conflict name without re-deriving the format.
pub const CONFLICT_SEP: &str = ".conflict-";

/// Maximum number of bytes [`conflict_name`] appends to a base name:
/// `CONFLICT_SEP` (`.conflict-`) + up to [`CONFLICT_HASH_HEX_LEN`] hex chars.
/// The name-safety guard reserves exactly this much headroom so a base name that
/// passes the guard ALWAYS yields a conflict name that also passes — closing the
/// gap (#85) where a 111–128-char base produced a 129–146-char conflict name
/// that failed `path_is_safe`, silently dropping the loser's edit.
pub const CONFLICT_SUFFIX_MAX_LEN: usize = CONFLICT_SEP.len() + CONFLICT_HASH_HEX_LEN;

/// True iff `name` is a well-formed conflict copy: `<base><CONFLICT_SEP><short>`
/// where `<base>` is non-empty and `<short>` is 1..=[`CONFLICT_HASH_HEX_LEN`]
/// lowercase hex chars (exactly what [`conflict_name`] emits). Lets a name guard
/// admit a long-but-legitimate conflict name even when the plain-name cap (with
/// reserved headroom) would reject it, so the holder can still serve/store the
/// conflict copy. Does NOT vouch for traversal safety — the caller still applies
/// its own no-`/`/no-`..` checks.
pub fn is_conflict_name(name: &str) -> bool {
    let Some(idx) = name.rfind(CONFLICT_SEP) else {
        return false;
    };
    if idx == 0 {
        return false; // empty base
    }
    let short = &name[idx + CONFLICT_SEP.len()..];
    !short.is_empty()
        && short.len() <= CONFLICT_HASH_HEX_LEN
        && short.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}

/// One file in a device's shared folder, reduced to what reconcile needs: its
/// flat `name` and a `hash` of its content. The hash is opaque to this module —
/// any deterministic function of the bytes works; equality means "same content"
/// and the lexicographic order of the hashes breaks same-name conflicts. The
/// real sync uses keccak256; tests use short byte strings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileMeta {
    /// Flat file name (no path), as listed by `shared_fs::apex_list`.
    pub name: String,
    /// Content hash. Two files are "the same" iff their hashes are equal; the
    /// LEXICOGRAPHICALLY GREATER hash wins a same-name conflict.
    pub hash: Vec<u8>,
}

impl FileMeta {
    /// Convenience constructor.
    pub fn new(name: impl Into<String>, hash: impl Into<Vec<u8>>) -> Self {
        Self {
            name: name.into(),
            hash: hash.into(),
        }
    }
}

/// Lowercase hex of `bytes`.
fn hex(bytes: &[u8]) -> String {
    const HEXD: &[u8; 16] = b"0123456789abcdef";
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push(HEXD[(b >> 4) as usize] as char);
        s.push(HEXD[(b & 0xf) as usize] as char);
    }
    s
}

/// The conflict-copy name for a file whose content hashed to `hash`:
/// `<name>.conflict-<shorthash>`. Deterministic from `name` + `hash`, so both
/// devices derive the SAME conflict name for the same losing content (which is
/// what keeps the merged set convergent). The short hash takes the FIRST
/// [`CONFLICT_HASH_HEX_LEN`] hex chars of the hash (or all of it, if shorter).
pub fn conflict_name(name: &str, hash: &[u8]) -> String {
    let full = hex(hash);
    let short = if full.len() >= CONFLICT_HASH_HEX_LEN {
        &full[..CONFLICT_HASH_HEX_LEN]
    } else {
        &full
    };
    format!("{name}.conflict-{short}")
}

/// What THIS device must do after seeing a peer's manifest, to converge with it.
///
/// Returned by [`plan_pulls`] and consumed by the wasm sync path:
///   - [`ReconcilePlan::want`] — file NAMES to request from the peer (the peer
///     holds bytes we don't, under either a new name or a conflict-copy name we
///     don't yet have).
///   - [`ReconcilePlan::rename_local`] — local files to COPY to a conflict name
///     because the peer's same-named file won the deterministic tiebreak: we
///     keep the peer's winner under the plain name (pulled via `want`) and keep
///     our own losing edit under `(from -> to)` so nothing is lost.
///
/// Applying a plan is monotonic (only adds/renames, never deletes the winner),
/// and BOTH devices' plans drive the same final set — see the module doc.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ReconcilePlan {
    /// Names to pull from the peer (plain names + conflict-copy names).
    pub want: Vec<String>,
    /// `(from_name, to_conflict_name)` local copies to make so a locally-losing
    /// edit survives under a conflict name while the peer's winner takes the
    /// plain name.
    pub rename_local: Vec<(String, String)>,
}

/// Decide what THIS device (manifest `local`) must pull from / write locally,
/// given the PEER's manifest `remote`, to make the two folders converge.
///
/// Deterministic and symmetric in the sense that matters: run on device A with
/// `(local=A, remote=B)` and on device B with `(local=B, remote=A)`, and after
/// each device applies its own plan (pull the wanted names from the peer, make
/// the conflict-copies) BOTH devices hold the identical merged set
/// `{name -> content}`. [`merged_set`] computes that fixed point directly for
/// testing the convergence property without a transport.
///
/// Rules, per file `name`:
///   - peer has it, we don't → `want` it (plain union).
///   - both have it, SAME hash → nothing (already identical).
///   - both have it, DIFFERENT hash → deterministic tiebreak on the hash:
///       * peer's hash GREATER (peer wins) → we will adopt the peer's bytes
///         under `name` (`want` it) AND copy our local loser to its conflict
///         name (`rename_local`), and also `want` the peer's loser-conflict-copy
///         (so we end up holding both sides' conflict copies too).
///       * our hash greater/equal-greater (we win) → we keep `name`; we still
///         `want` the peer's losing content under ITS conflict name so the loser
///         survives on our side as well.
///   - we have it, peer doesn't → nothing to pull (the peer will `want` it from
///     us via its own symmetric plan).
///
/// Names already present locally are never re-requested, so the plan is
/// idempotent: a second pass over converged folders yields an empty plan.
pub fn plan_pulls(local: &[FileMeta], remote: &[FileMeta]) -> ReconcilePlan {
    let mut plan = ReconcilePlan::default();
    // Do we ALREADY hold a file named `n` whose content is `h`? Used to skip a
    // conflict-copy we've already preserved WITHOUT skipping (and so dropping) a
    // loser whose conflict name is merely occupied by some unrelated content.
    let have_content = |n: &str, h: &[u8]| local.iter().any(|f| f.name == n && f.hash.as_slice() == h);

    for r in remote {
        match local.iter().find(|l| l.name == r.name) {
            None => {
                // Peer-only name: plain union pull (unless we somehow already
                // hold that exact name, guarded above by `find`).
                plan.want.push(r.name.clone());
            }
            Some(l) if l.hash == r.hash => {
                // Identical file — no-op.
            }
            Some(l) => {
                // Same name, different content → deterministic content-hash
                // tiebreak. Both devices see the same pair of hashes and agree
                // on the winner, so this is symmetric.
                let peer_wins = r.hash > l.hash;
                // The peer's losing/own copy must survive on our side under its
                // content-derived conflict name. The conflict name is derived
                // from the LOSER's hash; whichever file loses, its conflict copy
                // exists on the peer (the peer makes it via its own plan), so we
                // pull it. Only pull if we don't already hold it.
                if peer_wins {
                    // Peer's bytes win `name`: adopt them, and preserve OUR
                    // losing edit as a local conflict copy.
                    plan.want.push(r.name.clone());
                    let our_conflict = conflict_name(&l.name, &l.hash);
                    // Skip the preserve-copy ONLY if our conflict copy already
                    // holds THIS losing content (idempotent re-sync). A name-only
                    // guard (`have_name`) silently dropped the loser when an
                    // UNRELATED file happened to sit at the conflict name; check
                    // the hash so the loser is never lost.
                    if !have_content(&our_conflict, &l.hash) {
                        plan.rename_local
                            .push((l.name.clone(), our_conflict));
                    }
                } else {
                    // We win `name`; the peer's losing bytes survive on our side
                    // as a conflict copy pulled from the peer.
                    let peer_conflict = conflict_name(&r.name, &r.hash);
                    // Same content-aware guard: only skip the pull if we already
                    // hold the peer's losing content under its conflict name.
                    if !have_content(&peer_conflict, &r.hash) {
                        plan.want.push(peer_conflict);
                    }
                }
            }
        }
    }
    plan
}

/// The fully-converged merged folder, as a sorted list of
/// `(name, content_hash)`, computed directly from two manifests. This is the
/// fixed point both devices reach after exchanging plans; the tests assert it is
/// symmetric (`merged_set(A,B) == merged_set(B,A)`), which is the convergence
/// guarantee. The wasm path doesn't call this — it applies [`plan_pulls`]
/// incrementally over the channel — but the two agree by construction.
///
/// For a same-name conflict, the winner (greater hash) keeps `name` and the
/// loser is emitted under [`conflict_name`]. Distinct names union. Identical
/// files collapse to one entry.
pub fn merged_set(a: &[FileMeta], b: &[FileMeta]) -> Vec<(String, Vec<u8>)> {
    use std::collections::BTreeMap;
    // name -> content hash. Insertion is order-independent because the conflict
    // resolution is symmetric: we always key the WINNER under the plain name and
    // the LOSER under its content-derived conflict name, regardless of which
    // side a file came from.
    let mut out: BTreeMap<String, Vec<u8>> = BTreeMap::new();

    // Collect all (name -> set of distinct hashes) across both sides.
    let mut by_name: BTreeMap<String, Vec<Vec<u8>>> = BTreeMap::new();
    for f in a.iter().chain(b.iter()) {
        let slot = by_name.entry(f.name.clone()).or_default();
        if !slot.contains(&f.hash) {
            slot.push(f.hash.clone());
        }
    }

    for (name, mut hashes) in by_name {
        if hashes.len() == 1 {
            out.insert(name, hashes.pop().unwrap());
            continue;
        }
        // Conflict: winner = max hash keeps `name`; every other distinct hash
        // becomes a conflict copy. (With two devices there's at most 2, but
        // handle N defensively.)
        hashes.sort();
        let winner = hashes.pop().unwrap(); // greatest
        for loser in &hashes {
            out.insert(conflict_name(&name, loser), loser.clone());
        }
        out.insert(name, winner);
    }

    out.into_iter().collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn f(name: &str, hash: &[u8]) -> FileMeta {
        FileMeta::new(name, hash.to_vec())
    }

    /// Same name / different content resolves to a DETERMINISTIC winner: the
    /// greater content hash keeps the plain name, the loser becomes a conflict
    /// copy. The pick does not depend on argument order.
    #[test]
    fn same_name_different_content_deterministic_winner() {
        let a = vec![f("notes.txt", b"\x01")];
        let b = vec![f("notes.txt", b"\x02")]; // 0x02 > 0x01 → b wins
        let m_ab = merged_set(&a, &b);
        let m_ba = merged_set(&b, &a);
        assert_eq!(m_ab, m_ba, "winner must not depend on order");
        // Winner (0x02) keeps the plain name.
        assert_eq!(
            m_ab.iter().find(|(n, _)| n == "notes.txt").unwrap().1,
            b"\x02".to_vec()
        );
        // Loser (0x01) survives as a conflict copy.
        let conflict = conflict_name("notes.txt", b"\x01");
        assert_eq!(
            m_ab.iter().find(|(n, _)| n == &conflict).unwrap().1,
            b"\x01".to_vec()
        );
    }

    /// THE convergence property: `merged_set(A,B)` equals `merged_set(B,A)` as a
    /// set, across a mix of distinct names, identical files, and conflicts. This
    /// is what guarantees two devices reach the same state after a sync.
    #[test]
    fn merge_is_symmetric_convergent() {
        let a = vec![
            f("only_a.txt", b"AA"),
            f("shared_same.txt", b"SAME"),
            f("conflict.txt", b"\x10\x00"),
        ];
        let b = vec![
            f("only_b.txt", b"BB"),
            f("shared_same.txt", b"SAME"),
            f("conflict.txt", b"\x20\x00"), // greater → b wins conflict.txt
        ];
        let m_ab = merged_set(&a, &b);
        let m_ba = merged_set(&b, &a);
        assert_eq!(m_ab, m_ba, "reconcile must be symmetric (convergence)");

        // Spot-check the converged contents.
        let get = |m: &Vec<(String, Vec<u8>)>, n: &str| {
            m.iter().find(|(name, _)| name == n).map(|(_, h)| h.clone())
        };
        assert_eq!(get(&m_ab, "only_a.txt"), Some(b"AA".to_vec()));
        assert_eq!(get(&m_ab, "only_b.txt"), Some(b"BB".to_vec()));
        assert_eq!(get(&m_ab, "shared_same.txt"), Some(b"SAME".to_vec()));
        // conflict.txt: greater hash (0x20..) wins; loser (0x10..) is a copy.
        assert_eq!(get(&m_ab, "conflict.txt"), Some(b"\x20\x00".to_vec()));
        let loser_copy = conflict_name("conflict.txt", b"\x10\x00");
        assert_eq!(get(&m_ab, &loser_copy), Some(b"\x10\x00".to_vec()));
    }

    /// A losing edit must be preserved even when its conflict name is already
    /// occupied by UNRELATED content. The old name-only guard skipped the
    /// preserve-copy and silently dropped the loser (the "no edit is lost"
    /// invariant violated); the content-aware guard preserves it.
    #[test]
    fn loser_preserved_when_conflict_name_holds_other_content() {
        let lo = vec![0x01u8; 32];
        let hi = vec![0x02u8; 32]; // hi > lo → B (hi) wins `doc`
        let cname = conflict_name("doc", &lo);
        // A holds `doc`(lo) AND an unrelated file sitting at doc's conflict name.
        let a = vec![f("doc", &lo), f(&cname, b"unrelated")];
        let b = vec![f("doc", &hi)];
        let plan = plan_pulls(&a, &b);
        assert!(plan.want.contains(&"doc".to_string()), "adopt the winner");
        assert!(
            plan.rename_local.contains(&("doc".to_string(), cname.clone())),
            "the loser must be scheduled for preservation, not dropped"
        );
        // Idempotent: if A ALREADY holds `lo` under its conflict name, skip.
        let a2 = vec![f("doc", &lo), f(&cname, &lo)];
        assert!(
            plan_pulls(&a2, &b).rename_local.is_empty(),
            "an already-preserved loser is not re-copied"
        );
    }

    /// Distinct losing contents whose hashes share a prefix must get DISTINCT
    /// conflict names — the old 8-hex (4-byte) suffix aliased them onto one name
    /// and dropped an edit. The full-hash suffix keeps them apart.
    #[test]
    fn conflict_name_uses_full_hash_no_prefix_aliasing() {
        let h1 = vec![0xaa, 0xbb, 0xcc, 0xdd, 0x01];
        let h2 = vec![0xaa, 0xbb, 0xcc, 0xdd, 0x02]; // same first 4 bytes
        assert_ne!(
            conflict_name("doc", &h1),
            conflict_name("doc", &h2),
            "distinct content must yield distinct conflict names"
        );
    }

    /// Distinct names just union (the non-regressing v1 behaviour).
    #[test]
    fn distinct_names_union() {
        let a = vec![f("a.txt", b"a"), f("b.txt", b"b")];
        let b = vec![f("c.txt", b"c")];
        let m = merged_set(&a, &b);
        let names: Vec<&str> = m.iter().map(|(n, _)| n.as_str()).collect();
        assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
        assert_eq!(merged_set(&a, &b), merged_set(&b, &a));
    }

    /// Identical folders are a no-op — one copy of each file, no conflict
    /// copies, and the plan is empty (idempotent re-sync).
    #[test]
    fn identical_files_are_noop() {
        let a = vec![f("x.txt", b"X"), f("y.txt", b"Y")];
        let b = a.clone();
        let m = merged_set(&a, &b);
        assert_eq!(m.len(), 2, "no conflict copies for identical content");
        assert_eq!(m, merged_set(&b, &a));
        // The incremental plan agrees: nothing to do.
        assert_eq!(plan_pulls(&a, &b), ReconcilePlan::default());
        assert_eq!(plan_pulls(&b, &a), ReconcilePlan::default());
    }

    /// The conflict copy preserves BOTH contents — the merged set contains the
    /// winner under the plain name AND the loser under the conflict name, so no
    /// edit is silently lost.
    #[test]
    fn conflict_copy_preserves_both_contents() {
        let a = vec![f("doc", b"\xaa")];
        let b = vec![f("doc", b"\xbb")]; // 0xbb > 0xaa
        let m = merged_set(&a, &b);
        let contents: std::collections::BTreeSet<Vec<u8>> =
            m.iter().map(|(_, h)| h.clone()).collect();
        assert!(contents.contains(b"\xaa".as_slice()), "loser preserved");
        assert!(contents.contains(b"\xbb".as_slice()), "winner preserved");
        assert_eq!(m.len(), 2);
    }

    /// An empty side: merging with nothing yields the non-empty side unchanged
    /// (and is symmetric).
    #[test]
    fn empty_side() {
        let a = vec![f("a.txt", b"a"), f("b.txt", b"b")];
        let empty: Vec<FileMeta> = Vec::new();
        let m = merged_set(&a, &empty);
        assert_eq!(m.len(), 2);
        assert_eq!(merged_set(&a, &empty), merged_set(&empty, &a));
        assert_eq!(merged_set(&empty, &empty), Vec::new());
    }

    /// The incremental [`plan_pulls`] each device runs drives convergence to the
    /// SAME set that [`merged_set`] computes directly. We simulate applying both
    /// devices' plans and assert the resulting folders are byte-identical and
    /// equal to `merged_set`.
    #[test]
    fn plans_converge_to_merged_set() {
        let a = vec![
            f("only_a", b"A"),
            f("shared", b"\x05"),
            f("dup", b"DUP"),
        ];
        let b = vec![
            f("only_b", b"B"),
            f("shared", b"\x09"), // greater → b wins `shared`
            f("dup", b"DUP"),
        ];

        // Apply A's plan: A pulls wanted names from B and makes its conflict
        // copies; same for B.
        let final_a = apply_plan(&a, &b);
        let final_b = apply_plan(&b, &a);

        assert_eq!(final_a, final_b, "both devices reach the same folder");
        assert_eq!(final_a, merged_set(&a, &b), "matches the direct fixed point");
    }

    /// The conflict suffix budget exactly covers what [`conflict_name`] appends:
    /// `.conflict-` plus the short hash. This is the headroom the name guard
    /// (`crate::app::shared_fs::path_is_safe`) must reserve so a base name that
    /// passes the guard ALWAYS yields a conflict name that fits too (#85).
    #[test]
    fn conflict_suffix_len_matches_what_conflict_name_appends() {
        // A hash with >= CONFLICT_HASH_HEX_LEN hex chars (>= 16 bytes; a full
        // 32-byte keccak256 has 64) appends the maximum: `.conflict-` +
        // CONFLICT_HASH_HEX_LEN hex (the input is truncated to that).
        let max = conflict_name("x", &[0xab; 32]);
        assert_eq!(max.len() - "x".len(), CONFLICT_SUFFIX_MAX_LEN);
        // For ANY base + hash, the appended length never exceeds the budget.
        for base in ["a", "notes.txt", &"z".repeat(64)] {
            for hash in [&b""[..], &b"\x00"[..], &b"\xff\xff\xff\xff\xff\xff"[..]] {
                let cn = conflict_name(base, hash);
                assert!(
                    cn.len() <= base.len() + CONFLICT_SUFFIX_MAX_LEN,
                    "conflict suffix for {base:?}/{hash:?} overran the budget"
                );
            }
        }
    }

    /// A base name capped at `128 - CONFLICT_SUFFIX_MAX_LEN` (the reserved-
    /// headroom plain-name cap) yields a conflict name within the 128-byte cap —
    /// the property that fixes the silent loser-drop in #85.
    #[test]
    fn capped_base_yields_in_bounds_conflict_name() {
        const NAME_CAP: usize = 128;
        let base = "n".repeat(NAME_CAP - CONFLICT_SUFFIX_MAX_LEN); // the reserved-headroom cap
        assert!(base.len() <= NAME_CAP - CONFLICT_SUFFIX_MAX_LEN);
        let cn = conflict_name(&base, &[0xde, 0xad, 0xbe, 0xef, 0x99]);
        assert!(
            cn.len() <= NAME_CAP,
            "conflict name {} > cap {NAME_CAP}",
            cn.len()
        );
        assert!(is_conflict_name(&cn), "must be recognised as a conflict name");
    }

    /// [`is_conflict_name`] accepts exactly what [`conflict_name`] emits and
    /// rejects plain names + malformed lookalikes.
    #[test]
    fn is_conflict_name_round_trips() {
        // Emitted conflict names are recognised, across hash lengths.
        assert!(is_conflict_name(&conflict_name("notes.txt", b"\x01")));
        assert!(is_conflict_name(&conflict_name("a", &[0xab, 0xcd, 0xef, 0x12])));
        assert!(is_conflict_name(&conflict_name(
            "doc",
            &[0xff, 0xff, 0xff, 0xff, 0xff]
        )));
        // Plain names are not conflict names.
        assert!(!is_conflict_name("notes.txt"));
        assert!(!is_conflict_name("a.b.c"));
        // Empty base or empty/over-long/non-hex/upper short hash is rejected.
        assert!(!is_conflict_name(".conflict-ab"));
        assert!(!is_conflict_name("x.conflict-"));
        assert!(!is_conflict_name(&format!("x.conflict-{}", "a".repeat(33)))); // 33 > 32 hex
        assert!(!is_conflict_name("x.conflict-zz")); // non-hex
        assert!(!is_conflict_name("x.conflict-AB")); // uppercase
    }

    /// Test helper: simulate `local` applying its `plan_pulls(local, remote)` —
    /// pull each wanted name's content from `remote` (or, for a conflict-copy
    /// name, from the corresponding remote loser), and make the local conflict
    /// copies. Returns the resulting folder as a sorted `(name, hash)` set, the
    /// same shape `merged_set` returns.
    fn apply_plan(local: &[FileMeta], remote: &[FileMeta]) -> Vec<(String, Vec<u8>)> {
        use std::collections::BTreeMap;
        let plan = plan_pulls(local, remote);
        let mut folder: BTreeMap<String, Vec<u8>> =
            local.iter().map(|f| (f.name.clone(), f.hash.clone())).collect();

        // Local conflict copies: copy `from`'s current bytes to `to`.
        for (from, to) in &plan.rename_local {
            if let Some(h) = folder.get(from).cloned() {
                folder.insert(to.clone(), h);
            }
        }
        // Pull wanted names from the peer. A wanted name is either a plain name
        // the peer holds, or a conflict-copy name we derive from a peer file.
        for want in &plan.want {
            if let Some(rf) = remote.iter().find(|f| &f.name == want) {
                folder.insert(want.clone(), rf.hash.clone());
            } else if let Some(rf) = remote
                .iter()
                .find(|f| &conflict_name(&f.name, &f.hash) == want)
            {
                folder.insert(want.clone(), rf.hash.clone());
            }
        }
        folder.into_iter().collect()
    }
}