trusty-common 0.59.1

Shared utilities and provider-agnostic streaming chat (ChatProvider, OllamaProvider, OpenRouter, tool-use) for trusty-* projects
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
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
//! Palace-level alias map: redirect one palace name to another (issue #1939).
//!
//! Why: trusty-mpm pins a managed session's `TRUSTY_MEMORY_PALACE` to the
//! `owner-repo` slug that [`crate::derive_palace_id`] produces (e.g.
//! `bobmatnyc-trusty-tools`), but the pre-existing claude-mpm-era palace for a
//! repo is the BARE repo name (`trusty-tools`). When the `owner-repo` palace has
//! never been created, every memory tool call fails with "palace metadata
//! missing" while the real history lives under the bare name — memory is
//! split-brained. This module persists a small alias map so a lookup for a
//! non-existent palace can be transparently redirected to an existing target,
//! letting BOTH names resolve to the one on-disk store.
//!
//! What: a JSON file `<registry_dir>/palace_aliases.json` mapping
//! `alias_name -> target_palace`, with atomic (tmp + rename) writes and a
//! forgiving read (a missing/empty/corrupt file is treated as "no aliases").
//! Resolution is consulted by [`crate::memory_core::registry::PalaceRegistry`]
//! ONLY when the requested palace has no metadata on disk, so aliases never
//! shadow a real palace of the same name. This is DISTINCT from the term/KG
//! entity aliases exposed by the `add_alias`/`discover_aliases` MCP tools — those
//! live INSIDE a palace's knowledge graph; this is a PALACE-level redirect that
//! lives beside the per-palace subdirectories.
//!
//! Test: `crate::palace_alias::tests` covers round-trip register/resolve, the
//! missing-file default, idempotent re-registration, self-alias rejection, the
//! rename retarget and alias removal (#9544), and the `palace_registry_dir_from`
//! subdir resolution.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

/// Filename for the persisted palace-alias map (beside per-palace subdirs).
///
/// Why: the map must live in the SAME directory the daemon uses as its palace
/// registry root (`AppState::data_root`) so a single lookup finds it. Naming it
/// with a `.json` suffix (not a directory) means the palace-listing walker —
/// which only descends into subdirectories containing `palace.json` — skips it
/// automatically.
/// What: `"palace_aliases.json"`.
/// Test: `register_then_resolve_round_trips` writes/reads this path.
const PALACE_ALIASES_JSON: &str = "palace_aliases.json";

/// On-disk shape of the alias map (versioned for forward compatibility).
///
/// Why: wrapping the flat map in a struct with an explicit `version` lets the
/// schema evolve (e.g. add per-alias metadata) without breaking older readers.
/// What: `version` (defaulted to 1 for files written before it existed) plus the
/// `aliases` map. Serialised with `serde_json` pretty output.
/// Test: `register_then_resolve_round_trips`.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct PalaceAliasesFile {
    /// Schema version; defaults to 1 when absent so pre-version files still load.
    #[serde(default = "default_alias_schema_version")]
    version: u32,
    /// `alias_name -> target_palace` redirects. `BTreeMap` keeps the on-disk
    /// order stable so diffs are deterministic.
    #[serde(default)]
    aliases: BTreeMap<String, String>,
}

fn default_alias_schema_version() -> u32 {
    1
}

/// Stateless namespace for palace-alias persistence.
///
/// Why: like [`crate::memory_core::store::palace_store::PalaceStore`], alias
/// persistence has no state of its own — every operation is a pure function over
/// a registry directory. Grouping under a unit struct gives a stable import path.
/// What: `load_aliases` / `register_alias` / `resolve_alias`, plus (#9544)
/// `rename_target` / `remove_alias`.
/// Test: this module's `tests`.
pub struct PalaceAliasStore;

impl PalaceAliasStore {
    /// Load the full alias map for a registry directory.
    ///
    /// Why: callers that want the whole map (diagnostics, bulk resolution) get it
    /// in one read. A missing file is the common case (no aliases registered yet)
    /// and must NOT be an error, so a fresh install behaves like an empty map.
    /// What: reads `<registry_dir>/palace_aliases.json`. Returns an empty map when
    /// the file is absent, empty, or fails to parse (corruption is logged and
    /// treated as "no aliases" so a bad file can never wedge palace resolution).
    /// Test: `load_missing_is_empty`, `register_then_resolve_round_trips`.
    pub fn load_aliases(registry_dir: &Path) -> Result<BTreeMap<String, String>> {
        Self::read_aliases(registry_dir, false)
    }

