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
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
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
//! Unit tests for the canonical palace resolver (#5811).
//!
//! Why: the defect this module fixes was a precedence question that no test
//! could previously ask, because every level returned a bare `String`. These
//! tests assert the deciding level, not just the value.
//!
//! Tests that mutate `TRUSTY_MEMORY_PALACE` are `#[serial]` — the variable is
//! process-global and several other suites in this crate read it.

use super::*;
use std::fs;

/// Write a valid pin file under `root`, creating `.trusty-tools/`.
fn write_pin(root: &Path, palace: &str) -> PathBuf {
    let dir = root.join(TRUSTY_TOOLS_DIR);
    fs::create_dir_all(&dir).expect("create .trusty-tools");
    let path = root.join(PIN_FILE_REL);
    let pin = ProjectPin::new(palace);
    fs::write(&path, serde_yaml::to_string(&pin).expect("serialise")).expect("write pin");
    path
}

/// Initialise a real git repo at `root` with `remote.origin.url` set.
///
/// Returns `false` when git is unavailable, so the caller can skip rather than
/// fail on a machine without git.
fn init_repo(root: &Path, remote: Option<&str>) -> bool {
    let ok = |args: &[&str]| {
        Command::new("git")
            .arg("-C")
            .arg(root)
            .args(args)
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    };
    if !ok(&["init", "-q"]) {
        return false;
    }
    // Local identity so `commit` works on a machine with no global config.
    ok(&["config", "user.email", "t@example.com"]);
    ok(&["config", "user.name", "T"]);
    if let Some(url) = remote {
        ok(&["config", "remote.origin.url", url]);
    }
    true
}

// ---------------------------------------------------------------------------
// find_project_root
// ---------------------------------------------------------------------------

/// Why: the pin lives at the project root, so a call from a nested directory
/// must still find it.
#[test]
fn finds_git_root_from_nested_dir() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(root.join(".git")).unwrap();
    let nested = root.join("crates").join("foo");
    fs::create_dir_all(&nested).unwrap();

    let found = find_project_root(&nested).expect("root");
    assert_eq!(found, fs::canonicalize(&root).unwrap());
}

/// Why: a directory carrying only a pin file must still count as a project.
#[test]
fn trusty_tools_dir_is_a_marker() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(root.join(TRUSTY_TOOLS_DIR)).unwrap();
    assert!(find_project_root(&root).is_some());
}

/// Build a repo at `<tmp>/my-project` with a linked worktree under
/// `.claude/worktrees/agent-x`, mirroring this repo's own layout.
///
/// Why: a hand-written `.git` file would prove the parser and nothing else. Only
/// a real `git worktree add` writes the admin directory — `commondir` included —
/// that [`main_checkout_of_worktree`] reads.
/// What: returns `(main, worktree)`, or `None` when git cannot do its part, so
/// the caller skips rather than failing on a machine without git.
fn repo_with_worktree(tmp: &Path) -> Option<(PathBuf, PathBuf)> {
    let main = tmp.join("my-project");
    fs::create_dir_all(&main).unwrap();
    if !init_repo(&main, None) {
        return None;
    }
    let run = |args: &[&str]| {
        Command::new("git")
            .arg("-C")
            .arg(&main)
            .args(args)
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    };
    // A worktree needs at least one commit to branch from.
    fs::write(main.join("README.md"), "x").unwrap();
    if !run(&["add", "-A"]) || !run(&["commit", "-qm", "init"]) {
        return None;
    }
    let wt = main.join(".claude").join("worktrees").join("agent-x");
    if !run(&["worktree", "add", "-q", "-b", "wt-branch", wt.to_str()?]) {
        return None;
    }
    Some((main, wt))
}

