1use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13use super::peek::Tool;
14
15pub const MAX_MESSAGE_SIZE: usize = 8 * 1024;
16pub const MAX_PENDING_MESSAGES: usize = 16;
17pub const MAX_TOTAL_INBOX_BYTES: u64 = 32 * 1024;
18
19static UNLOCKED_MESSAGE_ID_COUNTER: std::sync::atomic::AtomicU64 =
20 std::sync::atomic::AtomicU64::new(0);
21
22#[derive(Debug, thiserror::Error)]
23pub enum InboxError {
24 #[error("message content exceeds {MAX_MESSAGE_SIZE} bytes: got {0} bytes")]
25 MessageTooLarge(usize),
26 #[error("invalid cwd path (contains `..` or is not absolute): {0}")]
27 InvalidCwd(String),
28 #[error("cannot push to self (both caller and target resolve to {0:?})")]
29 SelfPush(Tool),
30 #[error(
31 "inbox full: {current_count} messages / {current_bytes} bytes pending \
32 (limits: {MAX_PENDING_MESSAGES} messages, {MAX_TOTAL_INBOX_BYTES} bytes) — \
33 partner must drain first"
34 )]
35 InboxFull {
36 current_count: usize,
37 current_bytes: u64,
38 },
39 #[error("io error: {0}")]
40 Io(#[from] std::io::Error),
41 #[error("json error: {0}")]
42 Json(#[from] serde_json::Error),
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct InboxMessage {
47 pub pushed_at: String,
48 pub from: String,
49 pub content: String,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub thread_id: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub channel: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub message_id: Option<String>,
57}
58
59#[derive(Debug, Clone)]
61pub struct PushOutcome {
62 pub inbox_path: PathBuf,
63 pub inbox_size_after: u64,
64 pub message_id: String,
65}
66
67#[must_use]
70pub fn build_message_id(pushed_at: &str, from: &str, content: &str) -> String {
71 use sha2::{Digest, Sha256};
72
73 let mut hasher = Sha256::new();
74 hasher.update(pushed_at.as_bytes());
75 hasher.update([0]);
76 hasher.update(from.as_bytes());
77 hasher.update([0]);
78 hasher.update(content.as_bytes());
79 let digest = format!("{:x}", hasher.finalize());
80 format!("msg_{}", &digest[..12])
81}
82
83pub fn push_with_receipt(
91 mempal_home: &Path,
92 caller: Tool,
93 target: Tool,
94 cwd: &Path,
95 content: String,
96 pushed_at: String,
97) -> Result<PushOutcome, InboxError> {
98 let guard = super::receipts::acquire_receipts_lock(mempal_home, cwd);
104 let id_selection_is_serialized = guard.is_some() && cfg!(unix);
107
108 let base_id = build_message_id(&pushed_at, caller.dir_name(), &content);
109 let (used_ids, used_id_snapshot_is_complete) = collect_used_message_ids(mempal_home, cwd);
114 let message_id = if id_selection_is_serialized && used_id_snapshot_is_complete {
115 next_serialized_message_id(&base_id, &used_ids)
116 } else {
117 next_unserialized_message_id(&base_id, &used_ids)
118 };
119 let (inbox_path, inbox_size_after) = push_message(
120 mempal_home,
121 caller,
122 target,
123 cwd,
124 content,
125 pushed_at.clone(),
126 Some(message_id.clone()),
127 )?;
128
129 let event = super::receipts::ReceiptEvent {
130 event: super::receipts::EVENT_QUEUED.to_string(),
131 message_id: Some(message_id.clone()),
132 from: caller.dir_name().to_string(),
133 to: target.dir_name().to_string(),
134 at: pushed_at,
135 injected_as: None,
136 hook_runtime: None,
137 };
138 let _ = super::receipts::append_event_assuming_locked(mempal_home, cwd, &event);
140
141 Ok(PushOutcome {
142 inbox_path,
143 inbox_size_after,
144 message_id,
145 })
146}
147
148fn next_serialized_message_id(
149 base_id: &str,
150 used_ids: &std::collections::HashSet<String>,
151) -> String {
152 let mut message_id = base_id.to_string();
153 let mut suffix = 2;
154 while used_ids.contains(&message_id) {
155 message_id = format!("{base_id}-{suffix}");
156 suffix += 1;
157 }
158 message_id
159}
160
161fn next_unserialized_message_id(
166 base_id: &str,
167 used_ids: &std::collections::HashSet<String>,
168) -> String {
169 use std::sync::atomic::Ordering;
170 use std::time::{SystemTime, UNIX_EPOCH};
171
172 loop {
173 let nanos = SystemTime::now()
174 .duration_since(UNIX_EPOCH)
175 .map(|duration| duration.as_nanos())
176 .unwrap_or_default();
177 let sequence = UNLOCKED_MESSAGE_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
178 let candidate = format!("{base_id}-u{:x}-{nanos:x}-{sequence:x}", std::process::id());
179 if !used_ids.contains(&candidate) {
180 return candidate;
181 }
182 }
183}
184
185fn collect_used_message_ids(
189 mempal_home: &Path,
190 cwd: &Path,
191) -> (std::collections::HashSet<String>, bool) {
192 let mut used = std::collections::HashSet::new();
193 let mut is_complete = true;
194 match super::receipts::load_events_with_completeness(mempal_home, cwd) {
195 Ok((events, receipts_are_complete)) => {
196 used.extend(events.into_iter().filter_map(|event| event.message_id));
197 is_complete &= receipts_are_complete;
198 }
199 Err(_) => is_complete = false,
200 }
201 for target in [Tool::Claude, Tool::Codex] {
202 let Ok(path) = inbox_path(mempal_home, target, cwd) else {
203 is_complete = false;
204 continue;
205 };
206 let content = match std::fs::read_to_string(&path) {
207 Ok(content) => content,
208 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
209 Err(_) => {
210 is_complete = false;
211 continue;
212 }
213 };
214 for line in content.lines() {
215 match serde_json::from_str::<InboxMessage>(line.trim()) {
216 Ok(message) => {
217 if let Some(id) = message.message_id {
218 used.insert(id);
219 }
220 }
221 Err(_) => is_complete = false,
222 }
223 }
224 }
225 (used, is_complete)
226}
227
228pub fn mempal_home() -> PathBuf {
235 match std::env::var_os("HOME") {
236 Some(home) => PathBuf::from(home).join(".mempal"),
237 None => PathBuf::from(".mempal"),
238 }
239}
240
241pub fn project_identity(cwd: &Path) -> PathBuf {
249 let mut current = cwd.to_path_buf();
250 loop {
251 if current.join(".git").exists() {
252 return current;
253 }
254 match current.parent() {
255 Some(parent) => current = parent.to_path_buf(),
256 None => return cwd.to_path_buf(),
257 }
258 }
259}
260
261pub fn encode_project_identity(identity: &Path) -> Result<String, InboxError> {
265 let s = identity.to_string_lossy();
266 if !identity.is_absolute() || s.contains("..") {
267 return Err(InboxError::InvalidCwd(s.to_string()));
268 }
269 Ok(s.replace('/', "-"))
270}
271
272pub fn inbox_path(mempal_home: &Path, target: Tool, cwd: &Path) -> Result<PathBuf, InboxError> {
274 let identity = project_identity(cwd);
275 let encoded = encode_project_identity(&identity)?;
276 Ok(mempal_home
277 .join("cowork-inbox")
278 .join(target.dir_name())
279 .join(format!("{encoded}.jsonl")))
280}
281
282pub fn push(
288 mempal_home: &Path,
289 caller: Tool,
290 target: Tool,
291 cwd: &Path,
292 content: String,
293 pushed_at: String,
294) -> Result<(PathBuf, u64), InboxError> {
295 push_message(mempal_home, caller, target, cwd, content, pushed_at, None)
296}
297
298#[allow(clippy::too_many_arguments)]
299fn push_message(
300 mempal_home: &Path,
301 caller: Tool,
302 target: Tool,
303 cwd: &Path,
304 content: String,
305 pushed_at: String,
306 message_id: Option<String>,
307) -> Result<(PathBuf, u64), InboxError> {
308 use std::fs;
309 use std::io::Write;
310
311 if caller == target {
312 return Err(InboxError::SelfPush(caller));
313 }
314 if content.len() > MAX_MESSAGE_SIZE {
315 return Err(InboxError::MessageTooLarge(content.len()));
316 }
317
318 let path = inbox_path(mempal_home, target, cwd)?;
319 if let Some(parent) = path.parent() {
320 fs::create_dir_all(parent)?;
321 }
322
323 let (existing_count, existing_bytes) = if path.exists() {
324 let content_bytes = fs::read_to_string(&path).unwrap_or_default();
325 let line_count = content_bytes
326 .lines()
327 .filter(|l| !l.trim().is_empty())
328 .count();
329 (line_count, content_bytes.len() as u64)
330 } else {
331 (0, 0)
332 };
333
334 let msg = InboxMessage {
335 pushed_at,
336 from: caller.dir_name().to_string(),
337 content,
338 thread_id: None,
339 channel: None,
340 message_id,
341 };
342 let line = serde_json::to_string(&msg)?;
343 let new_line_bytes = (line.len() as u64) + 1;
345 let prospective_count = existing_count + 1;
346 let prospective_bytes = existing_bytes.saturating_add(new_line_bytes);
347 if prospective_count > MAX_PENDING_MESSAGES || prospective_bytes > MAX_TOTAL_INBOX_BYTES {
348 return Err(InboxError::InboxFull {
349 current_count: existing_count,
350 current_bytes: existing_bytes,
351 });
352 }
353
354 let mut file = fs::OpenOptions::new()
355 .create(true)
356 .append(true)
357 .open(&path)?;
358 writeln!(file, "{line}")?;
359 file.flush()?;
360
361 let size = fs::metadata(&path)?.len();
362 Ok((path, size))
363}
364
365pub fn drain(
375 mempal_home: &Path,
376 target: Tool,
377 cwd: &Path,
378) -> Result<Vec<InboxMessage>, InboxError> {
379 use std::fs;
380
381 let path = inbox_path(mempal_home, target, cwd)?;
382 let draining = path.with_extension("draining");
383
384 match fs::rename(&path, &draining) {
385 Ok(_) => {}
386 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
387 return Ok(Vec::new());
388 }
389 Err(e) => return Err(e.into()),
390 }
391
392 let content = fs::read_to_string(&draining)?;
393 let mut messages = Vec::new();
394 for line in content.lines() {
395 let trimmed = line.trim();
396 if trimmed.is_empty() {
397 continue;
398 }
399 if let Ok(msg) = serde_json::from_str::<InboxMessage>(trimmed) {
401 messages.push(msg);
402 }
403 }
404
405 let _ = fs::remove_file(&draining);
407 Ok(messages)
408}
409
410pub fn format_plain(from: Tool, messages: &[InboxMessage]) -> String {
412 if messages.is_empty() {
413 return String::new();
414 }
415 let mut out = format!(
416 "[Partner inbox from {} ({} message{} since last check):]\n",
417 from.dir_name(),
418 messages.len(),
419 if messages.len() == 1 { "" } else { "s" }
420 );
421 for msg in messages {
422 out.push_str(&format!("- {}: {}\n", msg.pushed_at, msg.content));
423 }
424 out.push_str("[End partner inbox]\n");
425 out
426}
427
428pub fn format_codex_hook_json(from: Tool, messages: &[InboxMessage]) -> Result<String, InboxError> {
431 if messages.is_empty() {
432 return Ok(String::new());
433 }
434 let plain = format_plain(from, messages);
435 let envelope = serde_json::json!({
436 "hookSpecificOutput": {
437 "hookEventName": "UserPromptSubmit",
438 "additionalContext": plain
439 }
440 });
441 Ok(envelope.to_string())
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use std::fs;
448 use tempfile::TempDir;
449
450 #[test]
451 fn project_identity_walks_to_git_root_from_subdir() {
452 let tmp = TempDir::new().unwrap();
453 let repo_root = tmp.path().join("project-gamma");
454 let subdir = repo_root.join("src").join("cowork");
455 fs::create_dir_all(&subdir).unwrap();
456 fs::create_dir_all(repo_root.join(".git")).unwrap();
457
458 assert_eq!(project_identity(&subdir), repo_root);
459 assert_eq!(project_identity(&repo_root), repo_root);
460 }
461
462 #[test]
463 fn project_identity_falls_back_to_raw_cwd_without_git() {
464 let tmp = TempDir::new().unwrap();
465 let plain = tmp.path().join("no-git-dir");
466 fs::create_dir_all(&plain).unwrap();
467
468 assert_eq!(project_identity(&plain), plain);
469 }
470
471 #[test]
472 fn encode_project_identity_rejects_relative_path() {
473 let result = encode_project_identity(Path::new("relative/path"));
474 assert!(matches!(result, Err(InboxError::InvalidCwd(_))));
475 }
476
477 #[test]
478 fn encode_project_identity_rejects_parent_traversal() {
479 let result = encode_project_identity(Path::new("/tmp/../etc"));
480 assert!(matches!(result, Err(InboxError::InvalidCwd(_))));
481 }
482
483 #[test]
484 fn encode_project_identity_replaces_slashes_with_dashes() {
485 let encoded =
486 encode_project_identity(Path::new("/Users/zhangalex/Work/Projects/AI/mempal")).unwrap();
487 assert_eq!(encoded, "-Users-zhangalex-Work-Projects-AI-mempal");
488 }
489
490 #[test]
491 fn mempal_home_resolves_from_home_env_var() {
492 let home = std::env::var("HOME").unwrap_or_default();
495 if home.is_empty() {
496 return;
497 }
498 let resolved = mempal_home();
499 assert_eq!(resolved, PathBuf::from(home).join(".mempal"));
500 }
501
502 #[test]
503 fn inbox_path_composes_home_target_and_encoded_identity() {
504 let tmp = TempDir::new().unwrap();
505 let repo = tmp.path().join("proj");
506 fs::create_dir_all(repo.join(".git")).unwrap();
507
508 let path = inbox_path(tmp.path(), Tool::Codex, &repo).unwrap();
509 assert!(path.starts_with(tmp.path().join("cowork-inbox").join("codex")));
510 assert!(path.to_string_lossy().ends_with(".jsonl"));
511 let encoded_name = path.file_name().unwrap().to_string_lossy().into_owned();
512 assert!(encoded_name.contains("proj"));
513 }
514
515 fn rfc3339() -> String {
516 "2026-04-15T00:00:00Z".to_string()
517 }
518
519 fn tmpdir_with_git() -> (TempDir, PathBuf) {
520 let tmp = TempDir::new().unwrap();
521 let repo = tmp.path().join("proj");
522 fs::create_dir_all(repo.join(".git")).unwrap();
523 (tmp, repo)
524 }
525
526 #[test]
527 fn push_rejects_content_over_max_size() {
528 let tmp_home = TempDir::new().unwrap();
529 let (_tmp_repo, repo) = tmpdir_with_git();
530 let oversize = "x".repeat(MAX_MESSAGE_SIZE + 1);
531 let err = push(
532 tmp_home.path(),
533 Tool::Claude,
534 Tool::Codex,
535 &repo,
536 oversize,
537 rfc3339(),
538 )
539 .unwrap_err();
540 assert!(matches!(err, InboxError::MessageTooLarge(n) if n == MAX_MESSAGE_SIZE + 1));
541 }
542
543 #[test]
544 fn push_rejects_cwd_with_parent_traversal() {
545 let tmp = TempDir::new().unwrap();
546 let weird = Path::new("/tmp/../etc");
547 let err = push(
548 tmp.path(),
549 Tool::Claude,
550 Tool::Codex,
551 weird,
552 "x".into(),
553 rfc3339(),
554 )
555 .unwrap_err();
556 assert!(matches!(err, InboxError::InvalidCwd(_)));
557 }
558
559 #[test]
560 fn push_rejects_self_push() {
561 let tmp_home = TempDir::new().unwrap();
562 let (_t, repo) = tmpdir_with_git();
563 let err = push(
564 tmp_home.path(),
565 Tool::Codex,
566 Tool::Codex,
567 &repo,
568 "x".into(),
569 rfc3339(),
570 )
571 .unwrap_err();
572 assert!(matches!(err, InboxError::SelfPush(Tool::Codex)));
573 }
574
575 #[test]
576 fn push_rejects_when_prospective_count_would_exceed_limit() {
577 let tmp_home = TempDir::new().unwrap();
578 let (_t, repo) = tmpdir_with_git();
579 for _ in 0..MAX_PENDING_MESSAGES {
580 push(
581 tmp_home.path(),
582 Tool::Claude,
583 Tool::Codex,
584 &repo,
585 "a".into(),
586 rfc3339(),
587 )
588 .unwrap();
589 }
590 let err = push(
591 tmp_home.path(),
592 Tool::Claude,
593 Tool::Codex,
594 &repo,
595 "a".into(),
596 rfc3339(),
597 )
598 .unwrap_err();
599 assert!(matches!(
600 err,
601 InboxError::InboxFull {
602 current_count: 16,
603 ..
604 }
605 ));
606 }
607
608 #[test]
609 fn push_rejects_when_prospective_bytes_would_cross_limit() {
610 let tmp_home = TempDir::new().unwrap();
613 let (_t, repo) = tmpdir_with_git();
614
615 const TARGET_BYTES: u64 = 32_700;
616 const TARGET_COUNT: usize = 10;
617 let bytes_per_push = (TARGET_BYTES / TARGET_COUNT as u64) as usize;
618
619 let probe = InboxMessage {
620 pushed_at: rfc3339(),
621 from: Tool::Claude.dir_name().to_string(),
622 content: String::new(),
623 thread_id: None,
624 channel: None,
625 message_id: None,
626 };
627 let empty_line_bytes = serde_json::to_string(&probe).unwrap().len() + 1;
628 assert!(
629 bytes_per_push > empty_line_bytes,
630 "bytes_per_push ({bytes_per_push}) must exceed empty_line_bytes ({empty_line_bytes})"
631 );
632 let content_per_push = "a".repeat(bytes_per_push - empty_line_bytes);
633
634 for _ in 0..TARGET_COUNT {
635 push(
636 tmp_home.path(),
637 Tool::Claude,
638 Tool::Codex,
639 &repo,
640 content_per_push.clone(),
641 rfc3339(),
642 )
643 .unwrap();
644 }
645
646 let inbox = inbox_path(tmp_home.path(), Tool::Codex, &repo).unwrap();
647 let current_bytes = fs::metadata(&inbox).unwrap().len();
648 let current_count = fs::read_to_string(&inbox)
649 .unwrap()
650 .lines()
651 .filter(|l| !l.trim().is_empty())
652 .count();
653 assert_eq!(
654 current_bytes, TARGET_BYTES,
655 "precondition: current_bytes == 32700"
656 );
657 assert_eq!(
658 current_count, TARGET_COUNT,
659 "precondition: current_count == 10"
660 );
661
662 let would_cross = "y".repeat(200);
663 let err = push(
664 tmp_home.path(),
665 Tool::Claude,
666 Tool::Codex,
667 &repo,
668 would_cross,
669 rfc3339(),
670 )
671 .unwrap_err();
672 assert!(
673 matches!(
674 err,
675 InboxError::InboxFull {
676 current_count: 10,
677 current_bytes: 32_700,
678 }
679 ),
680 "expected InboxFull with 10/32700 preconditions, got: {err:?}"
681 );
682
683 let after = fs::metadata(&inbox).unwrap().len();
684 assert_eq!(after, TARGET_BYTES);
685 }
686
687 #[test]
688 fn push_accepts_when_prospective_bytes_exactly_at_limit_and_rejects_one_more() {
689 let tmp_home = TempDir::new().unwrap();
697 let (_t, repo) = tmpdir_with_git();
698
699 let probe = InboxMessage {
700 pushed_at: rfc3339(),
701 from: Tool::Claude.dir_name().to_string(),
702 content: String::new(),
703 thread_id: None,
704 channel: None,
705 message_id: None,
706 };
707 let probe_empty_line_bytes = serde_json::to_string(&probe).unwrap().len() as u64 + 1;
708
709 let seed_content_len = MAX_MESSAGE_SIZE - 100;
714 let seed_content = "s".repeat(seed_content_len);
715 let inbox_preview = inbox_path(tmp_home.path(), Tool::Codex, &repo).unwrap();
716 loop {
717 let current = if inbox_preview.exists() {
718 fs::metadata(&inbox_preview).unwrap().len()
719 } else {
720 0
721 };
722 let remaining_after_maybe_seed = MAX_TOTAL_INBOX_BYTES
723 - current
724 - probe_empty_line_bytes
725 - (seed_content_len as u64);
726 if remaining_after_maybe_seed < (MAX_MESSAGE_SIZE as u64) {
729 break;
730 }
731 push(
732 tmp_home.path(),
733 Tool::Claude,
734 Tool::Codex,
735 &repo,
736 seed_content.clone(),
737 rfc3339(),
738 )
739 .unwrap();
740 }
741 push(
743 tmp_home.path(),
744 Tool::Claude,
745 Tool::Codex,
746 &repo,
747 seed_content.clone(),
748 rfc3339(),
749 )
750 .unwrap();
751
752 let inbox = inbox_path(tmp_home.path(), Tool::Codex, &repo).unwrap();
753 let current_bytes = fs::metadata(&inbox).unwrap().len();
754 let remaining = MAX_TOTAL_INBOX_BYTES - current_bytes;
755 let exact_content_len = (remaining - probe_empty_line_bytes) as usize;
758 assert!(
759 exact_content_len <= MAX_MESSAGE_SIZE,
760 "seed math is wrong: exact_content_len {exact_content_len} > MAX_MESSAGE_SIZE {MAX_MESSAGE_SIZE}"
761 );
762 let exact_content = "a".repeat(exact_content_len);
763
764 push(
765 tmp_home.path(),
766 Tool::Claude,
767 Tool::Codex,
768 &repo,
769 exact_content,
770 rfc3339(),
771 )
772 .unwrap();
773
774 let final_bytes = fs::metadata(&inbox).unwrap().len();
775 assert_eq!(
776 final_bytes, MAX_TOTAL_INBOX_BYTES,
777 "inbox MUST land exactly on the 32 KB boundary"
778 );
779
780 let err = push(
782 tmp_home.path(),
783 Tool::Claude,
784 Tool::Codex,
785 &repo,
786 "x".into(),
787 rfc3339(),
788 )
789 .unwrap_err();
790 assert!(
791 matches!(err, InboxError::InboxFull { .. }),
792 "one byte over the limit MUST be rejected, got: {err:?}"
793 );
794
795 let after_rejected = fs::metadata(&inbox).unwrap().len();
796 assert_eq!(after_rejected, MAX_TOTAL_INBOX_BYTES);
797 }
798
799 #[test]
800 fn drain_round_trip_preserves_content_bytes() {
801 let tmp_home = TempDir::new().unwrap();
802 let (_t, repo) = tmpdir_with_git();
803 let content = "hello from claude, P8 test #1".to_string();
804 push(
805 tmp_home.path(),
806 Tool::Claude,
807 Tool::Codex,
808 &repo,
809 content.clone(),
810 rfc3339(),
811 )
812 .unwrap();
813
814 let messages = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
815 assert_eq!(messages.len(), 1);
816 assert_eq!(messages[0].content, content);
817 assert_eq!(messages[0].from, "claude");
818 }
819
820 #[test]
821 fn drain_preserves_unicode_bytes_round_trip() {
822 let tmp_home = TempDir::new().unwrap();
823 let (_t, repo) = tmpdir_with_git();
824 let content = "决策:采用 Arc<Mutex<>> 🔒 because 'shared ownership' 需要".to_string();
825 push(
826 tmp_home.path(),
827 Tool::Claude,
828 Tool::Codex,
829 &repo,
830 content.clone(),
831 rfc3339(),
832 )
833 .unwrap();
834
835 let messages = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
836 assert_eq!(messages.len(), 1);
837 assert_eq!(messages[0].content, content);
838 }
839
840 #[test]
841 fn drain_empty_inbox_returns_empty_vec() {
842 let tmp_home = TempDir::new().unwrap();
843 let (_t, repo) = tmpdir_with_git();
844 let messages = drain(tmp_home.path(), Tool::Claude, &repo).unwrap();
845 assert!(messages.is_empty());
846 }
847
848 #[test]
849 fn drain_nonexistent_inbox_dir_returns_empty_vec() {
850 let tmp_home = TempDir::new().unwrap();
851 let (_t, repo) = tmpdir_with_git();
852 let messages = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
853 assert!(messages.is_empty());
854 }
855
856 #[test]
857 fn drain_preserves_fifo_order() {
858 let tmp_home = TempDir::new().unwrap();
859 let (_t, repo) = tmpdir_with_git();
860 for i in 0..3 {
861 push(
862 tmp_home.path(),
863 Tool::Claude,
864 Tool::Codex,
865 &repo,
866 format!("message-{i}"),
867 rfc3339(),
868 )
869 .unwrap();
870 }
871
872 let messages = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
873 assert_eq!(messages.len(), 3);
874 assert_eq!(messages[0].content, "message-0");
875 assert_eq!(messages[1].content, "message-1");
876 assert_eq!(messages[2].content, "message-2");
877 }
878
879 #[test]
880 fn drain_is_one_shot_file_disappears() {
881 let tmp_home = TempDir::new().unwrap();
882 let (_t, repo) = tmpdir_with_git();
883 push(
884 tmp_home.path(),
885 Tool::Claude,
886 Tool::Codex,
887 &repo,
888 "one".into(),
889 rfc3339(),
890 )
891 .unwrap();
892
893 let first = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
894 assert_eq!(first.len(), 1);
895
896 let second = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
897 assert!(second.is_empty());
898
899 let path = inbox_path(tmp_home.path(), Tool::Codex, &repo).unwrap();
900 assert!(!path.exists());
901 }
902
903 #[test]
904 fn drain_is_isolated_per_distinct_project() {
905 let tmp_home = TempDir::new().unwrap();
906 let proj_a = tmp_home.path().join("alpha");
907 let proj_b = tmp_home.path().join("beta");
908 fs::create_dir_all(proj_a.join(".git")).unwrap();
909 fs::create_dir_all(proj_b.join(".git")).unwrap();
910
911 push(
912 tmp_home.path(),
913 Tool::Claude,
914 Tool::Codex,
915 &proj_a,
916 "for alpha".into(),
917 rfc3339(),
918 )
919 .unwrap();
920
921 let drained = drain(tmp_home.path(), Tool::Codex, &proj_b).unwrap();
922 assert!(
923 drained.is_empty(),
924 "proj-b drain must not see proj-a messages"
925 );
926
927 let path_a = inbox_path(tmp_home.path(), Tool::Codex, &proj_a).unwrap();
928 assert!(path_a.exists(), "proj-a inbox still present");
929 }
930
931 #[test]
932 fn format_plain_empty_messages_returns_empty_string() {
933 let out = format_plain(Tool::Codex, &[]);
934 assert!(out.is_empty());
935 }
936
937 #[test]
938 fn format_plain_includes_count_and_message_lines() {
939 let msgs = vec![
940 InboxMessage {
941 pushed_at: "2026-04-15T01:00:00Z".into(),
942 from: "codex".into(),
943 content: "first".into(),
944 thread_id: None,
945 channel: None,
946 message_id: None,
947 },
948 InboxMessage {
949 pushed_at: "2026-04-15T01:01:00Z".into(),
950 from: "codex".into(),
951 content: "second".into(),
952 thread_id: None,
953 channel: None,
954 message_id: None,
955 },
956 ];
957 let out = format_plain(Tool::Codex, &msgs);
958 assert!(out.contains("Partner inbox from codex"));
959 assert!(out.contains("2 messages"));
960 assert!(out.contains("first"));
961 assert!(out.contains("second"));
962 assert!(out.contains("[End partner inbox]"));
963 }
964
965 #[test]
966 fn format_codex_hook_json_wraps_plain_in_correct_envelope() {
967 let msgs = vec![InboxMessage {
968 pushed_at: "2026-04-15T01:00:00Z".into(),
969 from: "claude".into(),
970 content: "test\nwith\nnewlines and \"quotes\"".into(),
971 thread_id: None,
972 channel: None,
973 message_id: None,
974 }];
975 let out = format_codex_hook_json(Tool::Claude, &msgs).unwrap();
976
977 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
978 assert_eq!(
979 parsed["hookSpecificOutput"]["hookEventName"],
980 "UserPromptSubmit"
981 );
982
983 let ac = parsed["hookSpecificOutput"]["additionalContext"]
984 .as_str()
985 .unwrap();
986 let expected_plain = format_plain(Tool::Claude, &msgs);
987 assert_eq!(ac, expected_plain);
988
989 assert!(ac.contains("test\nwith\nnewlines"));
990 assert!(ac.contains("\"quotes\""));
991 }
992
993 #[test]
994 fn format_codex_hook_json_empty_returns_empty_string() {
995 let out = format_codex_hook_json(Tool::Claude, &[]).unwrap();
996 assert!(out.is_empty());
997 }
998}