trusty-common 0.49.0

Shared utilities and provider-agnostic streaming chat (ChatProvider, OllamaProvider, OpenRouter, tool-use) for trusty-* projects
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
//! Project-derived trusty-search index identity — a PARTITIONING key
//! (epic #4207, replacing the approach closed as won't-do in #4063).
//!
//! Why: the index id in use today is either the bare directory basename
//! ([`crate::index_id::derive_index_id`]) — which collides for any two
//! unrelated checkouts that happen to share a directory name — or, for a
//! tm-managed session, the session/worktree UUID, which binds a service
//! identity to ephemeral writer isolation: N concurrent writers on one repo
//! produce N independently-stale indexes, none shareable, and the id is
//! unguessable so BASE_PM's instruction to pass the project name silently
//! 404s. Both defects need ONE deterministic id derived from the PROJECT.
//!
//! **The contract that sank the previous attempt — state it before reading
//! any code.** trusty-search's registry is one-`root_path`-per-id (see
//! `trusty-mpm`'s `session_manager::search_gc`), so an index id must
//! *partition*: one id maps to exactly ONE content tree. [`RepoIdentity`]'s
//! own module doc says it is a *grouping* key — deliberately shared by every
//! facet (live checkout, `.base` clone, each session worktree) of one repo.
//! #4063 promoted that grouping key into the partitioning slot, and each
//! review round found another pair of genuinely different roots sharing it:
//! linked worktrees, then sibling clones of one repo, then the
//! daemon-unreachable path resolving forward onto the new id. This module
//! partitions **by construction** instead of by patching cases: the canonical
//! content-tree root path is a hashed component of every id, so two distinct
//! trees can never collide no matter how their git metadata relates. The
//! grouping key is retained as a *component* (and as the public
//! [`ProjectIdentity::origin`] field), not as the whole id.
//!
//! What: [`ProjectIdentity`] is the three-part identity the owner specified on
//! #4207 — origin (which repository), root (which content tree of it), and
//! operator (which account operates it — the triple's "gh user", realised
//! offline as git's configured `user.email`, not a GitHub login).
//! [`ProjectIdentity::derive`] is the one
//! I/O entry point (git shell-outs + one `canonicalize`);
//! [`ProjectIdentity::index_id`] is a pure, dependency-free function over
//! those three fields, so every collision case is testable without a daemon,
//! a registry, or live state. [`derive_project_index_id`] is the one-call
//! convenience wrapper. Nothing here is wired into `ensure_project_indexed`,
//! `trusty-search serve`, or the daemon's resolution path — this slice is the
//! derivation only.
//!
//! **Constraint inherited by the migration slice (do NOT implement here).**
//! The ~7 currently-registered indexes must be migrated by rename/alias, never
//! by re-index (this workspace alone is ~105k chunks / 2.1 GB redb). Whatever
//! resolver performs that migration MUST fail **safe**: when the daemon or the
//! persisted registry cannot be consulted, fall back to the known-good LEGACY
//! id. It must never fail *forward* onto an id that does not exist yet —
//! #4063 did exactly that at `search_index.rs:106-108` and silently orphaned a
//! session's search for the session's entire life, with no self-heal.
//!
//! Discoverability note: because the id is always suffixed, it is not
//! guessable from the project name alone. That is deliberate — a guessable id
//! cannot partition. The #4207 self-description slice (MCP `instructions` at
//! initialize, `404` → available-index signpost) is what makes ids findable;
//! the two changes are complements, and neither substitutes for the other.
//!
//! Test: `cargo test -p trusty-common --features unconditional-only --
//! project_index_id` — the sibling `project_index_id_tests.rs` covers sibling
//! clones, linked worktrees, differing operators, remoteless directories,
//! symlinked roots, determinism, the id charset, and the origin/operator drift
//! cases enumerated in [`ProjectIdentity::index_id`]'s `Known limitation`
//! block.
//!
//! [`RepoIdentity`]: crate::repo_identity::RepoIdentity

use std::path::{Path, PathBuf};
use std::process::Command;

use crate::repo_identity::RepoIdentity;
use crate::slug::slugify_string;