/// Why (#5888): THE defect. `current.join(".git").exists()` is true for the
/// `.git` FILE git writes in a linked worktree, so the walk stopped at the
/// worktree and every caller read — and lazily WROTE — the pin there instead of
/// in the checkout the project lives in. A worktree and its main checkout must
/// answer the same root (ADR-0012 §1).
/// What: a real `git worktree add`, guarded by an `is_file` assertion on the
/// fixture, resolved from the worktree root and from a directory nested inside
/// it.
/// Test: itself. Against `4a823a0b4` both assertions return the worktree.
#[test]
fn a_worktree_resolves_to_its_main_checkout() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let Some((main, wt)) = repo_with_worktree(tmp.path()) else {
        eprintln!("skipping a_worktree_resolves_to_its_main_checkout: git unavailable");
        return;
    };
    assert!(
        wt.join(".git").is_file(),
        "fixture must reproduce git's linked-worktree `.git` FILE"
    );
    let expected = fs::canonicalize(&main).expect("canonicalize main");

    assert_eq!(
        find_project_root(&wt).expect("worktree resolves"),
        expected,
        "a worktree must resolve to the checkout that owns it"
    );

    let nested = wt.join("crates").join("foo");
    fs::create_dir_all(&nested).unwrap();
    assert_eq!(
        find_project_root(&nested).expect("nested dir resolves"),
        expected,
        "a directory inside a worktree must resolve to the same checkout"
    );
}

/// Why (#5888): a submodule and any `--separate-git-dir` checkout carry a `.git`
/// file of the identical shape, and their own directory IS the project root.
/// Following that pointer would hand the caller `<outer>/.git/modules` — git
/// internals, not a working tree, the same wrong answer #5819 removed from
/// [`main_worktree_root`].
/// What: builds the `--separate-git-dir` structure, asserts the fixture really
/// wrote a `.git` file, and asserts the walk still stops at the checkout.
#[test]
fn a_separate_git_dir_checkout_is_its_own_root() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let outer = tmp.path().join("outer");
    let sub = outer.join("sub");
    fs::create_dir_all(&sub).unwrap();
    if !init_repo(&outer, None) {
        eprintln!("skipping a_separate_git_dir_checkout_is_its_own_root: git unavailable");
        return;
    }
    let separate = outer.join(".git").join("modules").join("sub");
    fs::create_dir_all(separate.parent().unwrap()).unwrap();
    let init = Command::new("git")
        .arg("-C")
        .arg(&sub)
        .args([
            "init",
            "-q",
            &format!("--separate-git-dir={}", separate.display()),
            ".",
        ])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if !init {
        eprintln!("skipping a_separate_git_dir_checkout_is_its_own_root: cannot init");
        return;
    }
    assert!(
        sub.join(".git").is_file(),
        "fixture must reproduce the `.git` FILE a submodule checkout carries"
    );

    assert_eq!(
        find_project_root(&sub).expect("resolves"),
        fs::canonicalize(&sub).expect("canonicalize sub"),
        "a checkout whose git dir is elsewhere is still its own project root"
    );
}

/// Why (#5888): the redirect is best-effort. A `.git` file that does not parse,
/// or whose pointer names nothing, must leave the caller with the answer it had
/// before rather than with `None` — losing the root is worse than not following
/// the pointer.
#[test]
fn a_malformed_dot_git_file_leaves_the_directory_as_the_root() {
    let tmp = tempfile::tempdir().expect("tempdir");
    for (name, body) in [
        ("garbage", "not a gitdir pointer at all\n"),
        ("empty-pointer", "gitdir:   \n"),
        ("dangling", "gitdir: /nonexistent-trusty-test-admin-5888\n"),
    ] {
        let root = tmp.path().join(name);
        fs::create_dir_all(&root).unwrap();
        fs::write(root.join(".git"), body).unwrap();

        assert_eq!(
            find_project_root(&root).expect("resolves"),
            fs::canonicalize(&root).expect("canonicalize root"),
            "a `.git` file that does not resolve ({name}) must leave the directory as the root"
        );
    }
}

/// Why: outside any project the caller must be told so, not handed a guess.
#[test]
fn no_markers_returns_none() {
    // A bare tempdir has no markers, but its ANCESTORS might (e.g. `/tmp` on a
    // machine where something dropped a marker). Assert on a synthetic path
    // that cannot exist instead.
    let missing = Path::new("/nonexistent-trusty-test-root-5802/deeper");
    assert!(find_project_root(missing).is_none());
}

// ---------------------------------------------------------------------------
// read_project_pin — the fail-closed contract
// ---------------------------------------------------------------------------

/// Why: the happy path — a well-formed pin parses to its palace.
#[test]
fn reads_a_valid_pin() {
    let tmp = tempfile::tempdir().expect("tempdir");
    write_pin(tmp.path(), "canonical-name");
    let pin = read_project_pin(tmp.path()).expect("ok").expect("some");
    assert_eq!(pin.palace, "canonical-name");
}