    /// Read the alias map; `strict` turns a parse failure into an error.
    ///
    /// Why (#9544): `rename_target` rewrites the whole map, so reading a corrupt
    /// file as empty would overwrite every alias in it. Other callers keep the
    /// forgiving read.
    /// What: a missing or whitespace-only file is an empty map. Any other read
    /// error is returned. A parse failure is logged and read as empty, or
    /// returned when `strict` is set.
    /// Test: `corrupt_file_degrades_to_empty`,
    /// `rename_target_refuses_a_corrupt_alias_file_and_keeps_its_bytes`.
    fn read_aliases(registry_dir: &Path, strict: bool) -> Result<BTreeMap<String, String>> {
        let path = registry_dir.join(PALACE_ALIASES_JSON);
        let bytes = match std::fs::read(&path) {
            Ok(b) => b,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
            Err(e) => {
                return Err(e)
                    .with_context(|| format!("read palace aliases at {}", path.display()));
            }
        };
        if bytes.iter().all(u8::is_ascii_whitespace) {
            return Ok(BTreeMap::new());
        }
        match serde_json::from_slice::<PalaceAliasesFile>(&bytes) {
            Ok(file) => Ok(file.aliases),
            // #9544: a rewrite of the map must not run over a corrupt file.
            Err(e) if strict => {
                Err(e).with_context(|| format!("parse palace aliases at {}", path.display()))
            }
            Err(e) => {
                // A corrupt alias file must not break palace resolution — the
                // authoritative data is the palaces themselves. Log and degrade
                // to "no aliases".
                tracing::warn!(
                    path = %path.display(),
                    error = %e,
                    "palace alias file is unparseable; ignoring (treating as no aliases)"
                );
                Ok(BTreeMap::new())
            }
        }
    }

    /// Register (or overwrite) an alias `alias_name -> target`, persisting it.
    ///
    /// Why: trusty-mpm calls this at session launch to bind a derived
    /// `owner-repo` name to an existing bare-repo palace so the split-brain
    /// resolves. It must be idempotent — relaunching the same session repeatedly
    /// must converge on one entry, not error or duplicate.
    /// What: rejects empty operands and a self-alias (`alias == target`, which
    /// would be a useless no-op / cycle). Otherwise loads the current map,
    /// inserts/updates the entry, and atomically writes it back (tmp + rename) so
    /// a crash mid-write cannot leave a half-written map. Creating an alias that
    /// already maps to the same target is a cheap no-op write. Returns `Ok(())`.
    /// Test: `register_then_resolve_round_trips`, `register_is_idempotent`,
    /// `register_self_alias_is_rejected`, `register_rejects_empty`.
    pub fn register_alias(registry_dir: &Path, alias: &str, target: &str) -> Result<()> {
        let alias = alias.trim();
        let target = target.trim();
        if alias.is_empty() || target.is_empty() {
            anyhow::bail!(
                "palace alias and target must both be non-empty (alias={alias:?}, target={target:?})"
            );
        }
        if alias == target {
            anyhow::bail!(
                "refusing to register a self-referential palace alias {alias:?} -> {target:?}"
            );
        }

        let mut aliases = Self::load_aliases(registry_dir)?;
        aliases.insert(alias.to_string(), target.to_string());
        Self::write_aliases(registry_dir, aliases)
    }