/// Version tag mixed into every digest preimage.
///
/// Why: the derivation rule will eventually change (a new component, a
/// different framing). Mixing a version tag into the digest preimage means a
/// future `v2` can never re-point an ALREADY-REGISTERED id at different
/// content — every id moves wholesale into a disjoint space. That is the
/// property the migration slice actually needs.
/// What: the literal `"v1"`. Note what this does NOT give you: the emitted id
/// carries no version marker, so a v1 id and a v2 id are indistinguishable by
/// inspection. A migration that must classify ids has to track the scheme
/// out-of-band (or a future version must emit the tag in the id itself).
/// Test: `index_id_is_pinned_for_a_known_input` fails if this value changes.
const SCHEME_VERSION: &str = "v1";

/// Maximum length of the human-readable label prefix of an index id.
///
/// Why: an index id is a single URL path segment and appears in filenames and
/// log lines; an unbounded owner/repo pair makes both unwieldy. Truncation is
/// safe because uniqueness lives entirely in the digest suffix, never in the
/// label.
/// What: 48 characters.
const MAX_LABEL_LEN: usize = 48;

/// Placeholder label used when a project yields no slug-able name at all.
///
/// Why: `index_id` must never return a string that starts with `-` or is only
/// a digest; a stable placeholder keeps the id readable and well-formed.
/// What: the literal `"project"`.
const FALLBACK_LABEL: &str = "project";

/// The three-part identity of one indexable project.
///
/// Why: #4207 specifies identity as origin + root + gh user. Modelling those as
/// explicit fields — rather than folding them into an opaque derivation — makes
/// each component's job checkable in isolation and lets [`Self::index_id`] stay
/// a pure function, so the collision cases that killed #4063 are testable
/// without any live daemon or registry state.
/// What: `origin` is the repo-level GROUPING key ([`RepoIdentity`], `None`
/// outside a git repo); `root` is the canonical absolute content-tree root and
/// is the component that makes the derived id a PARTITIONING key; `operator` is
/// the account operating this checkout. All three are hashed; only `origin`
/// (or the root basename) contributes to the readable label.
///
/// `root` is the most stable of the three, but it is NOT immutable — it is
/// fixed only under ordinary GIT operations (#4269). Renaming or moving the
/// directory moves it, and `origin` and `operator` are both live git state.
/// See the `Known limitation` block on [`Self::index_id`] for the enumerated
/// drift cases and what each costs.
/// Test: `sibling_clones_of_same_repo_derive_distinct_ids`,
/// `linked_worktrees_of_same_repo_derive_distinct_ids`,
/// `different_operators_derive_distinct_ids`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectIdentity {
    /// Repo-level grouping key from the git origin remote, `None` outside a
    /// git repo. Deliberately NOT sufficient on its own to identify an index.
    /// MUTABLE: `git remote add origin` and the first commit both change it.
    pub origin: Option<RepoIdentity>,
    /// Canonical absolute root of THIS content tree (a clone root, or a linked
    /// worktree root — each is its own tree). The partitioning component, and
    /// the only component that does not move under ordinary git operations.
    pub root: PathBuf,
    /// The #4207 identity triple's "gh user" component, realised offline as
    /// git's configured operator identity.
    ///
    /// VALUE SHAPE: this is a `git config user.email` value (e.g.
    /// `bob@example.com`), NOT a GitHub login. Do not compare it against
    /// `gh api user`. `None` when git resolves no identity. See
    /// [`resolve_operator_identity`] for precedence and stability caveats.
    pub operator: Option<String>,
}