/// Why: "no pin" is the normal case and must not be an error — it is what lets
/// an unpinned project fall through to derivation.
#[test]
fn absent_pin_is_ok_none() {
    let tmp = tempfile::tempdir().expect("tempdir");
    assert!(read_project_pin(tmp.path()).expect("ok").is_none());
}

/// Why: THE fail-open regression (#5811). A pin file that exists but does not
/// parse used to log a warning and fall through to git derivation, handing the
/// caller a plausible name for a palace nobody chose. Against the pre-fix
/// `trusty_memory::project_root::project_slug_at`, this input returned
/// `Some("<basename>")`; it must now be an error.
#[test]
fn malformed_pin_is_an_error_not_a_fallthrough() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("some-project");
    fs::create_dir_all(root.join(".git")).unwrap();
    fs::create_dir_all(root.join(TRUSTY_TOOLS_DIR)).unwrap();
    // Valid YAML, wrong shape — `palace` is a map where a string is required.
    fs::write(
        root.join(PIN_FILE_REL),
        "schema_version: 1\npalace:\n  not: a-string\n",
    )
    .unwrap();

    let err = read_project_pin(&root).expect_err("malformed pin must error");
    assert!(
        matches!(err, PalaceResolveError::PinMalformed { .. }),
        "expected PinMalformed, got {err:?}"
    );

    // And the full resolver must propagate rather than derive past it.
    let err = resolve_palace(&root).expect_err("resolver must not fall through");
    assert!(
        matches!(err, PalaceResolveError::PinMalformed { .. }),
        "expected PinMalformed from resolve_palace, got {err:?}"
    );
}

/// Why: a pin whose `palace` field is present but blank is equally
/// untrustworthy — it names no palace, and deriving past it silently redirects
/// writes.
#[test]
fn empty_pin_palace_is_an_error() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(root.join(".git")).unwrap();
    write_pin(&root, "   ");

    let err = resolve_palace(&root).expect_err("empty pin must error");
    assert!(
        matches!(err, PalaceResolveError::PinEmpty { .. }),
        "expected PinEmpty, got {err:?}"
    );
}

/// Why (#6418): the pin level used to return its `palace` field verbatim while
/// levels 1, 3 and 4 all clamped, so a dotted or traversal-shaped id could reach
/// a palace directory name and a socket filename. Each input here came back
/// unchanged from `resolve_palace` before the fix.
#[test]
#[serial_test::serial]
fn a_pin_naming_an_invalid_palace_id_is_an_error() {
    // The variable is cleared so the assertion is about level 2, not about the
    // override that outranks it.
    let _guard = EnvGuard::clear();
    let too_long = "a".repeat(PALACE_ID_MAX_LEN + 1);
    let cases = [
        "tripbot.tours",
        "../evil",
        "..",
        "a/b",
        "UPPER",
        "has_underscore",
        "-leading-hyphen",
        too_long.as_str(),
    ];
    for value in cases {
        let tmp = tempfile::tempdir().expect("tempdir");
        let root = tmp.path().join("proj");
        fs::create_dir_all(root.join(".git")).unwrap();
        write_pin(&root, value);

        match resolve_palace(&root) {
            Ok(resolution) => panic!("pin {value:?} resolved to {:?}", resolution.id),
            Err(PalaceResolveError::PinInvalid { value: got, .. }) => {
                assert_eq!(got, value, "the error must name the rejected value");
            }
            Err(other) => panic!("pin {value:?} expected PinInvalid, got {other:?}"),
        }
    }
}

/// Why (#6418): the guard rejects an id shape, not a legitimate pin — a pin that
/// already satisfies the daemon's format gate must still resolve, and surrounding
/// whitespace is not a shape violation.
#[test]
#[serial_test::serial]
fn a_valid_pin_still_resolves_and_is_trimmed() {
    let _guard = EnvGuard::clear();
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(root.join(".git")).unwrap();
    write_pin(&root, "  canonical-name9  ");

    let got = resolve_palace(&root).expect("a valid pin resolves");
    assert_eq!(got.id, "canonical-name9");
    assert_eq!(got.source, PalaceSource::PinFile);
}