    /// Point a renamed palace's old id, and every alias of it, at the new id.
    ///
    /// Why (#9544): a palace id rename must keep the old id answering. Writing
    /// `old -> new` alone would strand an older alias `x -> old` on a name that
    /// no longer owns a palace, and a separate retarget write would leave a
    /// window where the map names a dead target. One write covers both.
    /// What: rejects empty operands and `old == new`. Then, in one atomic write:
    /// drops any entry keyed by `new` (that id becomes a real palace, so an alias
    /// under it is inert and would form an `old <-> new` cycle when a rename is
    /// reversed), repoints every `x -> old` to `x -> new`, and inserts
    /// `old -> new`. The entry takes effect only once `old` has no `palace.json`
    /// and `new` has one ([`alias_target_if_absent`]), so it is safe to write
    /// before the directory move. A corrupt alias file is an error and is left
    /// unchanged, since rewriting it would drop every alias it holds.
    /// Test: `rename_retargets_aliases_pointing_at_the_old_id`,
    /// `rename_drops_the_alias_keyed_by_the_new_id`,
    /// `rename_target_rejects_empty_or_equal_ids`,
    /// `rename_target_refuses_a_corrupt_alias_file_and_keeps_its_bytes`.
    pub fn rename_target(registry_dir: &Path, old: &str, new: &str) -> Result<()> {
        let old = old.trim();
        let new = new.trim();
        if old.is_empty() || new.is_empty() {
            anyhow::bail!("palace rename ids must both be non-empty (old={old:?}, new={new:?})");
        }
        if old == new {
            anyhow::bail!("refusing to alias palace {old:?} to itself");
        }
        // #9544: strict read, so a corrupt file fails the rename instead of
        // being overwritten with `{old: new}`.
        let mut aliases = Self::read_aliases(registry_dir, true)?;
        // #9544: `new` becomes a real palace; an alias under it is inert and
        // would close an `old <-> new` cycle when a rename is reversed.
        aliases.remove(new);
        for target in aliases.values_mut() {
            if target == old {
                *target = new.to_string();
            }
        }
        aliases.insert(old.to_string(), new.to_string());
        Self::write_aliases(registry_dir, aliases)
    }

    /// Remove one alias so its name stops resolving.
    ///
    /// Why (#9544): a renamed palace's old id answers "until removed"; this is
    /// the removal.
    /// What: loads the map, removes the entry keyed by `alias` (trimmed), and
    /// writes the map back only when an entry was removed. Returns whether one
    /// was. The target palace is never touched.
    /// Test: `alias_remove_stops_old_id_resolving`.
    pub fn remove_alias(registry_dir: &Path, alias: &str) -> Result<bool> {
        let mut aliases = Self::load_aliases(registry_dir)?;
        if aliases.remove(alias.trim()).is_none() {
            return Ok(false);
        }
        Self::write_aliases(registry_dir, aliases)?;
        Ok(true)
    }

    /// Persist the whole alias map with a tmp + rename write.
    ///
    /// Why: every mutation must publish a complete map, never a partial one.
    /// What: creates `registry_dir`, writes `palace_aliases.json.tmp`, renames
    /// it over `palace_aliases.json`. The tmp name is fixed, so concurrent
    /// writers must be serialised by the caller.
    /// Test: `register_then_resolve_round_trips`.
    fn write_aliases(registry_dir: &Path, aliases: BTreeMap<String, String>) -> Result<()> {
        std::fs::create_dir_all(registry_dir)
            .with_context(|| format!("create registry dir {}", registry_dir.display()))?;
        let file = PalaceAliasesFile {
            version: default_alias_schema_version(),
            aliases,
        };
        let bytes = serde_json::to_vec_pretty(&file).context("serialize palace aliases")?;

        let target_path = registry_dir.join(PALACE_ALIASES_JSON);
        let tmp_path = registry_dir.join(format!("{PALACE_ALIASES_JSON}.tmp"));
        std::fs::write(&tmp_path, &bytes)
            .with_context(|| format!("write palace aliases tmp {}", tmp_path.display()))?;
        std::fs::rename(&tmp_path, &target_path)
            .with_context(|| format!("rename palace aliases into {}", target_path.display()))?;
        Ok(())
    }

    /// Resolve a single alias to its target palace name, if one is registered.
    ///
    /// Why: the palace registry consults this when a requested palace has no
    /// on-disk metadata — a hit redirects the open to the target's store.
    /// What: loads the map and returns `Some(target)` when `alias` is present,
    /// `None` otherwise. A missing map yields `None`.
    /// Test: `register_then_resolve_round_trips`, `resolve_unknown_is_none`.
    pub fn resolve_alias(registry_dir: &Path, alias: &str) -> Result<Option<String>> {
        Ok(Self::load_aliases(registry_dir)?.get(alias.trim()).cloned())
    }
}

