trusty-memory 0.18.1

MCP server (stdio + HTTP/SSE) for trusty-memory
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
//! Integration tests for `commands::prompt_context`.
//!
//! Why: Separated from `mod.rs` to keep the production module under the
//! 500-SLOC cap while retaining full test coverage.
//! What: exercises `build_injection_body`, filter helpers, format helpers,
//! and the full HTTP-daemon path (gated on `axum-server`).
//! Test: this file is the test suite.

use super::*;
// Why (issue #226): `serde_json::json!` is only used by the daemon-based
//      tests, which are themselves gated behind `axum-server`. Mirror the
//      gate here so `--no-default-features` builds stay warning-free.
#[cfg(feature = "axum-server")]
use serde_json::json;

/// Why (issue #134): the recall query needs the actual prompt text the
/// user typed; the stdin payload carries it under `"prompt"`.
/// What: parses three shapes — full JSON with `prompt`, JSON without,
/// and raw text — and asserts each returns the expected string.
/// Test: itself.
#[test]
fn parse_user_prompt_prefers_prompt_field() {
    let json_with_prompt = serde_json::json!({
        "prompt": "what is rust?",
        "cwd": "/tmp/example",
    })
    .to_string();
    assert_eq!(parse_user_prompt(&json_with_prompt), "what is rust?");

    let json_without_prompt = serde_json::json!({"cwd": "/tmp/example"}).to_string();
    assert_eq!(parse_user_prompt(&json_without_prompt), json_without_prompt);

    assert_eq!(parse_user_prompt("plain text query"), "plain text query");
    assert_eq!(parse_user_prompt(""), "");
}

/// Why (issue #139): the deny-tag filter is the load-bearing piece of
/// the recall-quality fix; unit-test the boundary conditions in
/// isolation so a refactor cannot silently regress them.
/// What: case-insensitive matching, empty deny list = passthrough,
/// drawers with no tags = kept (no excluded tag can match).
/// Test: itself.
#[test]
fn filter_drawers_by_deny_tags_handles_edge_cases() {
    use filter::{filter_drawers_by_deny_tags, RecalledDrawer};
    let make = |tags: &[&str]| RecalledDrawer {
        content: "irrelevant".into(),
        tags: tags.iter().map(|s| s.to_string()).collect(),
        layer: Some(2),
    };

    // Empty deny list → passthrough.
    let drawers = vec![make(&["claude-session"]), make(&["rust"])];
    let out = filter_drawers_by_deny_tags(drawers.clone(), &[]);
    assert_eq!(out.len(), 2, "empty deny list must pass everything");

    // Case-insensitive match (deny "claude-session" vs tag "Claude-Session").
    let drawers = vec![make(&["Claude-Session"]), make(&["rust"])];
    let out = filter_drawers_by_deny_tags(drawers, &["claude-session".to_string()]);
    assert_eq!(out.len(), 1);
    assert!(out[0].tags.iter().any(|t| t == "rust"));

    // Drawer with no tags is always kept.
    let drawers = vec![make(&[]), make(&["user-prompt"])];
    let out = filter_drawers_by_deny_tags(drawers, &["user-prompt".to_string()]);
    assert_eq!(out.len(), 1, "tagless drawers must survive the filter");
    assert!(out[0].tags.is_empty());

    // Multiple deny entries — any match excludes.
    let drawers = vec![
        make(&["claude-session"]),
        make(&["user-prompt"]),
        make(&["signal"]),
    ];
    let out = filter_drawers_by_deny_tags(
        drawers,
        &["claude-session".to_string(), "user-prompt".to_string()],
    );
    assert_eq!(out.len(), 1);
    assert_eq!(out[0].tags, vec!["signal".to_string()]);
}

/// Why (issue #134): KG triples should only surface when one of their
/// endpoints actually appears in the user's prompt; otherwise the
/// injection just dumps random graph noise.
/// What: build a small set of triples; query a prompt that mentions
/// only one subject; assert exactly the matching triple comes back.
/// Test: itself.
#[test]
fn select_relevant_triples_filters_by_prompt_overlap() {
    use filter::{select_relevant_triples, RawTriple};
    let triples = vec![
        RawTriple {
            subject: "tga".into(),
            predicate: "is_alias_for".into(),
            object: "trusty-git-analytics".into(),
        },
        RawTriple {
            subject: "python".into(),
            predicate: "is-a".into(),
            object: "language".into(),
        },
        RawTriple {
            subject: "rust".into(),
            predicate: "is-a".into(),
            object: "language".into(),
        },
    ];
    let chosen = select_relevant_triples(&triples, "tell me about rust integration", 5);
    assert_eq!(chosen.len(), 1, "only the rust triple should match");
    assert_eq!(chosen[0].subject, "rust");

    // Empty / no-overlap prompt → no triples.
    let none = select_relevant_triples(&triples, "weather forecast next week", 5);
    assert!(none.is_empty());
}