/// Why: an unreadable pin (permissions) is the third untrustworthy case and
/// must not degrade to derivation either.
#[cfg(unix)]
#[test]
fn unreadable_pin_is_an_error() {
    use std::os::unix::fs::PermissionsExt;
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(root.join(".git")).unwrap();
    let path = write_pin(&root, "pinned");

    let mut perms = fs::metadata(&path).unwrap().permissions();
    perms.set_mode(0o000);
    fs::set_permissions(&path, perms).unwrap();

    let result = read_project_pin(&root);

    // Restore before asserting so the tempdir can always be cleaned up.
    let mut restore = fs::metadata(&path).unwrap().permissions();
    restore.set_mode(0o644);
    fs::set_permissions(&path, restore).unwrap();

    // Running as root defeats the permission bit entirely; skip rather than
    // assert a guarantee the environment cannot provide.
    if let Ok(Some(_)) = result {
        eprintln!("skipping unreadable_pin_is_an_error: running with root privileges");
        return;
    }
    let err = result.expect_err("unreadable pin must error");
    assert!(
        matches!(err, PalaceResolveError::PinUnreadable { .. }),
        "expected PinUnreadable, got {err:?}"
    );
}

// ---------------------------------------------------------------------------
// Precedence
// ---------------------------------------------------------------------------

/// Why: the core of the reported symptom. A committed pin must beat the git
/// `owner/repo` derivation — the pin exists precisely to stop the derived name
/// from orphaning memories written under the pinned one.
#[test]
#[serial_test::serial]
fn pin_beats_git_derivation() {
    let _guard = EnvGuard::clear();
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("checkout-dir");
    fs::create_dir_all(&root).unwrap();
    if !init_repo(&root, Some("git@github.com:bobmatnyc/trusty-tools.git")) {
        eprintln!("skipping pin_beats_git_derivation: git unavailable");
        return;
    }
    write_pin(&root, "trusty-tools");

    let got = resolve_palace(&root).expect("resolves");
    assert_eq!(got.id, "trusty-tools");
    assert_eq!(got.source, PalaceSource::PinFile);
}

/// Why: without a pin the git identity decides, and it is hyphenated so it is
/// safe as a directory name and a socket filename.
#[test]
#[serial_test::serial]
fn git_owner_repo_used_when_unpinned() {
    let _guard = EnvGuard::clear();
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("checkout-dir");
    fs::create_dir_all(&root).unwrap();
    if !init_repo(&root, Some("git@github.com:bobmatnyc/trusty-tools.git")) {
        eprintln!("skipping git_owner_repo_used_when_unpinned: git unavailable");
        return;
    }

    let got = resolve_palace(&root).expect("resolves");
    assert_eq!(got.id, "bobmatnyc-trusty-tools");
    assert_eq!(got.source, PalaceSource::GitOwnerRepo);
}

/// Why: the operator escape hatch must keep working — it is how CI and test
/// rigs pin a palace — and its precedence must be deterministic and reported,
/// so a producer that launders a derived value through it is diagnosable.
#[test]
#[serial_test::serial]
fn env_override_wins_over_pin_and_warns() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(root.join(".git")).unwrap();
    write_pin(&root, "pinned-name");

    let _guard = EnvGuard::set("operator-choice");
    let got = resolve_palace(&root).expect("resolves");
    assert_eq!(got.id, "operator-choice");
    assert_eq!(got.source, PalaceSource::EnvOverride);
}

/// Why: an override that slugifies to nothing must not shadow the pin — it is
/// not a choice, it is an empty variable.
#[test]
#[serial_test::serial]
fn blank_env_override_falls_through_to_pin() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(root.join(".git")).unwrap();
    write_pin(&root, "pinned-name");

    let _guard = EnvGuard::set("   ");
    let got = resolve_palace(&root).expect("resolves");
    assert_eq!(got.id, "pinned-name");
    assert_eq!(got.source, PalaceSource::PinFile);
}

/// Why (#2443): this level slugifies the variable itself rather than going
/// through the pure core, so it was the one derived id with no length bound —
/// an over-long `TRUSTY_MEMORY_PALACE` reached `palace_create` and was rejected,
/// leaving the caller's sink dead. Every level must produce an id the daemon's
/// gate accepts.
#[test]
#[serial_test::serial]
fn an_over_long_env_override_still_resolves_to_an_acceptable_id() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(root.join(".git")).unwrap();

    let _guard = EnvGuard::set(&"Very_Long Operator Palace Name ".repeat(6));
    let got = resolve_palace(&root).expect("resolves");
    assert_eq!(got.source, PalaceSource::EnvOverride);
    assert!(
        crate::palace_id::palace_id_is_valid(&got.id),
        "override-derived id must pass the daemon gate, got {:?} ({} bytes)",
        got.id,
        got.id.len()
    );
}

