Skip to main content

trusty_memory/messaging/
mod.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//! Sub-modules:
22//!   - `types`: `Message`, tag-prefix constants, `build_message_tags`, slug
23//!     helpers.
24//!   - `operations`: `send_message_to_palace`, `list_unread_messages`,
25//!     `list_messages`, `mark_message_read`, `cwd_palace_slug`.
26//!
27//! Test: `tests::round_trip_send_and_inbox`, `tests::slug_derivation_cases`,
28//! `tests::mark_read_is_atomic_under_concurrency`.
29
30mod operations;
31mod types;
32
33pub use operations::{
34    cwd_palace_slug, cwd_palace_slug_at, list_messages, list_unread_messages, mark_message_read,
35    send_message_to_palace,
36};
37pub use types::{
38    build_message_tags, slugify_for_palace, slugify_string, Message, MSG_MARKER_TAG,
39    TAG_FROM_PREFIX, TAG_PURPOSE_PREFIX, TAG_READ_PREFIX, TAG_SENT_AT_PREFIX, TAG_TO_PREFIX,
40};
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45    use crate::attribution::{CreatorInfo, CreatorSource};
46    use chrono::Utc;
47    use std::path::Path;
48    use std::path::PathBuf;
49    use std::sync::Arc;
50    use trusty_common::memory_core::{Palace, PalaceHandle, PalaceId, PalaceRegistry};
51
52    /// RAII guard that sets or clears an environment variable for the duration
53    /// of a test and restores the prior value on drop.
54    ///
55    /// Why: the issue-#1217 derivation reads `TRUSTY_MEMORY_PALACE`; tests must
56    /// pin it deterministically without leaking state into sibling tests. Pair
57    /// every use with `#[serial_test::serial]` so no other thread reads the env
58    /// concurrently (cargo runs test fns across OS threads in one process).
59    /// What: `set` installs a value, `clear` removes it; both capture the prior
60    /// value and restore it in `Drop`.
61    /// Test: exercised by `cwd_palace_slug_at_env_override_wins` and the
62    /// derivation tests that must run with the override cleared.
63    struct EnvGuard {
64        key: &'static str,
65        prev: Option<String>,
66    }
67
68    impl EnvGuard {
69        fn set(key: &'static str, value: &str) -> Self {
70            let prev = std::env::var(key).ok();
71            // SAFETY: paired with `#[serial]` on the calling test so no other
72            // thread reads or writes the env concurrently.
73            unsafe { std::env::set_var(key, value) };
74            Self { key, prev }
75        }
76
77        fn clear(key: &'static str) -> Self {
78            let prev = std::env::var(key).ok();
79            // SAFETY: see `set`.
80            unsafe { std::env::remove_var(key) };
81            Self { key, prev }
82        }
83    }
84
85    impl Drop for EnvGuard {
86        fn drop(&mut self) {
87            // SAFETY: see `set`.
88            unsafe {
89                match &self.prev {
90                    Some(v) => std::env::set_var(self.key, v),
91                    None => std::env::remove_var(self.key),
92                }
93            }
94        }
95    }
96
97    /// Test-only builder for a `CreatorInfo`. Tests don't care which writer
98    /// they simulate; pinning the values here avoids per-test boilerplate.
99    fn test_creator() -> CreatorInfo {
100        CreatorInfo {
101            client: "test-suite".to_string(),
102            version: "0.0.0".to_string(),
103            source: CreatorSource::Mcp,
104            cwd: Some("/tmp/test".to_string()),
105        }
106    }
107
108    /// Helper: build a registry + palace under a tempdir and return both.
109    fn fresh_palace(id: &str) -> (PalaceRegistry, Arc<PalaceHandle>, PathBuf) {
110        let tmp = tempfile::tempdir().expect("tempdir");
111        let root = tmp.path().to_path_buf();
112        std::mem::forget(tmp);
113        let registry = PalaceRegistry::new();
114        let palace = Palace {
115            id: PalaceId::new(id),
116            name: id.to_string(),
117            description: None,
118            created_at: Utc::now(),
119            data_dir: root.join(id),
120        };
121        registry
122            .create_palace(&root, palace)
123            .expect("create_palace");
124        let handle = registry
125            .open_palace(&root, &PalaceId::new(id))
126            .expect("open_palace");
127        (registry, handle, root)
128    }
129
130    #[test]
131    fn build_message_tags_includes_all_fields() {
132        let ts = Utc::now();
133        let tags = build_message_tags("alpha", "beta", "task", ts);
134        assert!(tags.contains(&MSG_MARKER_TAG.to_string()));
135        assert!(tags.iter().any(|t| t == "msg:from=alpha"));
136        assert!(tags.iter().any(|t| t == "msg:to=beta"));
137        assert!(tags.iter().any(|t| t == "msg:purpose=task"));
138        assert!(tags.iter().any(|t| t == "msg:read=false"));
139        assert!(tags
140            .iter()
141            .any(|t| t.starts_with("msg:sent_at=") && t.ends_with(&ts.to_rfc3339())));
142    }
143
144    #[test]
145    fn decode_message_from_drawer_round_trips() {
146        use chrono::DateTime;
147        use trusty_common::memory_core::palace::Drawer;
148        use uuid::Uuid;
149        let ts = "2026-05-25T12:34:56+00:00"
150            .parse::<DateTime<chrono::FixedOffset>>()
151            .unwrap()
152            .with_timezone(&Utc);
153        let mut d = Drawer::new(Uuid::new_v4(), "hello world");
154        d.tags = build_message_tags("alpha", "beta", "task", ts);
155        let m = Message::from_drawer(&d).expect("decode");
156        assert_eq!(m.from_palace, "alpha");
157        assert_eq!(m.to_palace, "beta");
158        assert_eq!(m.purpose, "task");
159        assert_eq!(m.sent_at, ts);
160        assert!(!m.read);
161        assert_eq!(m.content, "hello world");
162    }
163
164    #[test]
165    fn decode_skips_non_message_drawer() {
166        use trusty_common::memory_core::palace::Drawer;
167        use uuid::Uuid;
168        let d = Drawer::new(Uuid::new_v4(), "not a message");
169        assert!(Message::from_drawer(&d).is_none());
170    }
171
172    #[test]
173    fn formatted_message_includes_from_purpose_and_body() {
174        use trusty_common::memory_core::palace::Drawer;
175        use uuid::Uuid;
176        let mut d = Drawer::new(Uuid::new_v4(), "the body");
177        let ts = Utc::now();
178        d.tags = build_message_tags("alpha", "beta", "request", ts);
179        let m = Message::from_drawer(&d).unwrap();
180        let formatted = m.to_injection_block();
181        assert!(formatted.contains("alpha"));
182        assert!(formatted.contains("beta"));
183        assert!(formatted.contains("request"));
184        assert!(formatted.contains("the body"));
185    }
186
187    #[test]
188    fn slug_derivation_cases() {
189        // Basic lowercase + hyphenation.
190        assert_eq!(slugify_string("trusty-tools"), "trusty-tools");
191        assert_eq!(slugify_string("Trusty_Tools"), "trusty-tools");
192        assert_eq!(slugify_string("trusty tools"), "trusty-tools");
193        assert_eq!(slugify_string("  trusty   tools  "), "trusty-tools");
194        // Git suffix stripped.
195        assert_eq!(slugify_string("trusty-tools.git"), "trusty-tools");
196        // Non-alphanumerics stripped.
197        assert_eq!(slugify_string("trusty/tools!"), "trustytools");
198        // Multiple consecutive hyphens collapse.
199        assert_eq!(slugify_string("foo--bar"), "foo-bar");
200        // Pure unicode -> empty (caller must guard).
201        assert_eq!(slugify_string("漢字"), "");
202
203        // Path-based variants pick the basename.
204        assert_eq!(
205            slugify_for_palace(Path::new("/home/u/projects/Trusty_Tools")).unwrap(),
206            "trusty-tools"
207        );
208    }
209
210    #[serial_test::serial]
211    #[test]
212    fn cwd_palace_slug_uses_git_toplevel() {
213        // Issue #1217: a repo *without* an origin remote must derive from the
214        // git toplevel via the parent/dir slug — and must NOT take the nested
215        // sub-directory name. (With an origin remote it would use owner/repo;
216        // that path is covered by `cwd_palace_slug_at_uses_git_owner_repo`.)
217        let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
218        let tmp = tempfile::tempdir().expect("tempdir");
219        // Init a fake repo (no remote) so the test is hermetic.
220        let status = std::process::Command::new("git")
221            .args(["init", "-q"])
222            .current_dir(tmp.path())
223            .status();
224        if status.map(|s| s.success()).unwrap_or(false) {
225            // Create a sub-directory so we can confirm we resolve back to
226            // the toplevel and not to the sub-dir name.
227            let nested = tmp.path().join("nested-area");
228            std::fs::create_dir_all(&nested).unwrap();
229            let slug = cwd_palace_slug_at(&nested).expect("slug");
230            // The toplevel parent/dir slug must end with the toplevel basename,
231            // never the nested directory name.
232            assert_ne!(slug, "nested-area", "slug must come from git toplevel");
233            assert!(
234                !slug.contains("nested-area"),
235                "slug must not include the nested sub-dir; got {slug}"
236            );
237        }
238    }
239
240    #[serial_test::serial]
241    #[test]
242    fn cwd_palace_slug_falls_back_to_parent_dir() {
243        // Issue #1217: a non-git directory derives `parent-leaf`, not just the
244        // bare leaf basename. The tempdir's own basename is random, so assert
245        // the slug ends with `-my-project` and is not the bare leaf.
246        let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
247        let tmp = tempfile::tempdir().expect("tempdir");
248        let dir = tmp.path().join("my-project");
249        std::fs::create_dir_all(&dir).unwrap();
250        // Not a git repo — must fall back to the parent/dir slug.
251        let slug = cwd_palace_slug_at(&dir).expect("slug");
252        assert!(
253            slug.ends_with("-my-project"),
254            "non-git dir must derive `<parent>-my-project`; got {slug}"
255        );
256    }
257
258    /// Why: the `TRUSTY_MEMORY_PALACE` env override must beat every derivation
259    /// source (issue #1217 precedence level 1).
260    /// What: set the env var, call `cwd_palace_slug_at` from a plain dir, assert
261    /// the slugified override is returned regardless of the directory name.
262    /// Test: itself (serialised against other env-mutating tests via the var).
263    #[serial_test::serial]
264    #[test]
265    fn cwd_palace_slug_at_env_override_wins() {
266        let _guard = EnvGuard::set(crate::palace_id_derive::PALACE_OVERRIDE_ENV, "My Override");
267        let tmp = tempfile::tempdir().expect("tempdir");
268        let dir = tmp.path().join("some-dir");
269        std::fs::create_dir_all(&dir).unwrap();
270        let slug = cwd_palace_slug_at(&dir).expect("slug");
271        assert_eq!(
272            slug, "my-override",
273            "env override must win and be slugified"
274        );
275    }
276
277    /// Why: a git repo *with* an origin remote must derive the default palace
278    /// from the GitHub-style `owner/repo` path (issue #1217 precedence level 2).
279    /// What: init a repo, add an SSH origin remote, call from a subdirectory,
280    /// assert the slug is `acme-widget` (owner-repo, separator collapsed).
281    /// Test: itself.
282    #[serial_test::serial]
283    #[test]
284    fn cwd_palace_slug_at_uses_git_owner_repo() {
285        let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
286        // Skip when `git` is unavailable on PATH.
287        if std::process::Command::new("git")
288            .arg("--version")
289            .output()
290            .ok()
291            .map(|o| !o.status.success())
292            .unwrap_or(true)
293        {
294            eprintln!("skipping cwd_palace_slug_at_uses_git_owner_repo: git not on PATH");
295            return;
296        }
297        let tmp = tempfile::tempdir().expect("tempdir");
298        let root = tmp.path();
299        let run = |args: &[&str]| {
300            let ok = std::process::Command::new("git")
301                .args(args)
302                .current_dir(root)
303                .status()
304                .map(|s| s.success())
305                .unwrap_or(false);
306            assert!(ok, "git {args:?} failed");
307        };
308        run(&["init", "-q"]);
309        run(&["remote", "add", "origin", "git@github.com:acme/widget.git"]);
310        let nested = root.join("crates").join("foo");
311        std::fs::create_dir_all(&nested).unwrap();
312        let slug = cwd_palace_slug_at(&nested).expect("slug");
313        assert_eq!(
314            slug, "acme-widget",
315            "git owner/repo must drive the default palace id; got {slug}"
316        );
317    }
318
319    /// Why: Change 1 — when a `.trusty-tools/trusty-memory.yaml` pin file is
320    /// present, `cwd_palace_slug_at` must return the pinned slug even when the
321    /// directory basename differs. This is the core rename-safety guarantee.
322    /// What: create a root dir named `actual-dir`, write a pin with
323    /// `palace: pinned-name`, call `cwd_palace_slug_at`, assert result is
324    /// `pinned-name` not `actual-dir`.
325    /// Test: itself; serialised against all other env-mutating tests via
326    /// `#[serial_test::serial]` + `EnvGuard::clear` so that a concurrently
327    /// racing `cwd_palace_slug_at_env_override_wins` cannot leak
328    /// `TRUSTY_MEMORY_PALACE` into this test (env check is step 1, pin file
329    /// is step 2 — a leaked env var would shadow the pin and return
330    /// "my-override" instead of "pinned-name").
331    #[serial_test::serial]
332    #[test]
333    fn cwd_palace_slug_at_prefers_pin_file() {
334        use crate::project_root::{write_project_pin, ProjectPin, PIN_SCHEMA_VERSION};
335        let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
336        let tmp = tempfile::tempdir().expect("tempdir");
337        let root = tmp.path().join("actual-dir");
338        std::fs::create_dir_all(root.join(".git")).unwrap();
339        let pin = ProjectPin {
340            schema_version: PIN_SCHEMA_VERSION,
341            palace: "pinned-name".to_string(),
342            note: None,
343        };
344        write_project_pin(&root, &pin).expect("write pin");
345
346        let slug = cwd_palace_slug_at(&root).expect("slug");
347        assert_eq!(
348            slug, "pinned-name",
349            "pin file must override the directory basename in messaging slug resolution"
350        );
351    }
352
353    /// Why: `cwd_palace_slug_at` must walk upward to find the pin file, so
354    /// calling it from a subdirectory resolves to the same pinned slug as
355    /// calling it from the root — consistent with how `prompt-context` runs.
356    /// What: pin file at root, call from a nested subdirectory, assert pinned
357    /// slug is returned.
358    /// Test: itself; serialised against env-mutating sibling tests so that
359    /// `TRUSTY_MEMORY_PALACE` (step-1 precedence) cannot leak and shadow the
360    /// pin-file result (step-2 precedence).
361    #[serial_test::serial]
362    #[test]
363    fn cwd_palace_slug_at_reads_pin_from_subdir() {
364        use crate::project_root::{write_project_pin, ProjectPin, PIN_SCHEMA_VERSION};
365        let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
366        let tmp = tempfile::tempdir().expect("tempdir");
367        let root = tmp.path().join("my-repo");
368        std::fs::create_dir_all(root.join(".git")).unwrap();
369        let pin = ProjectPin {
370            schema_version: PIN_SCHEMA_VERSION,
371            palace: "my-repo".to_string(),
372            note: None,
373        };
374        write_project_pin(&root, &pin).expect("write pin");
375
376        let sub = root.join("crates").join("foo");
377        std::fs::create_dir_all(&sub).unwrap();
378        let slug = cwd_palace_slug_at(&sub).expect("slug from subdir");
379        assert_eq!(slug, "my-repo");
380    }
381
382    /// Why: the hook path must not create a pin file as a side-effect of
383    /// resolving the slug — `cwd_palace_slug_at` delegates to the readonly
384    /// variant so no file write occurs even when no pin exists.
385    /// What: call `cwd_palace_slug_at` from a git repo with no pin file;
386    /// assert the pin file is absent after the call.
387    /// Test: itself; serialised so that a leaked `TRUSTY_MEMORY_PALACE` env
388    /// var cannot short-circuit step 1 and return early before the pin-file
389    /// absence check at step 2.
390    #[serial_test::serial]
391    #[test]
392    fn cwd_palace_slug_at_pin_read_does_not_create_pin_file() {
393        use crate::project_root::{read_project_pin, PIN_FILE_REL};
394        let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
395        let tmp = tempfile::tempdir().expect("tempdir");
396        let root = tmp.path().join("no-pin-project");
397        std::fs::create_dir_all(root.join(".git")).unwrap();
398
399        let pin_path = root.join(PIN_FILE_REL);
400        assert!(!pin_path.exists(), "no pin before call");
401
402        let _slug = cwd_palace_slug_at(&root).expect("slug");
403
404        assert!(
405            !pin_path.exists(),
406            "cwd_palace_slug_at must NOT create a pin file (uses readonly variant)"
407        );
408        // But a pin read on the root itself must still return None.
409        assert!(read_project_pin(&root).unwrap().is_none());
410    }
411
412    #[tokio::test]
413    async fn round_trip_send_and_inbox() {
414        let (registry, handle_b, root) = fresh_palace("beta");
415        // Sender writes into "beta" with from="alpha".
416        let id = send_message_to_palace(
417            &registry,
418            &root,
419            "alpha",
420            "beta",
421            "task",
422            "hello".into(),
423            test_creator(),
424        )
425        .await
426        .expect("send");
427        // Inbox-check at beta returns the new message exactly once.
428        let unread = list_unread_messages(&handle_b);
429        assert_eq!(unread.len(), 1, "first inbox check returns the message");
430        assert_eq!(unread[0].id, id);
431        assert_eq!(unread[0].from_palace, "alpha");
432        assert_eq!(unread[0].to_palace, "beta");
433        assert_eq!(unread[0].purpose, "task");
434        assert_eq!(unread[0].content, "hello");
435        // Mark read.
436        let flipped = mark_message_read(&handle_b, id).await.expect("mark");
437        assert!(flipped);
438        // Second inbox check returns nothing.
439        let after = list_unread_messages(&handle_b);
440        assert!(after.is_empty(), "second inbox check is empty after mark");
441        // list_messages with unread_only=false still surfaces it.
442        let all = list_messages(&handle_b, false);
443        assert_eq!(all.len(), 1, "history view retains the read message");
444        assert!(all[0].read, "history view reports it as read");
445    }
446
447    #[tokio::test]
448    async fn inbox_returns_only_unread_after_mark() {
449        let (registry, handle, root) = fresh_palace("inbox-only");
450        // Send 3 messages.
451        let mut ids = Vec::new();
452        for i in 0..3 {
453            let id = send_message_to_palace(
454                &registry,
455                &root,
456                "alpha",
457                "inbox-only",
458                "task",
459                format!("body {i}"),
460                test_creator(),
461            )
462            .await
463            .expect("send");
464            ids.push(id);
465        }
466        // Mark the middle one read.
467        mark_message_read(&handle, ids[1]).await.expect("mark");
468        // unread_only=true: 2 messages.
469        let unread = list_messages(&handle, true);
470        assert_eq!(unread.len(), 2);
471        assert!(!unread.iter().any(|m| m.id == ids[1]));
472        // unread_only=false: all 3.
473        let all = list_messages(&handle, false);
474        assert_eq!(all.len(), 3);
475    }
476
477    #[tokio::test]
478    async fn mark_read_is_idempotent() {
479        let (registry, handle, root) = fresh_palace("idempotent");
480        let id = send_message_to_palace(
481            &registry,
482            &root,
483            "alpha",
484            "idempotent",
485            "task",
486            "msg".into(),
487            test_creator(),
488        )
489        .await
490        .expect("send");
491        assert!(mark_message_read(&handle, id).await.unwrap());
492        // Re-mark — must not error and must report "already read".
493        assert!(!mark_message_read(&handle, id).await.unwrap());
494    }
495
496    #[tokio::test]
497    async fn mark_read_is_atomic_under_concurrency() {
498        // Two concurrent inbox-check style flows on the same palace must
499        // not double-deliver: exactly one call flips the flag, the other
500        // sees `read=true` and returns `false`. The `parking_lot::RwLock`
501        // on `handle.drawers` serialises the compare-and-swap.
502        let (registry, handle, root) = fresh_palace("concurrent");
503        let id = send_message_to_palace(
504            &registry,
505            &root,
506            "alpha",
507            "concurrent",
508            "task",
509            "race".into(),
510            test_creator(),
511        )
512        .await
513        .expect("send");
514        // Two concurrent async tasks race on the same drawer. The
515        // parking_lot write lock inside `mark_message_read` serialises the
516        // compare-and-swap so exactly one observes `read=false`.
517        let h1 = handle.clone();
518        let h2 = handle.clone();
519        let (a, b) = tokio::join!(
520            async move { mark_message_read(&h1, id).await },
521            async move { mark_message_read(&h2, id).await }
522        );
523        let a = a.expect("mark a");
524        let b = b.expect("mark b");
525        // Exactly one of the two flips the flag.
526        let total_flips = a as u8 + b as u8;
527        assert_eq!(total_flips, 1, "exactly one mark must flip the flag");
528
529        // Exactly one message remains, and it is read.
530        let after = list_messages(&handle, false);
531        assert_eq!(after.len(), 1, "exactly one message survives the race");
532        assert!(after[0].read, "survivor is marked read");
533        // Unread inbox is empty.
534        let unread = list_unread_messages(&handle);
535        assert!(unread.is_empty());
536    }
537}