Skip to main content

trusty_memory/
messaging.rs

1//! Inter-project messaging primitive (issue #99).
2//!
3//! Why: Replaces the Python `/mpm-message` skill (claude-mpm repo, writes
4//! to `~/.claude-mpm/messaging.db`) with a trusty-memory-native primitive.
5//! Single-daemon-per-host architecture means cross-project messaging is
6//! just a write to a different palace and a read at session start — no
7//! IPC required.
8//!
9//! What: helpers that encode messages as **drawers tagged with a `msg:*`
10//! namespace** so we don't have to change the `Drawer` schema:
11//!
12//! - `msg:v1` — marker tag for fast filtering / dedup.
13//! - `msg:from=<palace>` — sender palace id.
14//! - `msg:to=<palace>` — recipient palace id (redundant with the host palace,
15//!   kept for audit + cross-palace queries).
16//! - `msg:purpose=<string>` — free-text purpose / category set by the sender.
17//! - `msg:sent_at=<rfc3339>` — UTC ISO 8601 timestamp when the sender wrote it.
18//! - `msg:read=<bool>` — receiver-controlled read flag (`true` after the
19//!   SessionStart hook has delivered it once).
20//!
21//! Addressing convention: receiver palace name = repo slug derived from cwd
22//! (basename of the git toplevel, or cwd when not in a git repo). The slug
23//! is **lowercased, with whitespace and underscores collapsed to single
24//! hyphens, and any character outside `[a-z0-9-]` stripped**. See
25//! [`slugify_for_palace`] for the exact rule.
26//!
27//! Test: `tests::round_trip_send_and_inbox`, `tests::slug_derivation_cases`,
28//! `tests::mark_read_is_atomic_under_concurrency`.
29
30use anyhow::{anyhow, Context, Result};
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use std::path::Path;
34use std::sync::Arc;
35use trusty_common::memory_core::palace::{Drawer, RoomType};
36use trusty_common::memory_core::retrieval::RememberOptions;
37use trusty_common::memory_core::PalaceHandle;
38use uuid::Uuid;
39
40/// Tag namespace prefix marking a drawer as a v1 inter-project message.
41///
42/// Why: A single static marker tag lets `inbox-check` filter drawers by tag
43/// without having to scan every `msg:*` namespaced tag — and gives the UI a
44/// cheap "is this a message?" check without parsing the other tags.
45/// What: The literal `"msg:v1"`. Bump the suffix if the message envelope
46/// schema ever needs a breaking change.
47/// Test: Indirectly via `round_trip_send_and_inbox`.
48pub const MSG_MARKER_TAG: &str = "msg:v1";
49
50/// Tag prefix carrying the sender's palace id (e.g. `msg:from=trusty-tools`).
51pub const TAG_FROM_PREFIX: &str = "msg:from=";
52
53/// Tag prefix carrying the recipient palace id (e.g. `msg:to=claude-mpm`).
54pub const TAG_TO_PREFIX: &str = "msg:to=";
55
56/// Tag prefix carrying the sender-defined purpose (e.g. `msg:purpose=task`).
57pub const TAG_PURPOSE_PREFIX: &str = "msg:purpose=";
58
59/// Tag prefix carrying the RFC3339 send timestamp (e.g.
60/// `msg:sent_at=2026-05-25T12:34:56+00:00`).
61pub const TAG_SENT_AT_PREFIX: &str = "msg:sent_at=";
62
63/// Tag prefix carrying the read flag (`msg:read=false` or `msg:read=true`).
64pub const TAG_READ_PREFIX: &str = "msg:read=";
65
66/// Decoded view of a message drawer.
67///
68/// Why: `inbox-check` and the HTTP `GET /api/v1/messages` endpoint both want
69/// a typed view of every message field, not the raw `Vec<String>` of tags.
70/// What: Owned strings plus the drawer id and content, populated by
71/// [`Message::from_drawer`].
72/// Test: `decode_message_from_drawer_round_trips`.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct Message {
75    pub id: Uuid,
76    pub from_palace: String,
77    pub to_palace: String,
78    pub purpose: String,
79    pub sent_at: DateTime<Utc>,
80    pub read: bool,
81    pub content: String,
82}
83
84impl Message {
85    /// Decode a drawer that carries the message tag namespace.
86    ///
87    /// Why: drawers are stored verbatim and the message envelope lives in
88    /// `tags`; centralising the parse keeps the inbox handler clean and
89    /// surfaces any malformed-tag failures with a uniform error.
90    /// What: returns `Some(Message)` when the drawer carries the
91    /// [`MSG_MARKER_TAG`] and every required field is present and parseable;
92    /// returns `None` (with a debug log) on any missing-field or parse error
93    /// so a single corrupt drawer can't poison the whole inbox. Unknown
94    /// `read` values default to `false` — better to re-deliver a message
95    /// than to silently swallow it.
96    /// Test: `decode_message_from_drawer_round_trips`,
97    /// `decode_skips_non_message_drawer`.
98    pub fn from_drawer(drawer: &Drawer) -> Option<Self> {
99        if !drawer.tags.iter().any(|t| t == MSG_MARKER_TAG) {
100            return None;
101        }
102        let from_palace = extract_tag(drawer, TAG_FROM_PREFIX)?.to_string();
103        let to_palace = extract_tag(drawer, TAG_TO_PREFIX)?.to_string();
104        let purpose = extract_tag(drawer, TAG_PURPOSE_PREFIX)?.to_string();
105        let sent_at_raw = extract_tag(drawer, TAG_SENT_AT_PREFIX)?;
106        let sent_at = DateTime::parse_from_rfc3339(sent_at_raw)
107            .ok()?
108            .with_timezone(&Utc);
109        let read = extract_tag(drawer, TAG_READ_PREFIX)
110            .map(|v| v.eq_ignore_ascii_case("true"))
111            .unwrap_or(false);
112        Some(Message {
113            id: drawer.id,
114            from_palace,
115            to_palace,
116            purpose,
117            sent_at,
118            read,
119            content: drawer.content.clone(),
120        })
121    }
122
123    /// Format the message as the Markdown block the SessionStart hook
124    /// injects via stdout.
125    ///
126    /// Why: Claude Code's SessionStart hook ingests stdout verbatim, so the
127    /// receiver needs a self-contained, model-readable block per message
128    /// (who, why, when, and the body) rather than raw JSON.
129    /// What: returns a multi-line `## Message from <from>` heading plus a
130    /// purpose/sent-at metadata line and the body. The caller concatenates
131    /// multiple messages with a blank line between them; the receiver agent
132    /// then reads them in order.
133    /// Test: `formatted_message_includes_from_purpose_and_body`.
134    pub fn to_injection_block(&self) -> String {
135        format!(
136            "## Message from {from} (purpose: {purpose})\n\
137             _sent {sent_at} → {to}_\n\
138             \n\
139             {content}\n",
140            from = self.from_palace,
141            purpose = self.purpose,
142            sent_at = self.sent_at.to_rfc3339(),
143            to = self.to_palace,
144            content = self.content
145        )
146    }
147}
148
149/// Extract the value of the first tag matching `prefix`.
150///
151/// Why: every `msg:*=...` field is encoded as a single tag entry; the
152/// receiver needs to recover the value half. Returning `Option<&str>`
153/// keeps the caller's error handling uniform (use `?` to bail on any
154/// missing required field).
155/// What: returns `Some(&str)` pointing at the substring after `prefix` of
156/// the first tag whose entire text starts with `prefix`, or `None` if no
157/// tag matches.
158/// Test: indirectly via `decode_message_from_drawer_round_trips`.
159fn extract_tag<'a>(drawer: &'a Drawer, prefix: &str) -> Option<&'a str> {
160    drawer.tags.iter().find_map(|t| t.strip_prefix(prefix))
161}
162
163/// Build the tag vector for a freshly-sent message.
164///
165/// Why: the send path (MCP tool, CLI, HTTP) all want the exact same tag
166/// shape — centralising it here means a future schema bump only touches
167/// one function.
168/// What: returns `[MSG_MARKER_TAG, msg:from=…, msg:to=…, msg:purpose=…,
169/// msg:sent_at=…, msg:read=false]` in that order.
170/// Test: `build_message_tags_includes_all_fields`.
171pub fn build_message_tags(
172    from_palace: &str,
173    to_palace: &str,
174    purpose: &str,
175    sent_at: DateTime<Utc>,
176) -> Vec<String> {
177    vec![
178        MSG_MARKER_TAG.to_string(),
179        format!("{TAG_FROM_PREFIX}{from_palace}"),
180        format!("{TAG_TO_PREFIX}{to_palace}"),
181        format!("{TAG_PURPOSE_PREFIX}{purpose}"),
182        format!("{TAG_SENT_AT_PREFIX}{ts}", ts = sent_at.to_rfc3339()),
183        format!("{TAG_READ_PREFIX}false"),
184    ]
185}
186
187/// Persist a message into the recipient palace.
188///
189/// Why: every send entry point (MCP, CLI, HTTP) needs the same write path:
190/// build tags + drawer, call `remember_with_options(force=true)` (we
191/// bypass the signal/noise filter because short notifications like "ping"
192/// are legitimately short messages), return the new drawer id. Centralising
193/// it keeps the three surfaces in lock-step.
194/// What: opens a handle to the recipient palace under `data_root`, writes
195/// the drawer with the message envelope tags plus the supplied creator
196/// attribution tags, and returns the new drawer id. The recipient palace
197/// must already exist — sending to a non-existent palace fails fast with
198/// a clear error rather than silently creating an empty inbox. `creator`
199/// is the writer's identity (HTTP / MCP / CLI / hook) — passed by every
200/// caller so noise drawers can be traced back to their origin.
201/// Test: `round_trip_send_and_inbox`.
202pub async fn send_message_to_palace(
203    registry: &trusty_common::memory_core::PalaceRegistry,
204    data_root: &Path,
205    from_palace: &str,
206    to_palace: &str,
207    purpose: &str,
208    content: String,
209    creator: crate::attribution::CreatorInfo,
210) -> Result<Uuid> {
211    let pid = trusty_common::memory_core::PalaceId::new(to_palace);
212    let handle = registry
213        .open_palace(data_root, &pid)
214        .with_context(|| format!("open recipient palace {to_palace}"))?;
215
216    let sent_at = Utc::now();
217    let mut tags = build_message_tags(from_palace, to_palace, purpose, sent_at);
218    creator.merge_into(&mut tags);
219
220    // force=true: bypass the signal/noise filter so short messages
221    // ("acknowledged", "ping") are not rejected. Messaging is an
222    // intentional human-controlled write, not auto-capture noise.
223    let opts = RememberOptions {
224        force: true,
225        ..RememberOptions::default()
226    };
227    let drawer_id = handle
228        .remember_with_options(
229            content,
230            RoomType::Custom("Messages".to_string()),
231            tags,
232            0.7,
233            opts,
234        )
235        .await
236        .context("write message drawer")?;
237    Ok(drawer_id)
238}
239
240/// List every unread message drawer in `palace`.
241///
242/// Why: the SessionStart hook needs to emit every unread message before
243/// marking them read. Filtering happens client-side (against
244/// `list_drawers`) because the message marker tag is namespaced — the
245/// existing tag filter accepts a single string and we filter on the
246/// composite `msg:v1` + `msg:read=false` predicate.
247/// What: pulls every drawer carrying [`MSG_MARKER_TAG`], decodes the
248/// envelope via [`Message::from_drawer`], and returns the ones with
249/// `read == false`. Sorted oldest-first by `sent_at` so multi-message
250/// inboxes deliver in a natural reading order.
251/// Test: `round_trip_send_and_inbox`.
252pub fn list_unread_messages(handle: &Arc<PalaceHandle>) -> Vec<Message> {
253    let drawers = handle.list_drawers(None, Some(MSG_MARKER_TAG.to_string()), usize::MAX);
254    let mut msgs: Vec<Message> = drawers
255        .iter()
256        .filter_map(Message::from_drawer)
257        .filter(|m| !m.read)
258        .collect();
259    msgs.sort_by_key(|m| m.sent_at);
260    msgs
261}
262
263/// List every message drawer in `palace`, optionally filtering to unread.
264///
265/// Why: the HTTP `GET /api/v1/messages` endpoint exposes both modes — full
266/// audit history and the unread-only view used by debuggers.
267/// What: same as `list_unread_messages` but with an opt-in `unread_only`
268/// filter; sorted by `sent_at` ascending in both cases.
269/// Test: `round_trip_send_and_inbox` and `inbox_returns_only_unread_after_mark`.
270pub fn list_messages(handle: &Arc<PalaceHandle>, unread_only: bool) -> Vec<Message> {
271    let drawers = handle.list_drawers(None, Some(MSG_MARKER_TAG.to_string()), usize::MAX);
272    let mut msgs: Vec<Message> = drawers
273        .iter()
274        .filter_map(Message::from_drawer)
275        .filter(|m| !unread_only || !m.read)
276        .collect();
277    msgs.sort_by_key(|m| m.sent_at);
278    msgs
279}
280
281/// Mark a message drawer as read by atomically rewriting its `msg:read=...`
282/// tag.
283///
284/// Why: the SessionStart hook MUST flip the read flag exactly once per
285/// message, even when two terminals race to start a session against the
286/// same palace. The naive "forget + remember" approach is not atomic
287/// (both racers can forget, then both can re-insert, producing two
288/// drawers). The single source of truth for "have we flipped this flag
289/// yet" is the in-memory drawer table — a `parking_lot::RwLock<Vec<Drawer>>`
290/// guarded by the palace handle. We take the write lock, do the
291/// compare-and-swap (return `false` if already read; otherwise rewrite
292/// the tag and clone the post-mutation drawer), then release the lock
293/// before crossing the `await` boundary for the persistent write.
294/// What: returns `Ok(false)` if the drawer is missing or already
295/// `msg:read=true`. Otherwise rewrites the tag in place under the write
296/// lock, clones the updated drawer, releases the lock, persists via
297/// `handle.kg.upsert_drawer`, and returns `Ok(true)`. The persistent
298/// write is async (it routes through the per-palace `KgWriter` actor for
299/// coalescing) so we cannot hold the parking_lot lock across it — but we
300/// don't need to: the in-memory CAS is the single source of truth for
301/// "have we flipped this flag", and the persistent write is just durable
302/// backing.
303/// Test: `mark_read_is_atomic_under_concurrency`,
304/// `mark_read_is_idempotent`.
305pub async fn mark_message_read(handle: &Arc<PalaceHandle>, drawer_id: Uuid) -> Result<bool> {
306    // In-memory compare-and-swap. The `Option<Drawer>` we return is the
307    // post-mutation snapshot we need to persist — `None` means "no work
308    // to do" (drawer missing or already read).
309    let snapshot: Option<Drawer> = {
310        let mut drawers = handle.drawers.write();
311        match drawers.iter_mut().find(|d| d.id == drawer_id) {
312            None => None,
313            Some(drawer) => {
314                if drawer
315                    .tags
316                    .iter()
317                    .any(|t| t.eq_ignore_ascii_case("msg:read=true"))
318                {
319                    None
320                } else {
321                    drawer.tags.retain(|t| !t.starts_with(TAG_READ_PREFIX));
322                    drawer.tags.push(format!("{TAG_READ_PREFIX}true"));
323                    Some(drawer.clone())
324                }
325            }
326        }
327    };
328    let Some(updated) = snapshot else {
329        return Ok(false);
330    };
331    // Persist the new tag set. Failures here leave the in-memory state
332    // ahead of disk — acceptable trade-off: the next call still observes
333    // `read=true` in memory (so no double-delivery) and a later restart
334    // will re-deliver the message at worst once. The alternative
335    // (rolling back the in-memory mutation) would let a racing reader
336    // observe the message as unread despite our intention to flip it.
337    handle
338        .kg
339        .upsert_drawer(&updated)
340        .await
341        .context("persist drawer tag update (mark-read)")?;
342    Ok(true)
343}
344
345/// Derive a palace slug from a filesystem path.
346///
347/// Why: addressing inter-project messages by repo slug means we need a
348/// deterministic, reversible-ish rule that maps a working-tree path to a
349/// stable palace name. Git users expect the slug to match their repo name;
350/// non-git working trees fall back to the directory basename. We aggressively
351/// canonicalise so casing, whitespace, and underscore vs. hyphen don't
352/// produce two different palaces for the same project.
353/// What: returns `basename(toplevel_or_cwd).lowercase()` with:
354///   - every run of whitespace or `_` collapsed to a single `-`,
355///   - every character outside `[a-z0-9-]` stripped,
356///   - leading / trailing `-` trimmed,
357///   - consecutive `-` collapsed to one.
358///
359/// Examples (all yield `trusty-tools`):
360///   - `/Users/bob/Projects/trusty-tools`
361///   - `/Users/bob/Projects/Trusty_Tools`
362///   - `/Users/bob/Projects/trusty tools/`
363///   - `/Users/bob/Projects/.trusty-tools.git` (git-suffix stripped)
364///
365/// Test: `tests::slug_derivation_cases`.
366pub fn slugify_for_palace(path: &Path) -> Result<String> {
367    let raw = path
368        .file_name()
369        .and_then(|s| s.to_str())
370        .ok_or_else(|| anyhow!("path has no final component: {}", path.display()))?;
371    Ok(slugify_string(raw))
372}
373
374/// String-level slug helper used by [`slugify_for_palace`].
375///
376/// Why: exposed separately so the CLI can slugify an arbitrary repo name
377/// (e.g. from `--to my_project`) without re-deriving from a path.
378/// What: applies the canonicalisation rules described on
379/// [`slugify_for_palace`].
380/// Test: `tests::slug_derivation_cases`.
381pub fn slugify_string(input: &str) -> String {
382    let lowered = input.trim().to_ascii_lowercase();
383    let stripped = lowered.strip_suffix(".git").unwrap_or(&lowered);
384    let mut out = String::with_capacity(stripped.len());
385    let mut prev_hyphen = false;
386    for c in stripped.chars() {
387        let next = match c {
388            'a'..='z' | '0'..='9' => Some(c),
389            '_' | '-' | ' ' | '\t' => Some('-'),
390            // Strip everything else.
391            _ => None,
392        };
393        if let Some(c) = next {
394            if c == '-' {
395                if !prev_hyphen && !out.is_empty() {
396                    out.push('-');
397                    prev_hyphen = true;
398                }
399            } else {
400                out.push(c);
401                prev_hyphen = false;
402            }
403        }
404    }
405    while out.ends_with('-') {
406        out.pop();
407    }
408    out
409}
410
411/// Resolve the calling project's palace slug from cwd, preferring the
412/// git toplevel when available.
413///
414/// Why: the SessionStart hook runs with whatever cwd Claude Code launches
415/// it under. Using the git toplevel makes `slug` stable regardless of
416/// which subdirectory the user opened — `cd /repo/crates/foo && trusty-memory
417/// inbox-check` and `cd /repo && trusty-memory inbox-check` resolve to the
418/// same slug.
419/// What: runs `git rev-parse --show-toplevel` from `cwd` (best-effort, no
420/// network); on success slugifies the basename of the returned path. On
421/// failure (not a repo, no git on PATH, command timeout) falls back to
422/// slugifying `cwd` itself.
423/// Test: `tests::cwd_palace_slug_uses_git_toplevel`,
424/// `tests::cwd_palace_slug_falls_back_to_basename`.
425pub fn cwd_palace_slug() -> Result<String> {
426    let cwd = std::env::current_dir().context("read cwd")?;
427    cwd_palace_slug_at(&cwd)
428}
429
430/// Variant of [`cwd_palace_slug`] that takes the working directory explicitly.
431///
432/// Why: lets unit tests drive the function without mutating the process' real
433/// cwd (which races with concurrent tests). Also used by the `prompt-context`
434/// hook, which must NOT trigger the lazy pin-file write (a read-only context
435/// may not have write permission and the hook's stdout must stay clean for the
436/// injection protocol). The pin-file read path therefore always uses the
437/// non-writing variant (`project_slug_at_readonly`).
438///
439/// Resolution order:
440///   1. If `.trusty-tools/trusty-memory.yaml` exists anywhere above `start`,
441///      return its `palace` field — the canonical, rename-stable slug.
442///   2. Otherwise, resolve the git toplevel via `git rev-parse --show-toplevel`
443///      and slugify its basename (pre-pin-file behaviour, preserved for repos
444///      that have not yet committed a pin file).
445///   3. If git is unavailable or not in a repo, slugify `start`'s basename.
446///
447/// Failures at steps 2 and 3 are best-effort (no network, short timeout);
448/// a corrupt pin file at step 1 is logged to stderr and falls through to
449/// step 2 — it never emits to stdout and never panics.
450/// What: returns `Ok(slug)` or an error when no slug can be derived (empty
451/// cwd basename in a non-git context).
452/// Test: `tests::cwd_palace_slug_uses_git_toplevel`,
453/// `tests::cwd_palace_slug_falls_back_to_basename`,
454/// `tests::cwd_palace_slug_at_prefers_pin_file`,
455/// `tests::cwd_palace_slug_at_reads_pin_from_subdir`,
456/// `tests::cwd_palace_slug_at_pin_read_does_not_create_pin_file`.
457pub fn cwd_palace_slug_at(start: &Path) -> Result<String> {
458    // Step 1 (PRIMARY): check for a committed pin file.
459    // Use the non-writing variant so the hook path stays side-effect-free.
460    // A missing or unreadable pin file falls through to the git / basename path.
461    if let Some(slug) = crate::project_root::project_slug_at_readonly(start) {
462        if !slug.is_empty() {
463            return Ok(slug);
464        }
465    }
466
467    // Step 2: best-effort git toplevel resolution — short timeout, no network.
468    let output = std::process::Command::new("git")
469        .arg("rev-parse")
470        .arg("--show-toplevel")
471        .current_dir(start)
472        .output();
473    if let Ok(output) = output {
474        if output.status.success() {
475            let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
476            if !toplevel.is_empty() {
477                let slug = slugify_for_palace(Path::new(&toplevel))?;
478                if !slug.is_empty() {
479                    return Ok(slug);
480                }
481            }
482        }
483    }
484    // Step 3: slugify cwd's basename.
485    let slug = slugify_for_palace(start)?;
486    if slug.is_empty() {
487        return Err(anyhow!(
488            "could not derive palace slug from cwd {} — pass --palace explicitly",
489            start.display()
490        ));
491    }
492    Ok(slug)
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::attribution::{CreatorInfo, CreatorSource};
499    use std::path::PathBuf;
500    use trusty_common::memory_core::{Palace, PalaceId, PalaceRegistry};
501
502    /// Test-only builder for a `CreatorInfo`. Tests don't care which writer
503    /// they simulate; pinning the values here avoids per-test boilerplate.
504    fn test_creator() -> CreatorInfo {
505        CreatorInfo {
506            client: "test-suite".to_string(),
507            version: "0.0.0".to_string(),
508            source: CreatorSource::Mcp,
509            cwd: Some("/tmp/test".to_string()),
510        }
511    }
512
513    /// Helper: build a registry + palace under a tempdir and return both.
514    fn fresh_palace(id: &str) -> (PalaceRegistry, Arc<PalaceHandle>, PathBuf) {
515        let tmp = tempfile::tempdir().expect("tempdir");
516        let root = tmp.path().to_path_buf();
517        std::mem::forget(tmp);
518        let registry = PalaceRegistry::new();
519        let palace = Palace {
520            id: PalaceId::new(id),
521            name: id.to_string(),
522            description: None,
523            created_at: Utc::now(),
524            data_dir: root.join(id),
525        };
526        registry
527            .create_palace(&root, palace)
528            .expect("create_palace");
529        let handle = registry
530            .open_palace(&root, &PalaceId::new(id))
531            .expect("open_palace");
532        (registry, handle, root)
533    }
534
535    #[test]
536    fn build_message_tags_includes_all_fields() {
537        let ts = Utc::now();
538        let tags = build_message_tags("alpha", "beta", "task", ts);
539        assert!(tags.contains(&MSG_MARKER_TAG.to_string()));
540        assert!(tags.iter().any(|t| t == "msg:from=alpha"));
541        assert!(tags.iter().any(|t| t == "msg:to=beta"));
542        assert!(tags.iter().any(|t| t == "msg:purpose=task"));
543        assert!(tags.iter().any(|t| t == "msg:read=false"));
544        assert!(tags
545            .iter()
546            .any(|t| t.starts_with("msg:sent_at=") && t.ends_with(&ts.to_rfc3339())));
547    }
548
549    #[test]
550    fn decode_message_from_drawer_round_trips() {
551        let ts = "2026-05-25T12:34:56+00:00"
552            .parse::<DateTime<chrono::FixedOffset>>()
553            .unwrap()
554            .with_timezone(&Utc);
555        let mut d = Drawer::new(Uuid::new_v4(), "hello world");
556        d.tags = build_message_tags("alpha", "beta", "task", ts);
557        let m = Message::from_drawer(&d).expect("decode");
558        assert_eq!(m.from_palace, "alpha");
559        assert_eq!(m.to_palace, "beta");
560        assert_eq!(m.purpose, "task");
561        assert_eq!(m.sent_at, ts);
562        assert!(!m.read);
563        assert_eq!(m.content, "hello world");
564    }
565
566    #[test]
567    fn decode_skips_non_message_drawer() {
568        let d = Drawer::new(Uuid::new_v4(), "not a message");
569        assert!(Message::from_drawer(&d).is_none());
570    }
571
572    #[test]
573    fn formatted_message_includes_from_purpose_and_body() {
574        let mut d = Drawer::new(Uuid::new_v4(), "the body");
575        let ts = Utc::now();
576        d.tags = build_message_tags("alpha", "beta", "request", ts);
577        let m = Message::from_drawer(&d).unwrap();
578        let formatted = m.to_injection_block();
579        assert!(formatted.contains("alpha"));
580        assert!(formatted.contains("beta"));
581        assert!(formatted.contains("request"));
582        assert!(formatted.contains("the body"));
583    }
584
585    #[test]
586    fn slug_derivation_cases() {
587        // Basic lowercase + hyphenation.
588        assert_eq!(slugify_string("trusty-tools"), "trusty-tools");
589        assert_eq!(slugify_string("Trusty_Tools"), "trusty-tools");
590        assert_eq!(slugify_string("trusty tools"), "trusty-tools");
591        assert_eq!(slugify_string("  trusty   tools  "), "trusty-tools");
592        // Git suffix stripped.
593        assert_eq!(slugify_string("trusty-tools.git"), "trusty-tools");
594        // Non-alphanumerics stripped.
595        assert_eq!(slugify_string("trusty/tools!"), "trustytools");
596        // Multiple consecutive hyphens collapse.
597        assert_eq!(slugify_string("foo--bar"), "foo-bar");
598        // Pure unicode -> empty (caller must guard).
599        assert_eq!(slugify_string("漢字"), "");
600
601        // Path-based variants pick the basename.
602        assert_eq!(
603            slugify_for_palace(Path::new("/home/u/projects/Trusty_Tools")).unwrap(),
604            "trusty-tools"
605        );
606    }
607
608    #[test]
609    fn cwd_palace_slug_uses_git_toplevel() {
610        // Best-effort: this test only works when run inside a git checkout.
611        // The trusty-tools repo *is* a git checkout, so the test is real.
612        let tmp = tempfile::tempdir().expect("tempdir");
613        // Init a fake repo so the test is hermetic.
614        let status = std::process::Command::new("git")
615            .args(["init", "-q"])
616            .current_dir(tmp.path())
617            .status();
618        if status.map(|s| s.success()).unwrap_or(false) {
619            // Create a sub-directory so we can confirm we resolve back to
620            // the toplevel and not to the sub-dir name.
621            let nested = tmp.path().join("nested-area");
622            std::fs::create_dir_all(&nested).unwrap();
623            let slug = cwd_palace_slug_at(&nested).expect("slug");
624            // Tempdir basename varies; the important assertion is that we
625            // didn't take the nested directory name.
626            assert_ne!(slug, "nested-area", "slug must come from git toplevel");
627        }
628    }
629
630    #[test]
631    fn cwd_palace_slug_falls_back_to_basename() {
632        let tmp = tempfile::tempdir().expect("tempdir");
633        let dir = tmp.path().join("my-project");
634        std::fs::create_dir_all(&dir).unwrap();
635        // Not a git repo — must fall back to the basename slug.
636        let slug = cwd_palace_slug_at(&dir).expect("slug");
637        assert_eq!(slug, "my-project");
638    }
639
640    /// Why: Change 1 — when a `.trusty-tools/trusty-memory.yaml` pin file is
641    /// present, `cwd_palace_slug_at` must return the pinned slug even when the
642    /// directory basename differs. This is the core rename-safety guarantee.
643    /// What: create a root dir named `actual-dir`, write a pin with
644    /// `palace: pinned-name`, call `cwd_palace_slug_at`, assert result is
645    /// `pinned-name` not `actual-dir`.
646    /// Test: itself.
647    #[test]
648    fn cwd_palace_slug_at_prefers_pin_file() {
649        use crate::project_root::{write_project_pin, ProjectPin, PIN_SCHEMA_VERSION};
650        let tmp = tempfile::tempdir().expect("tempdir");
651        let root = tmp.path().join("actual-dir");
652        std::fs::create_dir_all(root.join(".git")).unwrap();
653        let pin = ProjectPin {
654            schema_version: PIN_SCHEMA_VERSION,
655            palace: "pinned-name".to_string(),
656            note: None,
657        };
658        write_project_pin(&root, &pin).expect("write pin");
659
660        let slug = cwd_palace_slug_at(&root).expect("slug");
661        assert_eq!(
662            slug, "pinned-name",
663            "pin file must override the directory basename in messaging slug resolution"
664        );
665    }
666
667    /// Why: `cwd_palace_slug_at` must walk upward to find the pin file, so
668    /// calling it from a subdirectory resolves to the same pinned slug as
669    /// calling it from the root — consistent with how `prompt-context` runs.
670    /// What: pin file at root, call from a nested subdirectory, assert pinned
671    /// slug is returned.
672    /// Test: itself.
673    #[test]
674    fn cwd_palace_slug_at_reads_pin_from_subdir() {
675        use crate::project_root::{write_project_pin, ProjectPin, PIN_SCHEMA_VERSION};
676        let tmp = tempfile::tempdir().expect("tempdir");
677        let root = tmp.path().join("my-repo");
678        std::fs::create_dir_all(root.join(".git")).unwrap();
679        let pin = ProjectPin {
680            schema_version: PIN_SCHEMA_VERSION,
681            palace: "my-repo".to_string(),
682            note: None,
683        };
684        write_project_pin(&root, &pin).expect("write pin");
685
686        let sub = root.join("crates").join("foo");
687        std::fs::create_dir_all(&sub).unwrap();
688        let slug = cwd_palace_slug_at(&sub).expect("slug from subdir");
689        assert_eq!(slug, "my-repo");
690    }
691
692    /// Why: the hook path must not create a pin file as a side-effect of
693    /// resolving the slug — `cwd_palace_slug_at` delegates to the readonly
694    /// variant so no file write occurs even when no pin exists.
695    /// What: call `cwd_palace_slug_at` from a git repo with no pin file;
696    /// assert the pin file is absent after the call.
697    /// Test: itself.
698    #[test]
699    fn cwd_palace_slug_at_pin_read_does_not_create_pin_file() {
700        use crate::project_root::{read_project_pin, PIN_FILE_REL};
701        let tmp = tempfile::tempdir().expect("tempdir");
702        let root = tmp.path().join("no-pin-project");
703        std::fs::create_dir_all(root.join(".git")).unwrap();
704
705        let pin_path = root.join(PIN_FILE_REL);
706        assert!(!pin_path.exists(), "no pin before call");
707
708        let _slug = cwd_palace_slug_at(&root).expect("slug");
709
710        assert!(
711            !pin_path.exists(),
712            "cwd_palace_slug_at must NOT create a pin file (uses readonly variant)"
713        );
714        // But a pin read on the root itself must still return None.
715        assert!(read_project_pin(&root).unwrap().is_none());
716    }
717
718    #[tokio::test]
719    async fn round_trip_send_and_inbox() {
720        let (registry, handle_b, root) = fresh_palace("beta");
721        // Sender writes into "beta" with from="alpha".
722        let id = send_message_to_palace(
723            &registry,
724            &root,
725            "alpha",
726            "beta",
727            "task",
728            "hello".into(),
729            test_creator(),
730        )
731        .await
732        .expect("send");
733        // Inbox-check at beta returns the new message exactly once.
734        let unread = list_unread_messages(&handle_b);
735        assert_eq!(unread.len(), 1, "first inbox check returns the message");
736        assert_eq!(unread[0].id, id);
737        assert_eq!(unread[0].from_palace, "alpha");
738        assert_eq!(unread[0].to_palace, "beta");
739        assert_eq!(unread[0].purpose, "task");
740        assert_eq!(unread[0].content, "hello");
741        // Mark read.
742        let flipped = mark_message_read(&handle_b, id).await.expect("mark");
743        assert!(flipped);
744        // Second inbox check returns nothing.
745        let after = list_unread_messages(&handle_b);
746        assert!(after.is_empty(), "second inbox check is empty after mark");
747        // list_messages with unread_only=false still surfaces it.
748        let all = list_messages(&handle_b, false);
749        assert_eq!(all.len(), 1, "history view retains the read message");
750        assert!(all[0].read, "history view reports it as read");
751    }
752
753    #[tokio::test]
754    async fn inbox_returns_only_unread_after_mark() {
755        let (registry, handle, root) = fresh_palace("inbox-only");
756        // Send 3 messages.
757        let mut ids = Vec::new();
758        for i in 0..3 {
759            let id = send_message_to_palace(
760                &registry,
761                &root,
762                "alpha",
763                "inbox-only",
764                "task",
765                format!("body {i}"),
766                test_creator(),
767            )
768            .await
769            .expect("send");
770            ids.push(id);
771        }
772        // Mark the middle one read.
773        mark_message_read(&handle, ids[1]).await.expect("mark");
774        // unread_only=true: 2 messages.
775        let unread = list_messages(&handle, true);
776        assert_eq!(unread.len(), 2);
777        assert!(!unread.iter().any(|m| m.id == ids[1]));
778        // unread_only=false: all 3.
779        let all = list_messages(&handle, false);
780        assert_eq!(all.len(), 3);
781    }
782
783    #[tokio::test]
784    async fn mark_read_is_idempotent() {
785        let (registry, handle, root) = fresh_palace("idempotent");
786        let id = send_message_to_palace(
787            &registry,
788            &root,
789            "alpha",
790            "idempotent",
791            "task",
792            "msg".into(),
793            test_creator(),
794        )
795        .await
796        .expect("send");
797        assert!(mark_message_read(&handle, id).await.unwrap());
798        // Re-mark — must not error and must report "already read".
799        assert!(!mark_message_read(&handle, id).await.unwrap());
800    }
801
802    #[tokio::test]
803    async fn mark_read_is_atomic_under_concurrency() {
804        // Two concurrent inbox-check style flows on the same palace must
805        // not double-deliver: exactly one call flips the flag, the other
806        // sees `read=true` and returns `false`. The `parking_lot::RwLock`
807        // on `handle.drawers` serialises the compare-and-swap.
808        let (registry, handle, root) = fresh_palace("concurrent");
809        let id = send_message_to_palace(
810            &registry,
811            &root,
812            "alpha",
813            "concurrent",
814            "task",
815            "race".into(),
816            test_creator(),
817        )
818        .await
819        .expect("send");
820        // Two concurrent async tasks race on the same drawer. The
821        // parking_lot write lock inside `mark_message_read` serialises the
822        // compare-and-swap so exactly one observes `read=false`.
823        let h1 = handle.clone();
824        let h2 = handle.clone();
825        let (a, b) = tokio::join!(
826            async move { mark_message_read(&h1, id).await },
827            async move { mark_message_read(&h2, id).await }
828        );
829        let a = a.expect("mark a");
830        let b = b.expect("mark b");
831        // Exactly one of the two flips the flag.
832        let total_flips = a as u8 + b as u8;
833        assert_eq!(total_flips, 1, "exactly one mark must flip the flag");
834
835        // Exactly one message remains, and it is read.
836        let after = list_messages(&handle, false);
837        assert_eq!(after.len(), 1, "exactly one message survives the race");
838        assert!(after[0].read, "survivor is marked read");
839        // Unread inbox is empty.
840        let unread = list_unread_messages(&handle);
841        assert!(unread.is_empty());
842    }
843}