/// Why: the injection has a hard 4 KB byte ceiling so a runaway palace
/// can't drown the model's prompt; truncation must end with `…` and
/// stay valid UTF-8.
/// What: synthesises drawers whose previews exceed the cap, calls
/// `compose_injection`, asserts the result is `<= INJECTION_BYTE_CAP`
/// and ends with `…`.
/// Test: itself.
#[test]
fn compose_injection_truncates_at_cap() {
    use filter::{RawTriple, RecalledDrawer};
    use format::compose_injection;
    // Stuff a giant global-facts block to push the composition past the
    // 4 KB byte cap. Drawer previews are already capped at
    // DRAWER_PREVIEW_CHARS so the cap-trigger has to come from the
    // global section.
    let big_global = "## Big block\n".to_string() + &"- fact line\n".repeat(500);
    let drawers: Vec<RecalledDrawer> = (0..5)
        .map(|i| RecalledDrawer {
            content: format!("drawer {i} content"),
            tags: vec!["tag1".into()],
            layer: Some(2),
        })
        .collect();
    let triples: Vec<RawTriple> = (0..5)
        .map(|i| RawTriple {
            subject: format!("subject{i}"),
            predicate: "p".into(),
            object: "object".into(),
        })
        .collect();
    let out = compose_injection(Some(&big_global), &drawers, &triples, Some("alpha"));
    assert!(
        out.len() <= INJECTION_BYTE_CAP,
        "expected len <= cap; got {}",
        out.len()
    );
    // Truncation marker survives.
    assert!(
        out.ends_with(''),
        "expected `…` truncation marker; got tail: {}",
        &out[out.len().saturating_sub(20)..]
    );
}

/// Why: an empty composition (no global facts, no drawers, no triples)
/// must return an empty string so the caller can substitute the
/// legacy placeholder. Section headers should never appear without
/// content beneath them.
/// What: call `compose_injection` with empty inputs and assert the
/// result is empty.
/// Test: itself.
#[test]
fn compose_injection_empty_inputs_yields_empty() {
    use format::compose_injection;
    let out = compose_injection(None, &[], &[], Some("alpha"));
    assert!(out.is_empty(), "got: {out:?}");
}

/// Why (issue #125): when Claude Code invokes the UserPromptSubmit hook,
/// the stdin JSON carries a `cwd` field that reflects the user's actual
/// working directory at prompt time. The hook process cwd may be where
/// the hook was registered (typically a fixed install root), not where
/// the user actually is. The log palace must follow the stdin `cwd`.
/// What: build a stdin JSON payload pointing at a tempdir, derive the
/// expected slug for that tempdir via the *_at variant, and assert
/// `resolve_palace_for_log` returns the same slug — even though the
/// process cwd is unchanged and would resolve to a different slug.
/// Test: itself.
#[test]
fn resolve_palace_for_log_prefers_stdin_cwd() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let project = tmp.path().join("stdin-driven-project");
    std::fs::create_dir_all(&project).expect("create project dir");
    let payload = serde_json::json!({
        "hook_event_name": "UserPromptSubmit",
        "cwd": project.to_string_lossy(),
        "prompt": "hello"
    })
    .to_string();

    let expected =
        crate::messaging::cwd_palace_slug_at(&project).expect("derive slug from stdin cwd");
    let got = resolve_palace_for_log(&payload);
    assert_eq!(
        got, expected,
        "stdin `cwd` must override the process cwd for the log palace slug"
    );
    assert!(
        got.contains("stdin-driven-project"),
        "expected slug derived from stdin path, got {got:?}"
    );
}

