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 ®istry,
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 ®istry,
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 ®istry,
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 ®istry,
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}