/// Name the palace an alias redirects to, but only when the redirect actually fires.
///
/// Why (#5810): the registry applied this rule privately, so every caller that
/// wanted to *name* the palace it was about to reach had no way to ask. The
/// `UserPromptSubmit` hook printed the derived slug — `bobmatnyc-trusty-tools` —
/// while the drawers under it came from `trusty-tools`, and `palace_list` returns
/// only the latter. A second copy of the rule in trusty-memory would be the drift
/// this workspace's common-entry-point rule forbids, so the rule lives here and
/// [`crate::memory_core::registry`] now reads it from the same place.
/// What: returns `Some(target)` only when `<registry_dir>/<palace_id>/palace.json`
/// is absent AND an alias maps `palace_id` to a `target` whose own `palace.json`
/// is present. Returns `None` in every other case — the palace exists, no alias is
/// registered, the target is missing, or the alias file cannot be read — so a
/// caller's `unwrap_or(derived)` keeps today's behaviour on every degraded path.
///
/// Presence is `try_exists`, and only `Ok(false)` counts as absent (#5592,
/// ADR-0045): a path we are denied to stat presumes PRESENT, so an unverifiable
/// alias target never wins a redirect and an unstattable palace is never shadowed.
/// Test: `alias_target_is_none_when_the_palace_exists`,
/// `alias_target_names_the_redirect`,
/// `alias_target_is_none_when_the_target_is_missing`,
/// `alias_target_is_none_without_an_alias`.
pub fn alias_target_if_absent(registry_dir: &Path, palace_id: &str) -> Option<String> {
    try_alias_target_if_absent(registry_dir, palace_id)
        .ok()
        .flatten()
}

/// [`alias_target_if_absent`], but an unreadable alias file is an error.
///
/// Why (#9544): the `create_palace` guard must fail closed. Reading an
/// unreadable alias file as "no alias" lets a create through that shadows the
/// alias for good once the read error clears.
/// What: the same presence rule and redirect as [`alias_target_if_absent`].
/// A missing alias file is `Ok(None)`, and a corrupt one reads as no aliases,
/// as at open time. Any other read error on the alias file is returned.
/// Test: `palace_create_fails_when_the_alias_file_is_unreadable`.
pub(crate) fn try_alias_target_if_absent(
    registry_dir: &Path,
    palace_id: &str,
) -> Result<Option<String>> {
    let exists = |id: &str| {
        !matches!(
            registry_dir.join(id).join("palace.json").try_exists(),
            Ok(false)
        )
    };
    if exists(palace_id) {
        return Ok(None);
    }
    // #9544: propagate a read error; the infallible wrapper drops it.
    Ok(PalaceAliasStore::resolve_alias(registry_dir, palace_id)?.filter(|target| exists(target)))
}

/// The id a palace request actually reaches: the live alias target, else itself.
///
/// Why (#9544): every caller that keys state by palace id (write locks, session
/// stores, lexical-lane dirs) must agree on one id per palace, or an
/// alias-addressed write and a canonical write take different locks for one
/// store. This names that id with the same rule the registry's open follows.
/// What: [`alias_target_if_absent`] when it names a redirect, `palace_id`
/// unchanged otherwise. Never fails.
/// Test: `canonical_palace_id_follows_only_a_live_alias`.
pub fn canonical_palace_id(registry_dir: &Path, palace_id: &str) -> String {
    alias_target_if_absent(registry_dir, palace_id).unwrap_or_else(|| palace_id.to_string())
}

/// A palace create refused because the id is a live alias.
///
/// Why (#9544): creating a palace under a name that currently redirects would
/// shadow the alias (a real palace always wins), silently splitting reads and
/// writes for that name off the palace it pointed at.
/// What: carries the alias and the palace it resolves to. Returned, inside an
/// `anyhow::Error`, by `PalaceRegistry::create_palace`; callers downcast it to
/// map the refusal to a conflict. Hand-rolled because `thiserror` is optional
/// in this crate and this module is always compiled.
/// Test: `palace_create_refuses_a_live_alias_name`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveAliasError {
    /// The requested palace id, which is a live alias.
    pub alias: String,
    /// The palace the alias resolves to.
    pub target: String,
}

impl std::fmt::Display for LiveAliasError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "palace id {:?} is an alias of palace {:?}; remove the alias before creating a palace with that id",
            self.alias, self.target
        )
    }
}

impl std::error::Error for LiveAliasError {}