/// Why (issue #125): when stdin is empty or non-JSON, the helper must
/// fall through to the process-cwd resolution path so manual `trusty-
/// memory prompt-context` invocations from a TTY still get a useful
/// palace identifier.
/// What: pass an empty string and a non-JSON string; assert the result
/// is *not* the legacy `"<unknown>"` sentinel (the process cwd here is a
/// real git repo, so cwd_palace_slug succeeds).
/// Test: itself.
#[test]
fn resolve_palace_for_log_falls_back_to_process_cwd() {
    let from_empty = resolve_palace_for_log("");
    let from_garbage = resolve_palace_for_log("not json at all");
    assert_eq!(from_empty, from_garbage);
    assert_ne!(from_empty, "<unknown>");
}

/// Why: the hook is wired into every Claude Code prompt the user types;
/// failing it would block the prompt. The contract is that a missing
/// daemon-address lockfile (the canonical "daemon not running" signal)
/// must produce `Ok(())` with no stdout, not an error.
/// What: redirects `trusty_common::resolve_data_dir` at a fresh tempdir
/// via `TRUSTY_DATA_DIR_OVERRIDE` so `read_daemon_addr("trusty-memory")`
/// observes a missing lockfile, then runs the handler and asserts it
/// returns `Ok(())`.
#[tokio::test]
async fn prompt_context_returns_ok_without_daemon() {
    let _guard = crate::commands::env_test_lock().lock().await;
    let tmp = tempfile::tempdir().expect("tempdir");
    // SAFETY: tests serialise on `TRUSTY_DATA_DIR_OVERRIDE` by convention
    // across the trusty-* workspace (see trusty-common's lib.rs notes).
    // This test only mutates the env var inside its own scope.
    unsafe {
        std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
    }
    let res = handle_prompt_context().await;
    unsafe {
        std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
    }
    assert!(
        res.is_ok(),
        "missing daemon lockfile must degrade to Ok(()), got {res:?}"
    );
}

/// Why (issue #134): the hook's whole value-prop is surfacing relevant
/// drawers from the palace; previously it only returned the workspace-
/// level hot facts. Confirm that with a live daemon, a populated palace,
/// and a prompt that mentions a known keyword, the rendered injection
/// contains real drawer content — not the legacy `EMPTY_PLACEHOLDER`.
/// What: spin up a real HTTP daemon under a tempdir-pinned data root,
/// create a palace whose slug matches a project tempdir basename,
/// populate it with three keyworded drawers via the MCP dispatch
/// (which loads the real embedder), then call `build_injection_body`
/// with a stdin payload carrying `cwd = <project tempdir>` and
/// `prompt = "how does rust integration work?"`. Assert the body
/// contains the rust drawer's content and the relevant-memories
/// section header.
/// Test: itself.
/// Note (issue #226): gated on `axum-server` because it spins up the
/// real HTTP daemon via `run_http_on`.
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_recalls_palace_drawers() {
    let _guard = crate::commands::env_test_lock().lock().await;
    let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
        spin_up_test_daemon_with_palace("prompt-ctx-recall-pop").await;

    // Populate the palace with three diverged drawers via MCP dispatch.
    // Using the MCP path here exercises the real embedder + KG hook.
    for (text, tags) in [
        (
            "Rust integration uses tokio for async tasks and serde for JSON",
            vec!["rust", "tokio"],
        ),
        (
            "Python bindings ship via PyO3 with custom ABI shims",
            vec!["python", "pyo3"],
        ),
        (
            "Knowledge graph stores triples in redb with valid_from intervals",
            vec!["kg", "redb"],
        ),
    ] {
        let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
        let _ = crate::tools::dispatch_tool(
            &state,
            "memory_remember",
            json!({
                "palace": slug,
                "text": text,
                "room": "General",
                "tags": tags_json,
            }),
        )
        .await
        .expect("memory_remember");
    }

    // Build the stdin payload Claude Code would send: a JSON object with
    // `cwd` (the project dir) and `prompt` (mentioning "rust").
    let payload = json!({
        "hook_event_name": "UserPromptSubmit",
        "cwd": project_dir.to_string_lossy(),
        "prompt": "how does rust integration work?"
    })
    .to_string();

    let start = std::time::Instant::now();
    let body = build_injection_body(&payload).await;
    let elapsed_ms = start.elapsed().as_millis();
    eprintln!("prompt_context_recalls_palace_drawers latency: {elapsed_ms}ms");

    assert_ne!(
        body, EMPTY_PLACEHOLDER,
        "populated palace must return real content, not the placeholder"
    );
    // The injection must mention the rust drawer's content (proves
    // recall actually targeted the resolved palace and surfaced
    // prompt-relevant memories).
    assert!(
        body.to_lowercase().contains("rust") && body.to_lowercase().contains("integration"),
        "expected rust integration drawer in injection; got:\n{body}"
    );
    // Section header should be present (proves the multi-section
    // composition is wired through).
    assert!(
        body.contains("Relevant memories") || body.contains("memories from palace"),
        "expected a `Relevant memories` section; got:\n{body}"
    );

    // Performance guardrail (issue #134 target: <200 ms p95). On
    // CI/dev machines this comfortably stays under the budget.
    assert!(
        elapsed_ms < 5_000,
        "prompt-context too slow ({elapsed_ms}ms) — investigate"
    );

    addr_handle.shutdown().await;
}