/// Why (#2443): the `parent/dir` fallback is what a project with no remote and
/// no pin gets, and a long directory name pushed it past the daemon's limit.
#[test]
#[serial_test::serial]
fn a_long_project_dir_resolves_to_an_acceptable_id() {
    let _guard = EnvGuard::clear();
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp
        .path()
        .join("a-project-directory-named-far-past-any-reasonable-length-limit-indeed");
    fs::create_dir_all(root.join(TRUSTY_TOOLS_DIR)).unwrap();

    let got = resolve_palace(&root).expect("resolves");
    assert!(
        crate::palace_id::palace_id_is_valid(&got.id),
        "parent/dir id must pass the daemon gate, got {:?} ({} bytes)",
        got.id,
        got.id.len()
    );
}

/// Why: a malformed pin must error even when the env override would have won
/// anyway. The pin is read before precedence is applied precisely so a broken
/// pin cannot hide behind a variable that happens to be set today and gone
/// tomorrow.
#[test]
#[serial_test::serial]
fn malformed_pin_errors_even_under_env_override() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(root.join(".git")).unwrap();
    fs::create_dir_all(root.join(TRUSTY_TOOLS_DIR)).unwrap();
    fs::write(root.join(PIN_FILE_REL), "palace: [unclosed\n").unwrap();

    let _guard = EnvGuard::set("operator-choice");
    let err = resolve_palace(&root).expect_err("must error");
    assert!(
        matches!(err, PalaceResolveError::PinMalformed { .. }),
        "expected PinMalformed, got {err:?}"
    );
}

// ---------------------------------------------------------------------------
// Worktree stability — ADR-0012 §1
// ---------------------------------------------------------------------------

/// Why: a palace slug is per-project and shared across all worktrees and
/// branches of the same repo (ADR-0012 §1). Before this fix the `parent/dir`
/// fallback keyed on `git rev-parse --show-toplevel`, which names the
/// WORKTREE — so a worktree at
/// `<root>/.claude/worktrees/agent-x` derived `worktrees-agent-x` while its main
/// checkout derived `<parent>-<root>`. Two palaces, one project.
///
/// This exercises the unpinned, REMOTELESS case on purpose: with a pin or a
/// remote the two agree for unrelated reasons, so only this case proves the
/// `--git-common-dir` change.
#[test]
#[serial_test::serial]
fn worktree_and_main_checkout_agree() {
    let _guard = EnvGuard::clear();
    let tmp = tempfile::tempdir().expect("tempdir");
    let main = tmp.path().join("my-project");
    fs::create_dir_all(&main).unwrap();
    if !init_repo(&main, None) {
        eprintln!("skipping worktree_and_main_checkout_agree: git unavailable");
        return;
    }
    // A worktree needs at least one commit to branch from.
    fs::write(main.join("README.md"), "x").unwrap();
    let run = |args: &[&str]| {
        Command::new("git")
            .arg("-C")
            .arg(&main)
            .args(args)
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    };
    if !run(&["add", "-A"]) || !run(&["commit", "-qm", "init"]) {
        eprintln!("skipping worktree_and_main_checkout_agree: cannot commit");
        return;
    }
    let wt = main.join(".claude").join("worktrees").join("agent-x");
    if !run(&[
        "worktree",
        "add",
        "-q",
        "-b",
        "wt-branch",
        wt.to_str().unwrap(),
    ]) {
        eprintln!("skipping worktree_and_main_checkout_agree: cannot add worktree");
        return;
    }

    let from_main = resolve_palace(&main).expect("main resolves");
    let from_worktree = resolve_palace(&wt).expect("worktree resolves");

    assert_eq!(
        from_main.id, from_worktree.id,
        "a worktree must resolve to the same palace as its main checkout \
         (main={:?} from {:?}, worktree={:?} from {:?})",
        from_main.id, from_main.source, from_worktree.id, from_worktree.source
    );
    assert_eq!(from_main.source, PalaceSource::ParentDir);
    assert_eq!(from_worktree.source, PalaceSource::ParentDir);
}

