1mod 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 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 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 unsafe { std::env::remove_var(key) };
81 Self { key, prev }
82 }
83 }
84
85 impl Drop for EnvGuard {
86 fn drop(&mut self) {
87 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 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 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 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 assert_eq!(slugify_string("trusty-tools.git"), "trusty-tools");
196 assert_eq!(slugify_string("trusty/tools!"), "trustytools");
198 assert_eq!(slugify_string("foo--bar"), "foo-bar");
200 assert_eq!(slugify_string("漢字"), "");
202
203 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 let _guard = EnvGuard::clear(crate::palace_id_derive::PALACE_OVERRIDE_ENV);
218 let tmp = tempfile::tempdir().expect("tempdir");
219 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 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 let id = send_message_to_palace(
417 ®istry,
418 &root,
419 "alpha",
420 "beta",
421 "task",
422 "hello".into(),
423 test_creator(),
424 )
425 .await
426 .expect("send");
427 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 let flipped = mark_message_read(&handle_b, id).await.expect("mark");
437 assert!(flipped);
438 let after = list_unread_messages(&handle_b);
440 assert!(after.is_empty(), "second inbox check is empty after mark");
441 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 let mut ids = Vec::new();
452 for i in 0..3 {
453 let id = send_message_to_palace(
454 ®istry,
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_message_read(&handle, ids[1]).await.expect("mark");
468 let unread = list_messages(&handle, true);
470 assert_eq!(unread.len(), 2);
471 assert!(!unread.iter().any(|m| m.id == ids[1]));
472 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 ®istry,
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 assert!(!mark_message_read(&handle, id).await.unwrap());
494 }
495
496 #[tokio::test]
497 async fn mark_read_is_atomic_under_concurrency() {
498 let (registry, handle, root) = fresh_palace("concurrent");
503 let id = send_message_to_palace(
504 ®istry,
505 &root,
506 "alpha",
507 "concurrent",
508 "task",
509 "race".into(),
510 test_creator(),
511 )
512 .await
513 .expect("send");
514 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 let total_flips = a as u8 + b as u8;
527 assert_eq!(total_flips, 1, "exactly one mark must flip the flag");
528
529 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 let unread = list_unread_messages(&handle);
535 assert!(unread.is_empty());
536 }
537}