/// Why (issue #134, negative case): when the resolved palace has no
/// drawers AND no global hot facts have been asserted, the hook must
/// still emit a safe placeholder so downstream consumers see byte-
/// identical behaviour to the pre-fix daemon. Don't regress the empty
/// case while fixing the populated one.
/// What: spin up the same daemon shape but skip the drawer-population
/// step; assert the body equals [`EMPTY_PLACEHOLDER`].
/// Test: itself.
/// Note (issue #226): gated on `axum-server`; spawns the HTTP daemon.
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_empty_palace_falls_back_to_global() {
    let _guard = crate::commands::env_test_lock().lock().await;
    let (_state, _data_dir_tmp, _project_dir_tmp, project_dir, _slug, addr_handle) =
        spin_up_test_daemon_with_palace("prompt-ctx-recall-empty").await;

    let payload = json!({
        "hook_event_name": "UserPromptSubmit",
        "cwd": project_dir.to_string_lossy(),
        "prompt": "no drawers exist here"
    })
    .to_string();
    let body = build_injection_body(&payload).await;
    assert_eq!(
        body, EMPTY_PLACEHOLDER,
        "empty palace + empty prompt-facts must fall back to the placeholder"
    );

    addr_handle.shutdown().await;
}

/// Test fixture: spin up a real HTTP daemon under a tempdir-pinned
/// data root, create a palace with the given slug under a project
/// tempdir whose basename matches the slug, and return everything
/// the test needs to interact with the daemon.
///
/// Why: the prompt-context hook talks HTTP to a live daemon; unit
/// tests with the router alone can't exercise the `read_daemon_addr`
/// → HTTP round trip. This helper wires it all together in one place.
/// What: creates two tempdirs (one for the data root, one for the
/// project cwd whose basename equals `palace_slug`), pins
/// `TRUSTY_DATA_DIR_OVERRIDE`, builds the `AppState`, creates the
/// palace, spawns the HTTP server on `127.0.0.1:0`, and waits for
/// the daemon addr file to land. Returns `(state, data_dir_tmp,
/// project_dir_tmp, project_dir_path, palace_slug, addr_handle)`.
/// Test: indirectly via `prompt_context_recalls_palace_drawers` and
/// `prompt_context_empty_palace_falls_back_to_global`.
/// Note (issue #226): gated on `axum-server` because `run_http_on` is
/// only available when the HTTP-serving surface is compiled in.
#[cfg(feature = "axum-server")]
async fn spin_up_test_daemon_with_palace(
    palace_slug: &str,
) -> (
    crate::AppState,
    tempfile::TempDir,
    tempfile::TempDir,
    std::path::PathBuf,
    String,
    DaemonHandle,
) {
    let data_tmp = tempfile::tempdir().expect("data tempdir");
    let project_tmp = tempfile::tempdir().expect("project tempdir");
    // Build a project directory whose basename equals the palace slug.
    // This is what `cwd_palace_slug_at` will derive from the stdin `cwd`.
    let project_dir = project_tmp.path().join(palace_slug);
    std::fs::create_dir_all(&project_dir).expect("project dir");

    // SAFETY: env_test_lock serialises this section.
    unsafe {
        std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, data_tmp.path());
        std::env::remove_var(crate::prompt_log::ENV_ENABLED);
        std::env::remove_var(crate::prompt_log::ENV_DIR);
        std::env::remove_var(crate::prompt_log::ENV_HASH_PROMPTS);
        // Issue #88: bypass palace-slug enforcement so test palaces with
        // arbitrary names can be created without a matching project root.
        std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
    }

    // Issue #1217: the default palace ID is now derived from project identity
    // (git owner/repo, else parent/dir slug), so a bare tempdir whose basename
    // equals `palace_slug` no longer resolves to that slug (a non-git dir
    // derives `<parent>-<leaf>`). Write a committed pin file in `project_dir`
    // so the hook's `cwd_palace_slug_at` resolves deterministically to the
    // palace this fixture created. This is fully hermetic (per-tempdir, no env
    // or global state to leak into sibling tests) and exercises the #1217
    // pin-file-primacy anchor that keeps existing palaces from being orphaned.
    crate::project_root::write_project_pin(
        &project_dir,
        &crate::project_root::ProjectPin {
            schema_version: crate::project_root::PIN_SCHEMA_VERSION,
            palace: palace_slug.to_string(),
            note: None,
        },
    )
    .expect("write project pin for fixture");

    let data_root =
        trusty_common::resolve_data_dir("trusty-memory").expect("resolve data dir under override");
    let state = crate::AppState::new(data_root.clone());
    // Flip to Ready so the issue #911 warming preflight does not reject
    // the `memory_remember` calls that seed fixture data below.
    state.set_ready();

    // Create the palace via MCP dispatch so the on-disk metadata
    // matches what a real client would have produced. The `TRUSTY_MEMORY_PALACE`
    // override pinned above makes `cwd_palace_slug_at` (and thus the hook)
    // resolve to exactly this slug (issue #1217).
    let _ = crate::tools::dispatch_tool(&state, "palace_create", json!({"name": palace_slug}))
        .await
        .expect("palace_create");

    // Bind a random local port and start the HTTP server. `run_http_on`
    // writes the addr file as part of startup; we poll for it briefly
    // so the subsequent `read_daemon_addr` call succeeds.
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind 127.0.0.1:0");
    let addr = listener.local_addr().expect("local_addr");
    let state_for_server = state.clone();
    let handle = tokio::spawn(async move {
        let _ = crate::run_http_on(state_for_server, listener).await;
    });

    // Poll for the addr file (run_http_on writes it after binding).
    // Generous deadline so a contended CI machine doesn't flake — the
    // disk_size_ticker spawned inside `run_http_on` does some setup
    // work before the addr write lands. Bumped from 250 to 500
    // attempts (5 s → 10 s) under issue #139: the recall-quality fix
    // doubled the number of fixtures spinning a daemon (5 tests now
    // share this helper), so a heavily loaded host needs more headroom
    // before the first `http_addr` write lands.
    let addr_file = data_root.join("http_addr");
    let mut attempts = 0;
    while !addr_file.exists() && attempts < 500 {
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
        attempts += 1;
    }
    assert!(
        addr_file.exists(),
        "daemon never wrote http_addr at {} (attempts={attempts})",
        addr_file.display()
    );

    (
        state,
        data_tmp,
        project_tmp,
        project_dir,
        palace_slug.to_string(),
        DaemonHandle {
            addr,
            join: Some(handle),
        },
    )
}

