mod operations;
mod types;
pub use operations::{
cwd_palace_slug, cwd_palace_slug_at, list_messages, list_unread_messages, mark_message_read,
send_message_to_palace,
};
pub use types::{
build_message_tags, slugify_for_palace, slugify_string, Message, MSG_MARKER_TAG,
TAG_FROM_PREFIX, TAG_PURPOSE_PREFIX, TAG_READ_PREFIX, TAG_SENT_AT_PREFIX, TAG_TO_PREFIX,
};
#[cfg(test)]
mod tests {
use super::*;
use crate::attribution::{CreatorInfo, CreatorSource};
use chrono::Utc;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use trusty_common::memory_core::{Palace, PalaceHandle, PalaceId, PalaceRegistry};
struct EnvGuard {
key: &'static str,
prev: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let prev = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
Self { key, prev }
}
fn clear(key: &'static str) -> Self {
let prev = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self { key, prev }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.prev {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
fn test_creator() -> CreatorInfo {
CreatorInfo {
client: "test-suite".to_string(),
version: "0.0.0".to_string(),
source: CreatorSource::Mcp,
cwd: Some("/tmp/test".to_string()),
}
}
fn fresh_palace(id: &str) -> (PalaceRegistry, Arc<PalaceHandle>, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
let registry = PalaceRegistry::new();
let palace = Palace {
id: PalaceId::new(id),
name: id.to_string(),
description: None,
created_at: Utc::now(),
data_dir: root.join(id),
};
registry
.create_palace(&root, palace)
.expect("create_palace");
let handle = registry
.open_palace(&root, &PalaceId::new(id))
.expect("open_palace");
(registry, handle, root)
}
#[test]
fn build_message_tags_includes_all_fields() {
let ts = Utc::now();
let tags = build_message_tags("alpha", "beta", "task", ts);
assert!(tags.contains(&MSG_MARKER_TAG.to_string()));
assert!(tags.iter().any(|t| t == "msg:from=alpha"));
assert!(tags.iter().any(|t| t == "msg:to=beta"));
assert!(tags.iter().any(|t| t == "msg:purpose=task"));
assert!(tags.iter().any(|t| t == "msg:read=false"));
assert!(tags
.iter()
.any(|t| t.starts_with("msg:sent_at=") && t.ends_with(&ts.to_rfc3339())));
}
#[test]
fn decode_message_from_drawer_round_trips() {
use chrono::DateTime;
use trusty_common::memory_core::palace::Drawer;
use uuid::Uuid;
let ts = "2026-05-25T12:34:56+00:00"
.parse::<DateTime<chrono::FixedOffset>>()
.unwrap()
.with_timezone(&Utc);
let mut d = Drawer::new(Uuid::new_v4(), "hello world");
d.tags = build_message_tags("alpha", "beta", "task", ts);
let m = Message::from_drawer(&d).expect("decode");
assert_eq!(m.from_palace, "alpha");
assert_eq!(m.to_palace, "beta");
assert_eq!(m.purpose, "task");
assert_eq!(m.sent_at, ts);
assert!(!m.read);
assert_eq!(m.content, "hello world");
}
#[test]
fn decode_skips_non_message_drawer() {
use trusty_common::memory_core::palace::Drawer;
use uuid::Uuid;
let d = Drawer::new(Uuid::new_v4(), "not a message");
assert!(Message::from_drawer(&d).is_none());
}
#[test]
fn formatted_message_includes_from_purpose_and_body() {
use trusty_common::memory_core::palace::Drawer;
use uuid::Uuid;
let mut d = Drawer::new(Uuid::new_v4(), "the body");
let ts = Utc::now();
d.tags = build_message_tags("alpha", "beta", "request", ts);
let m = Message::from_drawer(&d).unwrap();
let formatted = m.to_injection_block();
assert!(formatted.contains("alpha"));
assert!(formatted.contains("beta"));
assert!(formatted.contains("request"));
assert!(formatted.contains("the body"));
}
#[test]
fn slug_derivation_cases() {
assert_eq!(slugify_string("trusty-tools"), "trusty-tools");
assert_eq!(slugify_string("Trusty_Tools"), "trusty-tools");
assert_eq!(slugify_string("trusty tools"), "trusty-tools");
assert_eq!(slugify_string(" trusty tools "), "trusty-tools");
assert_eq!(slugify_string("trusty-tools.git"), "trusty-tools");
assert_eq!(slugify_string("trusty/tools!"), "trustytools");
assert_eq!(slugify_string("foo--bar"), "foo-bar");
assert_eq!(slugify_string("漢字"), "");
assert_eq!(
slugify_for_palace(Path::new("/home/u/projects/Trusty_Tools")).unwrap(),
"trusty-tools"
);
}
#[serial_test::serial]
#[test]
fn cwd_palace_slug_uses_git_toplevel() {
let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
let tmp = tempfile::tempdir().expect("tempdir");
let status = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.status();
if status.map(|s| s.success()).unwrap_or(false) {
let nested = tmp.path().join("nested-area");
std::fs::create_dir_all(&nested).unwrap();
let slug = cwd_palace_slug_at(&nested).expect("slug");
assert_ne!(slug, "nested-area", "slug must come from git toplevel");
assert!(
!slug.contains("nested-area"),
"slug must not include the nested sub-dir; got {slug}"
);
}
}
#[serial_test::serial]
#[test]
fn cwd_palace_slug_falls_back_to_parent_dir() {
let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("my-project");
std::fs::create_dir_all(&dir).unwrap();
let slug = cwd_palace_slug_at(&dir).expect("slug");
assert!(
slug.ends_with("-my-project"),
"non-git dir must derive `<parent>-my-project`; got {slug}"
);
}
#[serial_test::serial]
#[test]
fn cwd_palace_slug_at_env_override_wins() {
let _guard = EnvGuard::set(crate::palace_id_derive::PALACE_OVERRIDE_ENV, "My Override");
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("some-dir");
std::fs::create_dir_all(&dir).unwrap();
let slug = cwd_palace_slug_at(&dir).expect("slug");
assert_eq!(
slug, "my-override",
"env override must win and be slugified"
);
}
#[serial_test::serial]
#[test]
fn cwd_palace_slug_at_uses_git_owner_repo() {
let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
if std::process::Command::new("git")
.arg("--version")
.output()
.ok()
.map(|o| !o.status.success())
.unwrap_or(true)
{
eprintln!("skipping cwd_palace_slug_at_uses_git_owner_repo: git not on PATH");
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let run = |args: &[&str]| {
let ok = std::process::Command::new("git")
.args(args)
.current_dir(root)
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "git {args:?} failed");
};
run(&["init", "-q"]);
run(&["remote", "add", "origin", "git@github.com:acme/widget.git"]);
let nested = root.join("crates").join("foo");
std::fs::create_dir_all(&nested).unwrap();
let slug = cwd_palace_slug_at(&nested).expect("slug");
assert_eq!(
slug, "acme-widget",
"git owner/repo must drive the default palace id; got {slug}"
);
}
#[serial_test::serial]
#[test]
fn cwd_palace_slug_at_prefers_pin_file() {
use crate::project_root::{write_project_pin, ProjectPin, PIN_SCHEMA_VERSION};
let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("actual-dir");
std::fs::create_dir_all(root.join(".git")).unwrap();
let pin = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: "pinned-name".to_string(),
note: None,
};
write_project_pin(&root, &pin).expect("write pin");
let slug = cwd_palace_slug_at(&root).expect("slug");
assert_eq!(
slug, "pinned-name",
"pin file must override the directory basename in messaging slug resolution"
);
}
#[serial_test::serial]
#[test]
fn cwd_palace_slug_at_reads_pin_from_subdir() {
use crate::project_root::{write_project_pin, ProjectPin, PIN_SCHEMA_VERSION};
let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("my-repo");
std::fs::create_dir_all(root.join(".git")).unwrap();
let pin = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: "my-repo".to_string(),
note: None,
};
write_project_pin(&root, &pin).expect("write pin");
let sub = root.join("crates").join("foo");
std::fs::create_dir_all(&sub).unwrap();
let slug = cwd_palace_slug_at(&sub).expect("slug from subdir");
assert_eq!(slug, "my-repo");
}
#[serial_test::serial]
#[test]
fn cwd_palace_slug_at_pin_read_does_not_create_pin_file() {
use crate::project_root::{read_project_pin, PIN_FILE_REL};
let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("no-pin-project");
std::fs::create_dir_all(root.join(".git")).unwrap();
let pin_path = root.join(PIN_FILE_REL);
assert!(!pin_path.exists(), "no pin before call");
let _slug = cwd_palace_slug_at(&root).expect("slug");
assert!(
!pin_path.exists(),
"cwd_palace_slug_at must NOT create a pin file (uses readonly variant)"
);
assert!(read_project_pin(&root).unwrap().is_none());
}
#[tokio::test]
async fn round_trip_send_and_inbox() {
let (registry, handle_b, root) = fresh_palace("beta");
let id = send_message_to_palace(
®istry,
&root,
"alpha",
"beta",
"task",
"hello".into(),
test_creator(),
)
.await
.expect("send");
let unread = list_unread_messages(&handle_b);
assert_eq!(unread.len(), 1, "first inbox check returns the message");
assert_eq!(unread[0].id, id);
assert_eq!(unread[0].from_palace, "alpha");
assert_eq!(unread[0].to_palace, "beta");
assert_eq!(unread[0].purpose, "task");
assert_eq!(unread[0].content, "hello");
let flipped = mark_message_read(&handle_b, id).await.expect("mark");
assert!(flipped);
let after = list_unread_messages(&handle_b);
assert!(after.is_empty(), "second inbox check is empty after mark");
let all = list_messages(&handle_b, false);
assert_eq!(all.len(), 1, "history view retains the read message");
assert!(all[0].read, "history view reports it as read");
}
#[tokio::test]
async fn inbox_returns_only_unread_after_mark() {
let (registry, handle, root) = fresh_palace("inbox-only");
let mut ids = Vec::new();
for i in 0..3 {
let id = send_message_to_palace(
®istry,
&root,
"alpha",
"inbox-only",
"task",
format!("body {i}"),
test_creator(),
)
.await
.expect("send");
ids.push(id);
}
mark_message_read(&handle, ids[1]).await.expect("mark");
let unread = list_messages(&handle, true);
assert_eq!(unread.len(), 2);
assert!(!unread.iter().any(|m| m.id == ids[1]));
let all = list_messages(&handle, false);
assert_eq!(all.len(), 3);
}
#[tokio::test]
async fn mark_read_is_idempotent() {
let (registry, handle, root) = fresh_palace("idempotent");
let id = send_message_to_palace(
®istry,
&root,
"alpha",
"idempotent",
"task",
"msg".into(),
test_creator(),
)
.await
.expect("send");
assert!(mark_message_read(&handle, id).await.unwrap());
assert!(!mark_message_read(&handle, id).await.unwrap());
}
#[tokio::test]
async fn mark_read_is_atomic_under_concurrency() {
let (registry, handle, root) = fresh_palace("concurrent");
let id = send_message_to_palace(
®istry,
&root,
"alpha",
"concurrent",
"task",
"race".into(),
test_creator(),
)
.await
.expect("send");
let h1 = handle.clone();
let h2 = handle.clone();
let (a, b) = tokio::join!(
async move { mark_message_read(&h1, id).await },
async move { mark_message_read(&h2, id).await }
);
let a = a.expect("mark a");
let b = b.expect("mark b");
let total_flips = a as u8 + b as u8;
assert_eq!(total_flips, 1, "exactly one mark must flip the flag");
let after = list_messages(&handle, false);
assert_eq!(after.len(), 1, "exactly one message survives the race");
assert!(after[0].read, "survivor is marked read");
let unread = list_unread_messages(&handle);
assert!(unread.is_empty());
}
}