/// Why: a pinned repo must also agree across worktrees. The pin is a tracked
/// file, so it is checked out into every worktree — this asserts the resolver
/// actually reads it there rather than stopping at some other root.
#[test]
#[serial_test::serial]
fn worktree_and_main_checkout_agree_when_pinned() {
    let _guard = EnvGuard::clear();
    let tmp = tempfile::tempdir().expect("tempdir");
    let main = tmp.path().join("my-project");
    fs::create_dir_all(&main).unwrap();
    if !init_repo(&main, Some("git@github.com:acme/widget.git")) {
        eprintln!("skipping worktree_and_main_checkout_agree_when_pinned: git unavailable");
        return;
    }
    write_pin(&main, "legacy-widget");
    let run = |args: &[&str]| {
        Command::new("git")
            .arg("-C")
            .arg(&main)
            .args(args)
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    };
    if !run(&["add", "-A"]) || !run(&["commit", "-qm", "init"]) {
        eprintln!("skipping worktree_and_main_checkout_agree_when_pinned: cannot commit");
        return;
    }
    let wt = main.join(".claude").join("worktrees").join("agent-y");
    if !run(&["worktree", "add", "-q", "-b", "wt2", wt.to_str().unwrap()]) {
        eprintln!("skipping worktree_and_main_checkout_agree_when_pinned: cannot add worktree");
        return;
    }

    let from_main = resolve_palace(&main).expect("main resolves");
    let from_worktree = resolve_palace(&wt).expect("worktree resolves");
    assert_eq!(from_main.id, "legacy-widget");
    assert_eq!(from_worktree.id, "legacy-widget");
    assert_eq!(from_worktree.source, PalaceSource::PinFile);
}

// ---------------------------------------------------------------------------
// git helpers
// ---------------------------------------------------------------------------

/// Why (#5819): `--git-common-dir` is `<outer>/.git/modules/<name>` inside a
/// submodule, so "parent of the common dir" is `<outer>/.git/modules` — git
/// internals, not a working tree — and the probe returned it confidently. A
/// caller cannot tell that answer from a real root by looking at the path, and
/// trusty-memory's prompt-context filter drops every drawer whose recorded cwd
/// falls outside the root it is given.
/// What: builds the `.git`-file structure a submodule produces, via `git init
/// --separate-git-dir` into `<outer>/.git/modules/sub`, then asserts the probe
/// declines instead of naming the internals directory.
/// Test: itself. Fails against `0ac9e1f4`, which returns
/// `<outer>/.git/modules`.
#[test]
fn separate_git_dir_child_yields_none_not_a_git_internals_path() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let outer = tmp.path().join("outer");
    let sub = outer.join("sub");
    fs::create_dir_all(&sub).unwrap();
    if !init_repo(&outer, None) {
        eprintln!(
            "skipping separate_git_dir_child_yields_none_not_a_git_internals_path: git unavailable"
        );
        return;
    }
    let modules = outer.join(".git").join("modules");
    fs::create_dir_all(&modules).unwrap();
    let separate = modules.join("sub");
    let init = Command::new("git")
        .arg("-C")
        .arg(&sub)
        .args([
            "init",
            "-q",
            &format!("--separate-git-dir={}", separate.display()),
            ".",
        ])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if !init {
        eprintln!(
            "skipping separate_git_dir_child_yields_none_not_a_git_internals_path: cannot init"
        );
        return;
    }
    assert!(
        sub.join(".git").is_file(),
        "fixture must reproduce the `.git` FILE a submodule checkout carries"
    );

    let resolved = main_worktree_root(&sub);
    assert_eq!(
        resolved, None,
        "a common dir under `.git/modules` names no working tree; the probe must \
         decline rather than return {resolved:?}"
    );
}

/// Why: outside a repo both probes must decline rather than guess.
#[test]
fn git_probes_outside_a_repo_are_none() {
    let missing = Path::new("/nonexistent-trusty-test-root-5802");
    assert!(git_remote_origin(missing).is_none());
    assert!(main_worktree_root(missing).is_none());
}

/// Why: the explicit-remote channel exists for cloned sessions that know their
/// origin before a checkout exists; it must beat the probed remote.
#[test]
#[serial_test::serial]
fn explicit_remote_beats_probed_remote() {
    let _guard = EnvGuard::clear();
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("proj");
    fs::create_dir_all(&root).unwrap();
    if !init_repo(&root, Some("git@github.com:probed/local.git")) {
        eprintln!("skipping explicit_remote_beats_probed_remote: git unavailable");
        return;
    }

    let got = resolve_palace_with_remote(&root, Some("git@github.com:acme/widget.git"))
        .expect("resolves");
    assert_eq!(got.id, "acme-widget");
    assert_eq!(got.source, PalaceSource::GitOwnerRepo);
}