/// Test-only handle to a spawned daemon — aborts the server task on
/// drop or explicit `shutdown` so the tempdir cleanup doesn't race
/// with in-flight requests.
/// Note (issue #226): gated on `axum-server` because the only callers
/// are HTTP-daemon-dependent tests.
#[cfg(feature = "axum-server")]
struct DaemonHandle {
    #[allow(dead_code)]
    addr: std::net::SocketAddr,
    join: Option<tokio::task::JoinHandle<()>>,
}

#[cfg(feature = "axum-server")]
impl DaemonHandle {
    async fn shutdown(mut self) {
        if let Some(h) = self.join.take() {
            h.abort();
            let _ = h.await;
        }
        // Release the pinned data dir override for sibling tests.
        // SAFETY: protected by env_test_lock in the caller.
        unsafe {
            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
        }
    }
}

/// Why (issue #139): live evidence from the user's `trusty-tools`
/// session showed the prompt-context hook injecting raw past user
/// prompts (drawers tagged `claude-session` / `user-prompt` from an
/// upstream auto-capture hook) on every UserPromptSubmit, dominating
/// real palace knowledge. Filtering by deny-listed tags is the
/// cheapest in-tree fix. Verify the default deny list drops both
/// auto-capture tags AND keeps a signal drawer untouched.
/// What: populate a palace with three drawers — one tagged
/// `claude-session`, one tagged `user-prompt`, one tagged with only
/// signal tags. The prompt mentions a keyword shared by all three so
/// recall returns all three. Assert the injection contains only the
/// signal drawer's content and neither of the deny-listed drawers'
/// content surfaces.
/// Test: itself.
/// Note (issue #226): gated on `axum-server`; spins up the HTTP daemon.
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_recall_filters_deny_tags() {
    let _guard = crate::commands::env_test_lock().lock().await;
    // Defensive: scrub the env override in case a sibling test set it
    // and panicked before its cleanup ran. Both vars are pinned to a
    // known state for this test.
    unsafe {
        std::env::remove_var(ENV_RECALL_DENY_TAGS);
    }
    let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
        spin_up_test_daemon_with_palace("prompt-ctx-deny-tags").await;

    // Three drawers, all mentioning "rust" so recall returns all three.
    // The first two carry the default deny tags and must be filtered;
    // their content is sized above the signal-filter threshold so the
    // remember path accepts them (the deny filter operates on tags,
    // not content length, so the body must be realistic).
    // The third has only signal tags and must survive.
    for (text, tags) in [
        (
            "user: how do I use rust async tokio runtime and serde derive macros in this project to glue an http handler to a kafka producer",
            vec!["claude-session", "user-prompt", "rust"],
        ),
        (
            "user: yes please go ahead and refactor the rust async producer module, this captured prompt fragment should never be surfaced",
            vec!["user-prompt", "rust"],
        ),
        (
            "Rust integration uses tokio for async tasks and serde for JSON",
            vec!["rust", "tokio"],
        ),
    ] {
        let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
        let _ = crate::tools::dispatch_tool(
            &state,
            "memory_remember",
            json!({
                "palace": slug,
                "text": text,
                "room": "General",
                "tags": tags_json,
            }),
        )
        .await
        .expect("memory_remember");
    }

    let payload = json!({
        "hook_event_name": "UserPromptSubmit",
        "cwd": project_dir.to_string_lossy(),
        "prompt": "how does rust integration work?"
    })
    .to_string();
    let body = build_injection_body(&payload).await;

    assert!(
        body.contains("tokio") && body.contains("serde"),
        "signal drawer must survive deny filter; got:\n{body}"
    );
    assert!(
        !body.contains("kafka producer"),
        "claude-session-tagged drawer must be filtered out; got:\n{body}"
    );
    assert!(
        !body.contains("captured prompt fragment"),
        "user-prompt-tagged drawer must be filtered out; got:\n{body}"
    );

    addr_handle.shutdown().await;
}