/// Resolve the directory that holds the per-palace subdirectories for a data dir.
///
/// Why: two on-disk layouts exist in the wild. The current code treats the data
/// dir itself as the parent of per-palace dirs (`<data_dir>/<id>/palace.json`);
/// legacy standalone installs nest everything under a `palaces/` subdirectory
/// (`<data_dir>/palaces/<id>/palace.json`), which is where real installs' data
/// lives. trusty-mpm (which registers aliases) and trusty-memory (which resolves
/// them) MUST agree on this directory or the alias file lands somewhere the
/// daemon never reads. Centralising the choice here keeps the two in lockstep —
/// trusty-memory's `resolve_palace_registry_dir` delegates to this.
/// What: returns `<data_dir>/palaces` when that subdirectory exists, else
/// `<data_dir>` itself.
/// Test: `palace_registry_dir_prefers_palaces_subdir`,
/// `palace_registry_dir_falls_back_to_data_dir`.
pub fn palace_registry_dir_from(data_dir: PathBuf) -> PathBuf {
    let nested = data_dir.join("palaces");
    if nested.is_dir() { nested } else { data_dir }
}

/// Resolve the default trusty-memory palace registry directory for this machine.
///
/// Why: trusty-mpm's session-launch alias registration must write the alias file
/// to the exact directory the trusty-memory daemon uses as its palace registry
/// root, WITHOUT depending on the `trusty-memory` crate. Reusing
/// [`crate::resolve_data_dir`] (which honours `TRUSTY_DATA_DIR_OVERRIDE`) keeps
/// path derivation identical to the daemon's, so tests and production agree.
/// What: resolves `resolve_data_dir("trusty-memory")` (the app data dir, created
/// if absent) and applies [`palace_registry_dir_from`] to pick the `palaces/`
/// subdir when present. Returns the registry directory.
/// Test: side-effect-bearing (creates the app data dir); the pure subdir choice
/// it composes is covered by `palace_registry_dir_*`.
pub fn default_palace_registry_dir() -> Result<PathBuf> {
    let data_dir = crate::resolve_data_dir("trusty-memory")?;
    Ok(palace_registry_dir_from(data_dir))
}

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

    /// Why: a fresh install has no alias file; load must return an empty map,
    /// never an error, so palace resolution proceeds normally.
    /// Test: itself.
    #[test]
    fn load_missing_is_empty() {
        let tmp = tempdir().unwrap();
        let aliases = PalaceAliasStore::load_aliases(tmp.path()).expect("load");
        assert!(aliases.is_empty());
    }

    /// Why: the core contract — a registered alias must be readable back both via
    /// the full map and the single-key resolver, and it must survive as an
    /// on-disk file (a fresh read from the same dir).
    /// Test: itself.
    #[test]
    fn register_then_resolve_round_trips() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "bobmatnyc-trusty-tools", "trusty-tools")
            .expect("register");

        // Single-key resolve.
        assert_eq!(
            PalaceAliasStore::resolve_alias(tmp.path(), "bobmatnyc-trusty-tools")
                .expect("resolve")
                .as_deref(),
            Some("trusty-tools")
        );
        // Full-map load reflects it too.
        let all = PalaceAliasStore::load_aliases(tmp.path()).expect("load");
        assert_eq!(
            all.get("bobmatnyc-trusty-tools").map(String::as_str),
            Some("trusty-tools")
        );
        // File actually exists on disk.
        assert!(tmp.path().join(PALACE_ALIASES_JSON).exists());
    }

    /// Why: relaunching the same managed session repeatedly must not duplicate or
    /// error — re-registering the same pair converges on one entry, and pointing
    /// an alias at a new target overwrites in place.
    /// Test: itself.
    #[test]
    fn register_is_idempotent() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all.get("a").map(String::as_str), Some("b"));

        // Overwrite to a new target.
        PalaceAliasStore::register_alias(tmp.path(), "a", "c").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all.get("a").map(String::as_str), Some("c"));
    }

    /// Why: a self-alias is a useless no-op / potential cycle; it must be
    /// rejected so a mis-derived slug can never point a palace at itself.
    /// Test: itself.
    #[test]
    fn register_self_alias_is_rejected() {
        let tmp = tempdir().unwrap();
        assert!(PalaceAliasStore::register_alias(tmp.path(), "same", "same").is_err());
        assert!(PalaceAliasStore::register_alias(tmp.path(), "same", " same ").is_err());
    }

    /// Why: empty operands would create a meaningless map entry; guard against
    /// them so a caller passing a blank slug fails loudly rather than silently.
    /// Test: itself.
    #[test]
    fn register_rejects_empty() {
        let tmp = tempdir().unwrap();
        assert!(PalaceAliasStore::register_alias(tmp.path(), "", "b").is_err());
        assert!(PalaceAliasStore::register_alias(tmp.path(), "a", "  ").is_err());
    }

    /// Why: an alias that was never registered must resolve to `None` so the
    /// registry surfaces the normal "metadata missing" error.
    /// Test: itself.
    #[test]
    fn resolve_unknown_is_none() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        assert_eq!(
            PalaceAliasStore::resolve_alias(tmp.path(), "zzz").unwrap(),
            None
        );
    }

    /// Why: a corrupt alias file must degrade to "no aliases", never panic or
    /// error, so it cannot wedge palace resolution.
    /// Test: itself.
    #[test]
    fn corrupt_file_degrades_to_empty() {
        let tmp = tempdir().unwrap();
        std::fs::write(tmp.path().join(PALACE_ALIASES_JSON), b"{ not json ]").unwrap();
        let all =
            PalaceAliasStore::load_aliases(tmp.path()).expect("load never errors on corruption");
        assert!(all.is_empty());
    }

    /// Why: when a `palaces/` subdir exists (the real-install layout) the
    /// registry dir must point INTO it so aliases sit beside the palaces.
    /// Test: itself.
    #[test]
    fn palace_registry_dir_prefers_palaces_subdir() {
        let tmp = tempdir().unwrap();
        let nested = tmp.path().join("palaces");
        std::fs::create_dir_all(&nested).unwrap();
        assert_eq!(palace_registry_dir_from(tmp.path().to_path_buf()), nested);
    }

    /// Write a minimal `palace.json` so the presence probe sees a real palace.
    fn seed_palace(registry_dir: &Path, id: &str) {
        let dir = registry_dir.join(id);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("palace.json"), b"{}").unwrap();
    }

    /// Why: an alias must never shadow a real palace, so a palace that exists on
    /// disk reports no redirect even when an alias names it.
    /// Test: itself.
    #[test]
    fn alias_target_is_none_when_the_palace_exists() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "owner-repo");
        seed_palace(tmp.path(), "repo");
        PalaceAliasStore::register_alias(tmp.path(), "owner-repo", "repo").unwrap();
        assert_eq!(alias_target_if_absent(tmp.path(), "owner-repo"), None);
    }

    /// Why: the split-brain case this exists for — the derived name owns no
    /// directory, the alias target does, so the target is the usable name.
    /// Test: itself.
    #[test]
    fn alias_target_names_the_redirect() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "trusty-tools");
        PalaceAliasStore::register_alias(tmp.path(), "bobmatnyc-trusty-tools", "trusty-tools")
            .unwrap();
        assert_eq!(
            alias_target_if_absent(tmp.path(), "bobmatnyc-trusty-tools").as_deref(),
            Some("trusty-tools")
        );
    }

    /// Why: a stale alias pointing at a deleted palace must not rename anything —
    /// the caller keeps the original id so its error names the palace asked for.
    /// Test: itself.
    #[test]
    fn alias_target_is_none_when_the_target_is_missing() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "ghost", "also-gone").unwrap();
        assert_eq!(alias_target_if_absent(tmp.path(), "ghost"), None);
    }

    /// Why: the common case — no alias file at all — must be a cheap `None`.
    /// Test: itself.
    #[test]
    fn alias_target_is_none_without_an_alias() {
        let tmp = tempdir().unwrap();
        assert_eq!(alias_target_if_absent(tmp.path(), "anything"), None);
    }

    /// Why (#9544): after a rename, an older alias of the old id must reach the
    /// new id in the same write that makes the old id an alias, so no alias is
    /// left naming a palace id that no longer exists.
    /// Test: itself.
    #[test]
    fn rename_retargets_aliases_pointing_at_the_old_id() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "older", "old-id").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "unrelated", "elsewhere").unwrap();
        PalaceAliasStore::rename_target(tmp.path(), "old-id", "new-id").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.get("old-id").map(String::as_str), Some("new-id"));
        assert_eq!(all.get("older").map(String::as_str), Some("new-id"));
        assert_eq!(all.get("unrelated").map(String::as_str), Some("elsewhere"));
        assert_eq!(all.len(), 3);

        // Once the directory has moved, both names resolve to the new palace.
        seed_palace(tmp.path(), "new-id");
        assert_eq!(canonical_palace_id(tmp.path(), "old-id"), "new-id");
        assert_eq!(canonical_palace_id(tmp.path(), "older"), "new-id");
    }

    /// Why (#9544): reversing a rename (`a -> b`, then `b` back to `a`) must not
    /// leave `a -> b` and `b -> a` in the map; the new id owns a palace, so any
    /// alias keyed by it goes.
    /// Test: itself.
    #[test]
    fn rename_drops_the_alias_keyed_by_the_new_id() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::rename_target(tmp.path(), "first", "second").unwrap();
        PalaceAliasStore::rename_target(tmp.path(), "second", "first").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.get("second").map(String::as_str), Some("first"));
        assert_eq!(
            all.get("first"),
            None,
            "no alias may be keyed by the new id"
        );
        assert!(all.iter().all(|(k, v)| k != v), "no self-alias: {all:?}");
    }

    /// Why (#9544): a blank id or a rename onto itself is a caller bug; it must
    /// fail and leave the map as it was.
    /// Test: itself.
    #[test]
    fn rename_target_rejects_empty_or_equal_ids() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "keep", "kept").unwrap();
        assert!(PalaceAliasStore::rename_target(tmp.path(), "", "b").is_err());
        assert!(PalaceAliasStore::rename_target(tmp.path(), "a", "  ").is_err());
        assert!(PalaceAliasStore::rename_target(tmp.path(), "same", " same ").is_err());
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all.get("keep").map(String::as_str), Some("kept"));
    }

    /// Why (#9544): a corrupt alias file may still hold aliases a person can
    /// recover. A rename that reads it as empty and writes `{old: new}` over it
    /// destroys them, so the rename must fail and leave the bytes as they were.
    /// Test: itself.
    #[test]
    fn rename_target_refuses_a_corrupt_alias_file_and_keeps_its_bytes() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join(PALACE_ALIASES_JSON);
        let garbage: &[u8] = br#"{"aliases": {"keep": "kept""#;
        std::fs::write(&path, garbage).unwrap();
        assert!(
            PalaceAliasStore::rename_target(tmp.path(), "old-id", "new-id").is_err(),
            "a corrupt alias file must refuse the rename"
        );
        assert_eq!(std::fs::read(&path).unwrap(), garbage);
    }

    /// Why (#9544): the old id answers "until removed"; after removal it must
    /// stop redirecting while every other alias stays.
    /// Test: itself.
    #[test]
    fn alias_remove_stops_old_id_resolving() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "new-id");
        PalaceAliasStore::register_alias(tmp.path(), "old-id", "new-id").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "other", "new-id").unwrap();
        assert_eq!(canonical_palace_id(tmp.path(), "old-id"), "new-id");

        assert!(PalaceAliasStore::remove_alias(tmp.path(), " old-id ").unwrap());
        assert_eq!(canonical_palace_id(tmp.path(), "old-id"), "old-id");
        assert_eq!(canonical_palace_id(tmp.path(), "other"), "new-id");
        assert!(
            !PalaceAliasStore::remove_alias(tmp.path(), "old-id").unwrap(),
            "a second removal finds nothing"
        );
    }

    /// Why (#9544): the canonical id is the alias target only while the redirect
    /// is live; a real palace or a dead target keeps the requested id.
    /// Test: itself.
    #[test]
    fn canonical_palace_id_follows_only_a_live_alias() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "target");
        PalaceAliasStore::register_alias(tmp.path(), "live", "target").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "dead", "gone").unwrap();
        assert_eq!(canonical_palace_id(tmp.path(), "live"), "target");
        assert_eq!(canonical_palace_id(tmp.path(), "dead"), "dead");
        assert_eq!(canonical_palace_id(tmp.path(), "target"), "target");

        seed_palace(tmp.path(), "live");
        assert_eq!(canonical_palace_id(tmp.path(), "live"), "live");
    }

    /// Why: absent a `palaces/` subdir the data dir itself is the registry root
    /// (the monorepo layout), matching trusty-memory's fallback.
    /// Test: itself.
    #[test]
    fn palace_registry_dir_falls_back_to_data_dir() {
        let tmp = tempdir().unwrap();
        assert_eq!(
            palace_registry_dir_from(tmp.path().to_path_buf()),
            tmp.path().to_path_buf()
        );
    }
}