use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
use trusty_common::memory_core::palace::{Drawer, RoomType};
use trusty_common::memory_core::retrieval::RememberOptions;
use trusty_common::memory_core::PalaceHandle;
use uuid::Uuid;
pub const MSG_MARKER_TAG: &str = "msg:v1";
pub const TAG_FROM_PREFIX: &str = "msg:from=";
pub const TAG_TO_PREFIX: &str = "msg:to=";
pub const TAG_PURPOSE_PREFIX: &str = "msg:purpose=";
pub const TAG_SENT_AT_PREFIX: &str = "msg:sent_at=";
pub const TAG_READ_PREFIX: &str = "msg:read=";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: Uuid,
pub from_palace: String,
pub to_palace: String,
pub purpose: String,
pub sent_at: DateTime<Utc>,
pub read: bool,
pub content: String,
}
impl Message {
pub fn from_drawer(drawer: &Drawer) -> Option<Self> {
if !drawer.tags.iter().any(|t| t == MSG_MARKER_TAG) {
return None;
}
let from_palace = extract_tag(drawer, TAG_FROM_PREFIX)?.to_string();
let to_palace = extract_tag(drawer, TAG_TO_PREFIX)?.to_string();
let purpose = extract_tag(drawer, TAG_PURPOSE_PREFIX)?.to_string();
let sent_at_raw = extract_tag(drawer, TAG_SENT_AT_PREFIX)?;
let sent_at = DateTime::parse_from_rfc3339(sent_at_raw)
.ok()?
.with_timezone(&Utc);
let read = extract_tag(drawer, TAG_READ_PREFIX)
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
Some(Message {
id: drawer.id,
from_palace,
to_palace,
purpose,
sent_at,
read,
content: drawer.content.clone(),
})
}
pub fn to_injection_block(&self) -> String {
format!(
"## Message from {from} (purpose: {purpose})\n\
_sent {sent_at} → {to}_\n\
\n\
{content}\n",
from = self.from_palace,
purpose = self.purpose,
sent_at = self.sent_at.to_rfc3339(),
to = self.to_palace,
content = self.content
)
}
}
fn extract_tag<'a>(drawer: &'a Drawer, prefix: &str) -> Option<&'a str> {
drawer.tags.iter().find_map(|t| t.strip_prefix(prefix))
}
pub fn build_message_tags(
from_palace: &str,
to_palace: &str,
purpose: &str,
sent_at: DateTime<Utc>,
) -> Vec<String> {
vec![
MSG_MARKER_TAG.to_string(),
format!("{TAG_FROM_PREFIX}{from_palace}"),
format!("{TAG_TO_PREFIX}{to_palace}"),
format!("{TAG_PURPOSE_PREFIX}{purpose}"),
format!("{TAG_SENT_AT_PREFIX}{ts}", ts = sent_at.to_rfc3339()),
format!("{TAG_READ_PREFIX}false"),
]
}
pub async fn send_message_to_palace(
registry: &trusty_common::memory_core::PalaceRegistry,
data_root: &Path,
from_palace: &str,
to_palace: &str,
purpose: &str,
content: String,
creator: crate::attribution::CreatorInfo,
) -> Result<Uuid> {
let pid = trusty_common::memory_core::PalaceId::new(to_palace);
let handle = registry
.open_palace(data_root, &pid)
.with_context(|| format!("open recipient palace {to_palace}"))?;
let sent_at = Utc::now();
let mut tags = build_message_tags(from_palace, to_palace, purpose, sent_at);
creator.merge_into(&mut tags);
let opts = RememberOptions {
force: true,
..RememberOptions::default()
};
let drawer_id = handle
.remember_with_options(
content,
RoomType::Custom("Messages".to_string()),
tags,
0.7,
opts,
)
.await
.context("write message drawer")?;
Ok(drawer_id)
}
pub fn list_unread_messages(handle: &Arc<PalaceHandle>) -> Vec<Message> {
let drawers = handle.list_drawers(None, Some(MSG_MARKER_TAG.to_string()), usize::MAX);
let mut msgs: Vec<Message> = drawers
.iter()
.filter_map(Message::from_drawer)
.filter(|m| !m.read)
.collect();
msgs.sort_by_key(|m| m.sent_at);
msgs
}
pub fn list_messages(handle: &Arc<PalaceHandle>, unread_only: bool) -> Vec<Message> {
let drawers = handle.list_drawers(None, Some(MSG_MARKER_TAG.to_string()), usize::MAX);
let mut msgs: Vec<Message> = drawers
.iter()
.filter_map(Message::from_drawer)
.filter(|m| !unread_only || !m.read)
.collect();
msgs.sort_by_key(|m| m.sent_at);
msgs
}
pub async fn mark_message_read(handle: &Arc<PalaceHandle>, drawer_id: Uuid) -> Result<bool> {
let snapshot: Option<Drawer> = {
let mut drawers = handle.drawers.write();
match drawers.iter_mut().find(|d| d.id == drawer_id) {
None => None,
Some(drawer) => {
if drawer
.tags
.iter()
.any(|t| t.eq_ignore_ascii_case("msg:read=true"))
{
None
} else {
drawer.tags.retain(|t| !t.starts_with(TAG_READ_PREFIX));
drawer.tags.push(format!("{TAG_READ_PREFIX}true"));
Some(drawer.clone())
}
}
}
};
let Some(updated) = snapshot else {
return Ok(false);
};
handle
.kg
.upsert_drawer(&updated)
.await
.context("persist drawer tag update (mark-read)")?;
Ok(true)
}
pub fn slugify_for_palace(path: &Path) -> Result<String> {
let raw = path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("path has no final component: {}", path.display()))?;
Ok(slugify_string(raw))
}
pub fn slugify_string(input: &str) -> String {
let lowered = input.trim().to_ascii_lowercase();
let stripped = lowered.strip_suffix(".git").unwrap_or(&lowered);
let mut out = String::with_capacity(stripped.len());
let mut prev_hyphen = false;
for c in stripped.chars() {
let next = match c {
'a'..='z' | '0'..='9' => Some(c),
'_' | '-' | ' ' | '\t' => Some('-'),
_ => None,
};
if let Some(c) = next {
if c == '-' {
if !prev_hyphen && !out.is_empty() {
out.push('-');
prev_hyphen = true;
}
} else {
out.push(c);
prev_hyphen = false;
}
}
}
while out.ends_with('-') {
out.pop();
}
out
}
pub fn cwd_palace_slug() -> Result<String> {
let cwd = std::env::current_dir().context("read cwd")?;
cwd_palace_slug_at(&cwd)
}
pub fn cwd_palace_slug_at(start: &Path) -> Result<String> {
let output = std::process::Command::new("git")
.arg("rev-parse")
.arg("--show-toplevel")
.current_dir(start)
.output();
if let Ok(output) = output {
if output.status.success() {
let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !toplevel.is_empty() {
let slug = slugify_for_palace(Path::new(&toplevel))?;
if !slug.is_empty() {
return Ok(slug);
}
}
}
}
let slug = slugify_for_palace(start)?;
if slug.is_empty() {
return Err(anyhow!(
"could not derive palace slug from cwd {} — pass --palace explicitly",
start.display()
));
}
Ok(slug)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attribution::{CreatorInfo, CreatorSource};
use std::path::PathBuf;
use trusty_common::memory_core::{Palace, PalaceId, PalaceRegistry};
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() {
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() {
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() {
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"
);
}
#[test]
fn cwd_palace_slug_uses_git_toplevel() {
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");
}
}
#[test]
fn cwd_palace_slug_falls_back_to_basename() {
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_eq!(slug, "my-project");
}
#[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());
}
}