impl ProjectIdentity {
    /// Derive the identity of the project containing `start`.
    ///
    /// Why: the three components must be gathered by ONE rule wherever an id is
    /// computed (trusty-mpm at session launch, trusty-search's `detect_project`,
    /// trusty-review's report enrichment) or the callers silently diverge — the
    /// same failure mode `index_id` was centralised here to prevent (#1373).
    /// What: canonicalises `start` — which resolves **symlinks**, so a symlinked
    /// path and its target derive one id — then walks to the enclosing git root
    /// via [`crate::index_id::resolve_project_root`], which lands on the *linked
    /// worktree* root for a worktree because its `.git` is a file there, so each
    /// worktree is correctly treated as its own content tree. Finally reads the
    /// origin via [`RepoIdentity::derive`] and the operator via
    /// [`resolve_operator_identity`]. Best-effort and offline: every component
    /// degrades to `None` rather than failing, and `canonicalize` falls back to
    /// the input path when it cannot resolve. No network, no daemon, no registry.
    ///
    /// Known limitation — canonicalisation resolves symlinks ONLY. macOS
    /// firmlinks and Linux bind mounts are NOT resolved (`/Users/x` and
    /// `/System/Volumes/Data/Users/x` each canonicalise to themselves), so one
    /// content tree reached through both derives two ids and therefore two
    /// indexes over identical content. This is inherent to any path-based
    /// partitioning key, not specific to this derivation.
    /// Test: `derivation_is_deterministic_across_calls`,
    /// `directory_without_git_origin_derives_stable_id`,
    /// `symlinked_root_derives_same_id_as_real_root`.
    pub fn derive(start: &Path) -> ProjectIdentity {
        // #4207: canonicalise BEFORE the git-root walk so a symlinked start
        // path and the real path resolve to the same content tree.
        let canonical_start = std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
        let root = crate::index_id::resolve_project_root(&canonical_start);
        ProjectIdentity {
            origin: RepoIdentity::derive(&root),
            operator: resolve_operator_identity(&root),
            root,
        }
    }

