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'
433/// real cwd (which races with concurrent tests).
434/// What: same logic as [`cwd_palace_slug`] but rooted at `start`.
435/// Test: `tests::cwd_palace_slug_uses_git_toplevel`,
436/// `tests::cwd_palace_slug_falls_back_to_basename`.
437pub fn cwd_palace_slug_at(start: &Path) -> Result<String> {
438    // Best-effort git toplevel resolution: short timeout, no network.
439    let output = std::process::Command::new("git")
440        .arg("rev-parse")
441        .arg("--show-toplevel")
442        .current_dir(start)
443        .output();
444    if let Ok(output) = output {
445        if output.status.success() {
446            let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
447            if !toplevel.is_empty() {
448                let slug = slugify_for_palace(Path::new(&toplevel))?;
449                if !slug.is_empty() {
450                    return Ok(slug);
451                }
452            }
453        }
454    }
455    // Fallback: slugify cwd's basename.
456    let slug = slugify_for_palace(start)?;
457    if slug.is_empty() {
458        return Err(anyhow!(
459            "could not derive palace slug from cwd {} — pass --palace explicitly",
460            start.display()
461        ));
462    }
463    Ok(slug)
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::attribution::{CreatorInfo, CreatorSource};
470    use std::path::PathBuf;
471    use trusty_common::memory_core::{Palace, PalaceId, PalaceRegistry};
472
473    /// Test-only builder for a `CreatorInfo`. Tests don't care which writer
474    /// they simulate; pinning the values here avoids per-test boilerplate.
475    fn test_creator() -> CreatorInfo {
476        CreatorInfo {
477            client: "test-suite".to_string(),
478            version: "0.0.0".to_string(),
479            source: CreatorSource::Mcp,
480            cwd: Some("/tmp/test".to_string()),
481        }
482    }
483
484    /// Helper: build a registry + palace under a tempdir and return both.
485    fn fresh_palace(id: &str) -> (PalaceRegistry, Arc<PalaceHandle>, PathBuf) {
486        let tmp = tempfile::tempdir().expect("tempdir");
487        let root = tmp.path().to_path_buf();
488        std::mem::forget(tmp);
489        let registry = PalaceRegistry::new();
490        let palace = Palace {
491            id: PalaceId::new(id),
492            name: id.to_string(),
493            description: None,
494            created_at: Utc::now(),
495            data_dir: root.join(id),
496        };
497        registry
498            .create_palace(&root, palace)
499            .expect("create_palace");
500        let handle = registry
501            .open_palace(&root, &PalaceId::new(id))
502            .expect("open_palace");
503        (registry, handle, root)
504    }
505
506    #[test]
507    fn build_message_tags_includes_all_fields() {
508        let ts = Utc::now();
509        let tags = build_message_tags("alpha", "beta", "task", ts);
510        assert!(tags.contains(&MSG_MARKER_TAG.to_string()));
511        assert!(tags.iter().any(|t| t == "msg:from=alpha"));
512        assert!(tags.iter().any(|t| t == "msg:to=beta"));
513        assert!(tags.iter().any(|t| t == "msg:purpose=task"));
514        assert!(tags.iter().any(|t| t == "msg:read=false"));
515        assert!(tags
516            .iter()
517            .any(|t| t.starts_with("msg:sent_at=") && t.ends_with(&ts.to_rfc3339())));
518    }
519
520    #[test]
521    fn decode_message_from_drawer_round_trips() {
522        let ts = "2026-05-25T12:34:56+00:00"
523            .parse::<DateTime<chrono::FixedOffset>>()
524            .unwrap()
525            .with_timezone(&Utc);
526        let mut d = Drawer::new(Uuid::new_v4(), "hello world");
527        d.tags = build_message_tags("alpha", "beta", "task", ts);
528        let m = Message::from_drawer(&d).expect("decode");
529        assert_eq!(m.from_palace, "alpha");
530        assert_eq!(m.to_palace, "beta");
531        assert_eq!(m.purpose, "task");
532        assert_eq!(m.sent_at, ts);
533        assert!(!m.read);
534        assert_eq!(m.content, "hello world");
535    }
536
537    #[test]
538    fn decode_skips_non_message_drawer() {
539        let d = Drawer::new(Uuid::new_v4(), "not a message");
540        assert!(Message::from_drawer(&d).is_none());
541    }
542
543    #[test]
544    fn formatted_message_includes_from_purpose_and_body() {
545        let mut d = Drawer::new(Uuid::new_v4(), "the body");
546        let ts = Utc::now();
547        d.tags = build_message_tags("alpha", "beta", "request", ts);
548        let m = Message::from_drawer(&d).unwrap();
549        let formatted = m.to_injection_block();
550        assert!(formatted.contains("alpha"));
551        assert!(formatted.contains("beta"));
552        assert!(formatted.contains("request"));
553        assert!(formatted.contains("the body"));
554    }
555
556    #[test]
557    fn slug_derivation_cases() {
558        // Basic lowercase + hyphenation.
559        assert_eq!(slugify_string("trusty-tools"), "trusty-tools");
560        assert_eq!(slugify_string("Trusty_Tools"), "trusty-tools");
561        assert_eq!(slugify_string("trusty tools"), "trusty-tools");
562        assert_eq!(slugify_string("  trusty   tools  "), "trusty-tools");
563        // Git suffix stripped.
564        assert_eq!(slugify_string("trusty-tools.git"), "trusty-tools");
565        // Non-alphanumerics stripped.
566        assert_eq!(slugify_string("trusty/tools!"), "trustytools");
567        // Multiple consecutive hyphens collapse.
568        assert_eq!(slugify_string("foo--bar"), "foo-bar");
569        // Pure unicode -> empty (caller must guard).
570        assert_eq!(slugify_string("漢字"), "");
571
572        // Path-based variants pick the basename.
573        assert_eq!(
574            slugify_for_palace(Path::new("/home/u/projects/Trusty_Tools")).unwrap(),
575            "trusty-tools"
576        );
577    }
578
579    #[test]
580    fn cwd_palace_slug_uses_git_toplevel() {
581        // Best-effort: this test only works when run inside a git checkout.
582        // The trusty-tools repo *is* a git checkout, so the test is real.
583        let tmp = tempfile::tempdir().expect("tempdir");
584        // Init a fake repo so the test is hermetic.
585        let status = std::process::Command::new("git")
586            .args(["init", "-q"])
587            .current_dir(tmp.path())
588            .status();
589        if status.map(|s| s.success()).unwrap_or(false) {
590            // Create a sub-directory so we can confirm we resolve back to
591            // the toplevel and not to the sub-dir name.
592            let nested = tmp.path().join("nested-area");
593            std::fs::create_dir_all(&nested).unwrap();
594            let slug = cwd_palace_slug_at(&nested).expect("slug");
595            // Tempdir basename varies; the important assertion is that we
596            // didn't take the nested directory name.
597            assert_ne!(slug, "nested-area", "slug must come from git toplevel");
598        }
599    }
600
601    #[test]
602    fn cwd_palace_slug_falls_back_to_basename() {
603        let tmp = tempfile::tempdir().expect("tempdir");
604        let dir = tmp.path().join("my-project");
605        std::fs::create_dir_all(&dir).unwrap();
606        // Not a git repo — must fall back to the basename slug.
607        let slug = cwd_palace_slug_at(&dir).expect("slug");
608        assert_eq!(slug, "my-project");
609    }
610
611    #[tokio::test]
612    async fn round_trip_send_and_inbox() {
613        let (registry, handle_b, root) = fresh_palace("beta");
614        // Sender writes into "beta" with from="alpha".
615        let id = send_message_to_palace(
616            &registry,
617            &root,
618            "alpha",
619            "beta",
620            "task",
621            "hello".into(),
622            test_creator(),
623        )
624        .await
625        .expect("send");
626        // Inbox-check at beta returns the new message exactly once.
627        let unread = list_unread_messages(&handle_b);
628        assert_eq!(unread.len(), 1, "first inbox check returns the message");
629        assert_eq!(unread[0].id, id);
630        assert_eq!(unread[0].from_palace, "alpha");
631        assert_eq!(unread[0].to_palace, "beta");
632        assert_eq!(unread[0].purpose, "task");
633        assert_eq!(unread[0].content, "hello");
634        // Mark read.
635        let flipped = mark_message_read(&handle_b, id).await.expect("mark");
636        assert!(flipped);
637        // Second inbox check returns nothing.
638        let after = list_unread_messages(&handle_b);
639        assert!(after.is_empty(), "second inbox check is empty after mark");
640        // list_messages with unread_only=false still surfaces it.
641        let all = list_messages(&handle_b, false);
642        assert_eq!(all.len(), 1, "history view retains the read message");
643        assert!(all[0].read, "history view reports it as read");
644    }
645
646    #[tokio::test]
647    async fn inbox_returns_only_unread_after_mark() {
648        let (registry, handle, root) = fresh_palace("inbox-only");
649        // Send 3 messages.
650        let mut ids = Vec::new();
651        for i in 0..3 {
652            let id = send_message_to_palace(
653                &registry,
654                &root,
655                "alpha",
656                "inbox-only",
657                "task",
658                format!("body {i}"),
659                test_creator(),
660            )
661            .await
662            .expect("send");
663            ids.push(id);
664        }
665        // Mark the middle one read.
666        mark_message_read(&handle, ids[1]).await.expect("mark");
667        // unread_only=true: 2 messages.
668        let unread = list_messages(&handle, true);
669        assert_eq!(unread.len(), 2);
670        assert!(!unread.iter().any(|m| m.id == ids[1]));
671        // unread_only=false: all 3.
672        let all = list_messages(&handle, false);
673        assert_eq!(all.len(), 3);
674    }
675
676    #[tokio::test]
677    async fn mark_read_is_idempotent() {
678        let (registry, handle, root) = fresh_palace("idempotent");
679        let id = send_message_to_palace(
680            &registry,
681            &root,
682            "alpha",
683            "idempotent",
684            "task",
685            "msg".into(),
686            test_creator(),
687        )
688        .await
689        .expect("send");
690        assert!(mark_message_read(&handle, id).await.unwrap());
691        // Re-mark — must not error and must report "already read".
692        assert!(!mark_message_read(&handle, id).await.unwrap());
693    }
694
695    #[tokio::test]
696    async fn mark_read_is_atomic_under_concurrency() {
697        // Two concurrent inbox-check style flows on the same palace must
698        // not double-deliver: exactly one call flips the flag, the other
699        // sees `read=true` and returns `false`. The `parking_lot::RwLock`
700        // on `handle.drawers` serialises the compare-and-swap.
701        let (registry, handle, root) = fresh_palace("concurrent");
702        let id = send_message_to_palace(
703            &registry,
704            &root,
705            "alpha",
706            "concurrent",
707            "task",
708            "race".into(),
709            test_creator(),
710        )
711        .await
712        .expect("send");
713        // Two concurrent async tasks race on the same drawer. The
714        // parking_lot write lock inside `mark_message_read` serialises the
715        // compare-and-swap so exactly one observes `read=false`.
716        let h1 = handle.clone();
717        let h2 = handle.clone();
718        let (a, b) = tokio::join!(
719            async move { mark_message_read(&h1, id).await },
720            async move { mark_message_read(&h2, id).await }
721        );
722        let a = a.expect("mark a");
723        let b = b.expect("mark b");
724        // Exactly one of the two flips the flag.
725        let total_flips = a as u8 + b as u8;
726        assert_eq!(total_flips, 1, "exactly one mark must flip the flag");
727
728        // Exactly one message remains, and it is read.
729        let after = list_messages(&handle, false);
730        assert_eq!(after.len(), 1, "exactly one message survives the race");
731        assert!(after[0].read, "survivor is marked read");
732        // Unread inbox is empty.
733        let unread = list_unread_messages(&handle);
734        assert!(unread.is_empty());
735    }
736}