// ---------------------------------------------------------------------------
// Env guard
// ---------------------------------------------------------------------------

/// Restores `TRUSTY_MEMORY_PALACE` to its prior value on drop.
///
/// Why: the variable is process-global; a test that leaked it would silently
/// pin every later test in the binary to one palace.
struct EnvGuard(Option<String>);

impl EnvGuard {
    fn clear() -> Self {
        let prior = std::env::var(crate::palace_id::PALACE_OVERRIDE_ENV).ok();
        // SAFETY: `#[serial]` serialises every test in this module that touches
        // this variable, so no other thread reads it concurrently.
        unsafe { std::env::remove_var(crate::palace_id::PALACE_OVERRIDE_ENV) };
        Self(prior)
    }

    fn set(value: &str) -> Self {
        let prior = std::env::var(crate::palace_id::PALACE_OVERRIDE_ENV).ok();
        // SAFETY: as above.
        unsafe { std::env::set_var(crate::palace_id::PALACE_OVERRIDE_ENV, value) };
        Self(prior)
    }
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        // SAFETY: as above.
        unsafe {
            match self.0.take() {
                Some(v) => std::env::set_var(crate::palace_id::PALACE_OVERRIDE_ENV, v),
                None => std::env::remove_var(crate::palace_id::PALACE_OVERRIDE_ENV),
            }
        }
    }
}

// ---------------------------------------------------------------------------
// ProjectPin construction (#5811)
// ---------------------------------------------------------------------------

/// Why: [`ProjectPin`] is `#[non_exhaustive]`, so consumers cannot write the
/// struct literal and must go through [`ProjectPin::new`]. That constructor is
/// the only place the schema version is stamped, so a caller cannot pin an older
/// one by copying an old literal.
#[test]
fn new_stamps_the_current_schema_version() {
    let pin = ProjectPin::new("canonical-name");
    assert_eq!(pin.schema_version, PIN_SCHEMA_VERSION);
    assert_eq!(pin.palace, "canonical-name");
    assert_eq!(pin.note, None);
}

/// Why: `note` is `skip_serializing_if = "Option::is_none"`, so both the present
/// and absent shapes have to survive a YAML round trip through the reader every
/// consumer uses.
#[test]
fn with_note_round_trips_through_yaml() {
    let tmp = tempfile::tempdir().expect("tempdir");
    fs::create_dir_all(tmp.path().join(TRUSTY_TOOLS_DIR)).unwrap();
    let pin = ProjectPin::new("canonical-name").with_note(Some("pinned before reorg".to_string()));
    fs::write(
        tmp.path().join(PIN_FILE_REL),
        serde_yaml::to_string(&pin).unwrap(),
    )
    .unwrap();

    let read_back = read_project_pin(tmp.path()).expect("ok").expect("some");
    assert_eq!(read_back, pin);
}

// ---------------------------------------------------------------------------
// Non-project callers stay resolvable
// ---------------------------------------------------------------------------

/// Why: not every palace belongs to a software project — trusty-agents mints one
/// per ASSISTANT, with no remote, no owner/repo and no project root. Making the
/// pin fail CLOSED (#5811) must not have made a git identity a REQUIREMENT: a
/// caller with none of those still resolves, via level 4, and only the three
/// pin-TRUST failures produce an error. `NoIdentity` is the separate, narrow
/// case where even the `parent/dir` slug is empty.
///
/// Creating an arbitrary palace by NAME never reaches this resolver at all —
/// `palace_create { force: true }` bypasses trusty-memory's `validate_palace_name`
/// gate outright (`dispatch_palace_create_force_allowed_in_single_tenant_default`),
/// which is the path trusty-agents' `TrustyMemoryClient::ensure_palace` uses.
#[test]
#[serial_test::serial]
fn a_caller_with_no_git_identity_still_resolves() {
    let _env = EnvGuard::clear();
    // No `.git`, no remote, no pin, no project marker of any kind.
    let tmp = tempfile::tempdir().expect("tempdir");
    let plain = tmp.path().join("assistant-scratch");
    fs::create_dir_all(&plain).unwrap();

    let resolved = resolve_palace(&plain).expect("a directory with no git identity must resolve");

    assert_eq!(resolved.source, PalaceSource::ParentDir);
    assert!(
        !resolved.id.is_empty(),
        "level 4 must still produce a usable id"
    );
}