    /// Render this identity as a trusty-search index id.
    ///
    /// Why: this is the PARTITIONING key defined at the top of this module —
    /// one id, exactly one content tree — and it must hold *by construction*,
    /// for sibling clones and linked worktrees alike, because trusty-search's
    /// registry stores a single `root_path` per id and silently degrades a
    /// second registration to a *find* against the first tree's root.
    /// What: `"<label>-<digest>"`, where `label` is the slugified
    /// `owner-repo` (falling back to the root basename, then [`FALLBACK_LABEL`],
    /// truncated to [`MAX_LABEL_LEN`]) and `digest` is 16 lowercase hex digits
    /// over an injectively-framed, version-tagged encoding of ALL THREE fields.
    /// The label is cosmetic; every bit of uniqueness is in the digest, so
    /// two projects sharing a label still get different ids. Pure — no I/O, no
    /// global state, no daemon lookup — which is what makes the collision cases
    /// testable and keeps resolution independent of mutable live state.
    /// Guarantees (exactly these, and no more): non-empty; a single URL path
    /// segment matching `[a-z0-9][a-z0-9-]*`; and a pure function of the three
    /// fields — identical fields yield an identical id in every process, on
    /// every machine, across daemon restarts, forever. Stability of the *id for
    /// a given project* is therefore only as strong as the stability of the
    /// three inputs, and two of them move. Read the next block before assuming
    /// permanence.
    ///
    /// **Known limitation — which inputs are mutable, and what changes an id.**
    /// `root` is fixed only under ordinary GIT operations — NOT for the life of
    /// a content tree (#4269). It moves whenever the directory does, and `mv` is
    /// routine. Both other components are live git/user state, so ordinary
    /// actions re-derive a different id for the SAME tree. Each row below is
    /// pinned by a test; the four `root`/whole-triple movers named after the
    /// table are pinned in #4269, not here:
    ///
    /// | Action | Component | Test |
    /// |---|---|---|
    /// | The repo's first commit (`None` → `ContentHash`) | `origin` | `id_changes_when_first_commit_lands` |
    /// | `git remote add origin …` (`ContentHash` → `GitHub`) | `origin` | `id_changes_when_origin_remote_is_added` |
    /// | A new root commit (`git checkout --orphan`) on a remoteless repo | `origin` | `id_changes_on_orphan_root_commit` |
    /// | Changing the configured `user.email` | `operator` | `different_operators_derive_distinct_ids` |
    ///
    /// Row 2 is the everyday `git init` → work → `gh repo create` sequence. It
    /// changes both the label and the digest, so under a
    /// one-`root_path`-per-id registry an index registered before the remote is
    /// added is ORPHANED the moment it is added, with no self-heal — the same
    /// silent-orphan shape this module's header warns the migration slice about.
    /// This is a strictly weaker hazard than the #4063 failure (one tree
    /// deriving two ids over time wastes an index; two trees deriving one id
    /// merges unrelated codebases), and it cannot bite while the module has no
    /// call sites — but the wiring slice MUST NOT read this function as
    /// permanent.
    ///
    /// **Do NOT reconcile on `root`** (#4269, corrected #4288). An earlier
    /// version of this paragraph recommended exactly that, on the false premise
    /// that `root` does not move. Four routine actions move it or the whole
    /// triple, none of them a row in the table above, each verified against
    /// this crate:
    ///
    /// | Action | Observed |
    /// |---|---|
    /// | `mv proj proj-renamed` | `acme-widget-88e6…` → `acme-widget-0bd2…` |
    /// | GitHub repo rename | `acme-widget-6370…` → `acme-widget-renamed-b138…` |
    /// | GitHub repo transfer | `acme-widget-6370…` → `newowner-widget-4d1e…` |
    /// | `git remote remove origin` | `newowner-widget-4d1e…` → `widget-fa2a…` |
    ///
    /// The directory rename is the significant one: it is routine, it orphans
    /// the registered index, and it removes the exact anchor the migration
    /// slice was being told to reconcile on. `ProjectIdentity::digest` hashes
    /// `path_bytes(&self.root)` and `label()` falls back to the root
    /// basename, so `mv` moves BOTH halves of the id at once.
    ///
    /// Recommended shape for that slice instead: reconcile on an anchor GIT
    /// itself maintains, and treat an origin/operator change as an
    /// alias-and-carry-forward rather than a new index. The worktree
    /// reconciliation in `trusty-mpm`'s `session_manager::worktree_reconcile`
    /// (#4288) uses `(git rev-parse --git-common-dir, admin-dir basename)` for
    /// this reason: it survives a directory move, a repo rename, a transfer,
    /// and `git remote remove origin`, and when it does break — the repository
    /// directory moved without `git worktree repair` — it breaks LOUDLY, where
    /// a `root`-keyed scheme fails silently by re-deriving a fresh id.
    ///
    /// Also machine-local: `root` is an absolute path, so the same repo cloned
    /// on two machines derives two ids. That is correct for a partitioning key
    /// — each machine holds its own physical index.
    /// Test: `index_id_is_a_single_url_safe_segment`,
    /// `label_ambiguity_does_not_collide`, `empty_label_falls_back_to_placeholder`,
    /// `index_id_is_pinned_for_a_known_input`,
    /// `long_label_is_truncated_without_losing_uniqueness`.
    pub fn index_id(&self) -> String {
        format!("{}-{:016x}", self.label(), self.digest())
    }

    /// Build the cosmetic, human-readable prefix of the id.
    ///
    /// Why: an id of pure hex is unreadable in logs and `tm` output; a label
    /// keeps `bobmatnyc-trusty-tools-…` recognisable at a glance. It carries no
    /// uniqueness guarantee — deliberately, so a label collision is harmless.
    /// What: `owner-repo` for a GitHub origin, else the root basename; slugified
    /// via [`slugify_string`], truncated to [`MAX_LABEL_LEN`] on a character
    /// boundary with any trailing `-` trimmed, and replaced by
    /// [`FALLBACK_LABEL`] when empty.
    /// Test: `label_ambiguity_does_not_collide`,
    /// `empty_label_falls_back_to_placeholder`.
    fn label(&self) -> String {
        let raw = match &self.origin {
            Some(RepoIdentity::GitHub(gp)) => format!("{}-{}", gp.owner, gp.repo),
            // A content-hash origin is opaque hex; the directory name reads
            // better and the digest still carries the uniqueness.
            _ => self
                .root
                .file_name()
                .unwrap_or_default()
                .to_string_lossy()
                .into_owned(),
        };
        let mut label: String = slugify_string(&raw).chars().take(MAX_LABEL_LEN).collect();
        while label.ends_with('-') {
            label.pop();
        }
        if label.is_empty() {
            FALLBACK_LABEL.to_string()
        } else {
            label
        }
    }