/// Why (issue #139): operators need to widen the deny list at runtime
/// without rebuilding the binary — e.g. when a palace accumulates a
/// project-specific synthetic tag. The env override
/// [`ENV_RECALL_DENY_TAGS`] supplies the comma-separated list.
/// What: set the env override to a custom tag, populate a palace
/// where the only recallable drawer carries that custom tag plus a
/// keyword shared with the prompt, assert the drawer is filtered out
/// and the body falls back to the global / empty placeholder path
/// (no `Relevant memories` section).
/// Test: itself.
/// Note (issue #226): gated on `axum-server`; spins up the HTTP daemon.
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_recall_env_override_extends_deny_list() {
    let _guard = crate::commands::env_test_lock().lock().await;
    // SAFETY: env_test_lock serialises this section.
    unsafe {
        std::env::set_var(ENV_RECALL_DENY_TAGS, "noise-tag");
    }
    let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
        spin_up_test_daemon_with_palace("prompt-ctx-env-deny").await;

    let _ = crate::tools::dispatch_tool(
        &state,
        "memory_remember",
        json!({
            "palace": slug,
            "text": "Rust integration uses tokio and serde for the async layer",
            "room": "General",
            "tags": ["noise-tag", "rust"],
        }),
    )
    .await
    .expect("memory_remember");

    let payload = json!({
        "hook_event_name": "UserPromptSubmit",
        "cwd": project_dir.to_string_lossy(),
        "prompt": "how does rust integration work?"
    })
    .to_string();
    let body = build_injection_body(&payload).await;

    // The single drawer is filtered, so the body should NOT carry its
    // content. The empty-palace fallback (or the global facts path)
    // takes over.
    assert!(
        !body.contains("tokio and serde"),
        "noise-tag drawer must be filtered when env override targets it; got:\n{body}"
    );

    // Clean up the env override so it does not leak to sibling tests.
    // SAFETY: env_test_lock still held until DaemonHandle::shutdown.
    unsafe {
        std::env::remove_var(ENV_RECALL_DENY_TAGS);
    }
    addr_handle.shutdown().await;
}