    /// Hash all three identity components into a stable 64-bit digest.
    ///
    /// Why: the digest is the whole partitioning guarantee. Its preimage must be
    /// *injective* — #4063 shipped a bare `format!("{owner}-{repo}")` join where
    /// `foo-bar`/`baz` and `foo`/`bar-baz` produced one string — so every field
    /// here is length-prefixed and every `Option` carries a presence tag, making
    /// two different field tuples impossible to encode identically.
    /// What: length-framed `<len>:<bytes>` records for the scheme version, the
    /// origin's canonical form (`+`/`-` presence tag), the root path's raw
    /// bytes (not lossy UTF-8, so non-UTF-8 paths stay distinct), and the
    /// operator, run through [`fnv1a_64`].
    /// Test: `label_ambiguity_does_not_collide`,
    /// `unrelated_projects_sharing_a_basename_derive_distinct_ids`.
    fn digest(&self) -> u64 {
        let mut buf: Vec<u8> = Vec::with_capacity(256);
        push_field(&mut buf, SCHEME_VERSION.as_bytes());
        push_opt(&mut buf, self.origin.as_ref().map(|o| o.canonical()));
        push_field(&mut buf, &path_bytes(&self.root));
        push_opt(&mut buf, self.operator.clone());
        fnv1a_64(&buf)
    }
}

/// Derive the trusty-search index id for the project containing `start`.
///
/// Why: nearly every call site wants the id, not the identity; a one-call
/// wrapper keeps them from re-implementing the two-step derive-then-render and
/// drifting apart. Callers that also need the repo-level grouping key (to relate
/// several indexes of one repo) should use [`ProjectIdentity::derive`] and read
/// [`ProjectIdentity::origin`] instead of deriving it a second time.
/// What: [`ProjectIdentity::derive`] followed by [`ProjectIdentity::index_id`].
/// Performs the same best-effort git I/O as `derive`; never panics.
/// Test: `derivation_is_deterministic_across_calls`,
/// `sibling_clones_of_same_repo_derive_distinct_ids`.
pub fn derive_project_index_id(start: &Path) -> String {
    ProjectIdentity::derive(start).index_id()
}

/// Resolve the operator identity for the checkout at `root`, offline.
///
/// Why: #4207 specifies the account as an identity component, but the id must be
/// derivable with no network — `gh api user` would make every derivation a round
/// trip and a flake. Git's own configured identity is the offline signal that
/// actually varies per account, and a repo-local `user.email` override is
/// precisely how a second account's checkout is configured in practice.
///
/// **Deliberately reads NO environment variable.** An earlier revision honoured
/// a `TRUSTY_GH_USER` override; it was removed rather than merely tested,
/// because it made derivation non-hermetic in the one way that matters here —
/// two live callers on the SAME tree at the SAME instant deriving different ids
/// purely from differing process environments. The trusty-mpm daemon (launchd
/// plist env) and a `tm` CLI (shell env) are exactly that pair, so the override
/// would have re-created the "callers silently diverge" failure (#1373) that is
/// this module's whole reason to exist. Covering both branches with tests would
/// have pinned the divergence, not removed it. Eliminating the branch removes
/// the failure class.
/// What: `git -C <root> config --get user.email`, which already applies git's
/// repo-local-over-global precedence. `None` when git resolves no identity or is
/// unavailable. Returns a git email address, NOT a GitHub login.
///
/// Residual non-hermeticity, disclosed rather than claimed away: git's own
/// config resolution still depends on `HOME` / `GIT_CONFIG_GLOBAL`, so callers
/// running under materially different environments can still disagree when the
/// repo sets no local `user.email`; and `--get` on a multi-valued key returns
/// the LAST entry. The wiring slice must therefore guarantee uniform
/// environment inheritance across daemon and CLI — a partial rollout partitions
/// one tree into two indexes.
/// Test: `resolve_operator_identity_prefers_repo_local_git_identity`,
/// `resolve_operator_identity_ignores_ambient_env_override`.
pub fn resolve_operator_identity(root: &Path) -> Option<String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(root)
        .args(["config", "--get", "user.email"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    String::from_utf8_lossy(&output.stdout)
        .lines()
        .next()
        .map(str::trim)
        .filter(|l| !l.is_empty())
        .map(str::to_string)
}

/// Append one length-framed record to a digest preimage.
///
/// Why: concatenating fields with a separator is not injective — the exact
/// defect flagged on #4063's `owner-repo` join. An explicit byte length makes
/// the boundary unambiguous regardless of the field's own contents.
/// What: writes `<decimal len>:<bytes>` to `buf`.
/// Test: covered by `label_ambiguity_does_not_collide`.
pub(crate) fn push_field(buf: &mut Vec<u8>, value: &[u8]) {
    buf.extend_from_slice(value.len().to_string().as_bytes());
    buf.push(b':');
    buf.extend_from_slice(value);
}

/// Append an optional field with an explicit presence tag.
///
/// Why: `None` and `Some("")` must not encode identically, or a project with no
/// origin would share a digest with one whose origin resolved to an empty
/// string.
/// What: `Some(v)` writes `+` then [`push_field`]; `None` writes a bare `-`.
/// Test: covered by `directory_without_git_origin_derives_stable_id`.
fn push_opt(buf: &mut Vec<u8>, value: Option<String>) {
    match value {
        Some(v) => {
            buf.push(b'+');
            push_field(buf, v.as_bytes());
        }
        None => buf.push(b'-'),
    }
}

/// Return a path's raw bytes for hashing.
///
/// Why: `to_string_lossy` maps every invalid byte sequence to the same
/// replacement character, so two genuinely different non-UTF-8 paths could hash
/// identically — a partitioning-key violation, however rare. Hashing the raw
/// OS bytes removes the case entirely on unix.
/// What: the underlying `OsStr` bytes on unix; the lossy UTF-8 encoding
/// elsewhere (Windows `OsStr` is UTF-16 and has no byte view).
/// Test: covered by `sibling_clones_of_same_repo_derive_distinct_ids`.
pub(crate) fn path_bytes(p: &Path) -> Vec<u8> {
    #[cfg(unix)]
    {
        use std::os::unix::ffi::OsStrExt;
        p.as_os_str().as_bytes().to_vec()
    }
    #[cfg(not(unix))]
    {
        p.to_string_lossy().as_bytes().to_vec()
    }
}

/// FNV-1a 64-bit digest with a final avalanche mix.
///
/// Why: the id must be byte-identical across processes, machines, and toolchain
/// versions, which rules out `std`'s `DefaultHasher` (explicitly not
/// stability-guaranteed). A cryptographic digest would work but `sha2` is an
/// *optional* dependency of this crate, and an identity that changes with a
/// feature flag is worse than no identity at all — so this stays dependency-free
/// and unconditional. Not a security boundary: nothing here defends against a
/// chosen-preimage attacker, only against accidental collision.
/// (`memory_core::analytics::fnv1a_hash` is the same algorithm but lives behind
/// the `memory-core` feature and takes `&str`, so it is unusable here.)
/// What: standard FNV-1a over `bytes`, then a splitmix64 finalizer so that
/// paths differing in a single character still differ in most output bits.
/// Test: `digest_is_stable_for_identical_inputs`.
pub(crate) fn fnv1a_64(bytes: &[u8]) -> u64 {
    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
    const PRIME: u64 = 0x0000_0100_0000_01b3;
    let mut hash = OFFSET_BASIS;
    for b in bytes {
        hash ^= u64::from(*b);
        hash = hash.wrapping_mul(PRIME);
    }
    // splitmix64 finalizer — FNV-1a alone avalanches poorly on inputs that
    // differ only in their final bytes, which is exactly the sibling-path case.
    hash ^= hash >> 30;
    hash = hash.wrapping_mul(0xbf58_476d_1ce4_e5b9);
    hash ^= hash >> 27;
    hash = hash.wrapping_mul(0x94d0_49bb_1331_11eb);
    hash ^ (hash >> 31)
}

#[cfg(test)]
#[path = "project_index_id_tests.rs"]
mod tests;