/// Why (issue #139, regression): a palace consisting entirely of
/// deny-listed drawers must NOT crash the hook and must NOT inject a
/// `Relevant memories` section. The empty-palace fallback path (the
/// existing global hot-facts route from #136) must kick in instead.
/// What: populate a palace where every drawer carries a deny tag,
/// run the hook, assert the body is either the legacy placeholder OR
/// global-facts-only — crucially, it must not contain any of the
/// drawer content nor a `Relevant memories` section header.
/// Test: itself.
/// Note (issue #226): gated on `axum-server`; spins up the HTTP daemon.
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_recall_all_filtered_falls_back_to_global() {
    let _guard = crate::commands::env_test_lock().lock().await;
    unsafe {
        std::env::remove_var(ENV_RECALL_DENY_TAGS);
    }
    let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
        spin_up_test_daemon_with_palace("prompt-ctx-all-filtered").await;

    // Every drawer is deny-listed. Bodies are sized above the signal
    // filter threshold so memory_remember accepts them — the deny-tag
    // filter operates downstream on tags, not content length.
    for (text, tags) in [
        (
            "user: status update on the rust async rewrite, the kafka consumer should not surface in any prompt-context injection",
            vec!["claude-session", "user-prompt", "rust"],
        ),
        (
            "user: yes please continue with the rust refactor on the producer side, this prompt fragment must be filtered out of recall",
            vec!["claude-session", "rust"],
        ),
    ] {
        let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
        let _ = crate::tools::dispatch_tool(
            &state,
            "memory_remember",
            json!({
                "palace": slug,
                "text": text,
                "room": "General",
                "tags": tags_json,
            }),
        )
        .await
        .expect("memory_remember");
    }

    let payload = json!({
        "hook_event_name": "UserPromptSubmit",
        "cwd": project_dir.to_string_lossy(),
        "prompt": "tell me about rust"
    })
    .to_string();
    let body = build_injection_body(&payload).await;

    // No drawer content leaks through and no `Relevant memories`
    // section is rendered — either the global hot-facts section or
    // the empty-placeholder fallback wins.
    assert!(
        !body.contains("kafka consumer") && !body.contains("producer side"),
        "filtered drawer content must not leak; got:\n{body}"
    );
    assert!(
        !body.contains("Relevant memories"),
        "no `Relevant memories` section should render when every drawer is filtered; got:\n{body}"
    );

    addr_handle.shutdown().await;
}

/// Why (issue #105): even when the daemon is down, the hook must still
/// log an attempt entry so operators can see "prompt-context fired N
/// times but the daemon was unreachable" in the JSONL stream.
/// What: pin a tempdir as the data directory, run the handler with no
/// daemon, and assert exactly one log file landed under `<tmp>/logs/`
/// with a single JSONL line whose `injection_kind` is the prompt-context
/// kind.
/// Test: itself.
#[tokio::test]
async fn prompt_context_logs_attempt_without_daemon() {
    let _guard = crate::commands::env_test_lock().lock().await;
    let tmp = tempfile::tempdir().expect("tempdir");
    unsafe {
        std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
        std::env::remove_var(crate::prompt_log::ENV_ENABLED);
        std::env::remove_var(crate::prompt_log::ENV_DIR);
        std::env::remove_var(crate::prompt_log::ENV_HASH_PROMPTS);
    }
    let res = handle_prompt_context().await;
    let logs_dir = trusty_common::resolve_data_dir("trusty-memory")
        .expect("resolve data dir")
        .join("logs");
    unsafe {
        std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
    }
    assert!(res.is_ok());
    let files: Vec<_> = std::fs::read_dir(&logs_dir)
        .expect("logs dir should be created")
        .flatten()
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.starts_with("enriched-prompts."))
        })
        .collect();
    assert_eq!(
        files.len(),
        1,
        "expected one enriched-prompts log file, got {files:?}"
    );
    let content = std::fs::read_to_string(&files[0]).expect("read log");
    let line = content.lines().next().expect("at least one line");
    let parsed: crate::prompt_log::PromptLogEntry =
        serde_json::from_str(line).expect("parse JSONL");
    assert_eq!(parsed.hook_type, "UserPromptSubmit");
    assert_eq!(parsed.injection_kind, "prompt-context-facts");
}