1use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::{Context, Result, bail};
7use tracing::{info, warn};
8
9use super::{completion, config, hierarchy, inbox, merge, team_config_path, telegram};
10
11const INBOX_BODY_PREVIEW_CHARS: usize = 140;
12
13fn resolve_role_name(project_root: &Path, member_name: &str) -> String {
16 if matches!(member_name, "human" | "daemon") {
18 return member_name.to_string();
19 }
20 let config_path = team_config_path(project_root);
21 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
22 if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
23 if let Some(m) = members.iter().find(|m| m.name == member_name) {
24 return m.role_name.clone();
25 }
26 }
27 }
28 member_name.to_string()
30}
31
32pub(crate) fn resolve_member_name(project_root: &Path, member_name: &str) -> Result<String> {
39 if matches!(member_name, "human" | "daemon") {
40 return Ok(member_name.to_string());
41 }
42
43 let config_path = team_config_path(project_root);
44 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
45 if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
46 if let Some(member) = members.iter().find(|m| m.name == member_name) {
47 return Ok(member.name.clone());
48 }
49
50 let matches: Vec<String> = members
51 .iter()
52 .filter(|m| m.role_name == member_name)
53 .map(|m| m.name.clone())
54 .collect();
55
56 return match matches.len() {
57 0 => Ok(member_name.to_string()),
58 1 => Ok(matches[0].clone()),
59 _ => bail!(
60 "'{member_name}' matches multiple members: {}. Use the explicit member name.",
61 matches.join(", ")
62 ),
63 };
64 }
65 }
66
67 Ok(member_name.to_string())
68}
69
70pub fn send_message(project_root: &Path, role: &str, msg: &str) -> Result<()> {
76 send_message_as(project_root, None, role, msg)
77}
78
79pub fn send_message_as(
80 project_root: &Path,
81 from_override: Option<&str>,
82 role: &str,
83 msg: &str,
84) -> Result<()> {
85 let from = effective_sender(project_root, from_override);
86 let recipient = resolve_member_name(project_root, role)?;
87
88 let config_path = team_config_path(project_root);
90 if config_path.exists() {
91 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
92 let from_role = resolve_role_name(project_root, &from);
93 let to_role = resolve_role_name(project_root, &recipient);
94 if !team_config.can_talk(&from_role, &to_role) {
95 bail!(
96 "{from} ({from_role}) is not allowed to message {recipient} ({to_role}). \
97 Check talks_to in team.yaml."
98 );
99 }
100 }
101 }
102
103 let root = inbox::inboxes_root(project_root);
104 let inbox_msg = inbox::InboxMessage::new_send(&from, &recipient, msg);
105 let id = inbox::deliver_to_inbox(&root, &inbox_msg)?;
106 if let Err(error) = completion::ingest_completion_message(project_root, msg) {
107 warn!(from, to = %recipient, error = %error, "failed to ingest completion packet");
108 }
109 info!(to = %recipient, id = %id, "message delivered to inbox");
110 Ok(())
111}
112
113pub(crate) fn detect_sender() -> Option<String> {
116 if let Ok(member) = std::env::var("BATTY_MEMBER") {
118 if !member.is_empty() {
119 return Some(member);
120 }
121 }
122
123 let pane_id = std::env::var("TMUX_PANE").ok()?;
125 let output = std::process::Command::new("tmux")
126 .args(["show-options", "-p", "-t", &pane_id, "-v", "@batty_role"])
127 .output()
128 .ok()?;
129 if output.status.success() {
130 let role = String::from_utf8_lossy(&output.stdout).trim().to_string();
131 if !role.is_empty() { Some(role) } else { None }
132 } else {
133 None
134 }
135}
136
137fn effective_sender(project_root: &Path, from_override: Option<&str>) -> String {
138 let candidate = from_override
139 .map(str::to_string)
140 .or_else(detect_sender)
141 .unwrap_or_else(|| "human".to_string());
142
143 if sender_belongs_to_project(project_root, &candidate) {
144 candidate
145 } else {
146 "human".to_string()
147 }
148}
149
150fn sender_belongs_to_project(project_root: &Path, sender: &str) -> bool {
151 if matches!(sender, "human" | "daemon") {
152 return true;
153 }
154
155 let config_path = team_config_path(project_root);
156 let Ok(team_config) = config::TeamConfig::load(&config_path) else {
157 return false;
158 };
159
160 if team_config.roles.iter().any(|role| role.name == sender) {
161 return true;
162 }
163
164 hierarchy::resolve_hierarchy(&team_config)
165 .map(|members| members.iter().any(|member| member.name == sender))
166 .unwrap_or(false)
167}
168
169pub fn assign_task(project_root: &Path, engineer: &str, task: &str) -> Result<String> {
171 let from = effective_sender(project_root, None);
172 let recipient = resolve_member_name(project_root, engineer)?;
173
174 let config_path = team_config_path(project_root);
175 if config_path.exists() {
176 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
177 let from_role = resolve_role_name(project_root, &from);
178 let to_role = resolve_role_name(project_root, &recipient);
179 if !team_config.can_talk(&from_role, &to_role) {
180 bail!(
181 "{from} ({from_role}) is not allowed to assign {recipient} ({to_role}). \
182 Check talks_to in team.yaml."
183 );
184 }
185 }
186 }
187
188 let root = inbox::inboxes_root(project_root);
189 let msg = inbox::InboxMessage::new_assign(&from, &recipient, task);
190 let id = inbox::deliver_to_inbox(&root, &msg)?;
191 info!(from, engineer = %recipient, task, id = %id, "assignment delivered to inbox");
192 Ok(id)
193}
194
195pub fn list_inbox(project_root: &Path, member: &str, limit: Option<usize>) -> Result<()> {
197 let member = resolve_member_name(project_root, member)?;
198 let root = inbox::inboxes_root(project_root);
199 let messages = inbox::all_messages(&root, &member)?;
200 print!("{}", format_inbox_listing(&member, &messages, limit));
201 Ok(())
202}
203
204fn format_inbox_listing(
205 member: &str,
206 messages: &[(inbox::InboxMessage, bool)],
207 limit: Option<usize>,
208) -> String {
209 if messages.is_empty() {
210 return format!("No messages for {member}.\n");
211 }
212
213 let start = match limit {
214 Some(0) => messages.len(),
215 Some(n) => messages.len().saturating_sub(n),
216 None => 0,
217 };
218 let shown = &messages[start..];
219 let refs = inbox_message_refs(messages);
220 let shown_refs = &refs[start..];
221
222 let mut out = String::new();
223 if shown.len() < messages.len() {
224 out.push_str(&format!(
225 "Showing {} of {} messages for {member}. Use `-n <N>` or `--all` to see more.\n",
226 shown.len(),
227 messages.len()
228 ));
229 }
230 out.push_str(&format!(
231 "{:<10} {:<12} {:<12} {:<14} BODY\n",
232 "STATUS", "FROM", "TYPE", "REF"
233 ));
234 out.push_str(&format!("{}\n", "-".repeat(96)));
235 for ((msg, delivered), msg_ref) in shown.iter().zip(shown_refs.iter()) {
236 let status = if *delivered { "delivered" } else { "pending" };
237 let body_short = truncate_chars(&msg.body, INBOX_BODY_PREVIEW_CHARS);
238 out.push_str(&format!(
239 "{:<10} {:<12} {:<12} {:<14} {}\n",
240 status,
241 msg.from,
242 format!("{:?}", msg.msg_type).to_lowercase(),
243 msg_ref,
244 body_short,
245 ));
246 }
247 out
248}
249
250fn inbox_message_refs(messages: &[(inbox::InboxMessage, bool)]) -> Vec<String> {
251 let mut totals = HashMap::new();
252 for (msg, _) in messages {
253 *totals.entry(msg.timestamp).or_insert(0usize) += 1;
254 }
255
256 let mut seen = HashMap::new();
257 messages
258 .iter()
259 .map(|(msg, _)| {
260 let ordinal = seen.entry(msg.timestamp).or_insert(0usize);
261 *ordinal += 1;
262 if totals.get(&msg.timestamp).copied().unwrap_or(0) <= 1 {
263 msg.timestamp.to_string()
264 } else {
265 format!("{}-{}", msg.timestamp, ordinal)
266 }
267 })
268 .collect()
269}
270
271fn resolve_inbox_message_indices(
272 messages: &[(inbox::InboxMessage, bool)],
273 selector: &str,
274) -> Vec<usize> {
275 let refs = inbox_message_refs(messages);
276 messages
277 .iter()
278 .enumerate()
279 .filter_map(|(idx, (msg, _))| {
280 if msg.id == selector || msg.id.starts_with(selector) || refs[idx] == selector {
281 Some(idx)
282 } else {
283 None
284 }
285 })
286 .collect()
287}
288
289fn truncate_chars(input: &str, max_chars: usize) -> String {
290 if input.chars().count() <= max_chars {
291 return input.to_string();
292 }
293 let mut truncated: String = input.chars().take(max_chars).collect();
294 truncated.push_str("...");
295 truncated
296}
297
298pub fn read_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
300 let member = resolve_member_name(project_root, member)?;
301 let root = inbox::inboxes_root(project_root);
302 let messages = inbox::all_messages(&root, &member)?;
303
304 let matching = resolve_inbox_message_indices(&messages, id);
305
306 match matching.len() {
307 0 => bail!("no message matching '{id}' in {member}'s inbox"),
308 1 => {
309 let (msg, delivered) = &messages[matching[0]];
310 let status = if *delivered { "delivered" } else { "pending" };
311 println!("ID: {}", msg.id);
312 println!("From: {}", msg.from);
313 println!("To: {}", msg.to);
314 println!("Type: {:?}", msg.msg_type);
315 println!("Status: {status}");
316 println!("Time: {}", msg.timestamp);
317 println!();
318 println!("{}", msg.body);
319 }
320 n => {
321 bail!(
322 "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
323 );
324 }
325 }
326
327 Ok(())
328}
329
330pub fn ack_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
332 let member = resolve_member_name(project_root, member)?;
333 let root = inbox::inboxes_root(project_root);
334 let messages = inbox::all_messages(&root, &member)?;
335 let matching = resolve_inbox_message_indices(&messages, id);
336 let resolved_id = match matching.len() {
337 0 => bail!("no message matching '{id}' in {member}'s inbox"),
338 1 => messages[matching[0]].0.id.clone(),
339 n => bail!(
340 "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
341 ),
342 };
343 inbox::mark_delivered(&root, &member, &resolved_id)?;
344 info!(member, id = %resolved_id, "message acknowledged");
345 Ok(())
346}
347
348pub fn purge_inbox(
350 project_root: &Path,
351 member: Option<&str>,
352 all_roles: bool,
353 before: Option<u64>,
354 purge_all: bool,
355) -> Result<inbox::InboxPurgeSummary> {
356 if !purge_all && before.is_none() {
357 bail!("use `--all` or `--before <unix-timestamp>` with `batty inbox purge`");
358 }
359
360 let root = inbox::inboxes_root(project_root);
361 if all_roles {
362 return inbox::purge_delivered_messages_for_all(&root, before, purge_all);
363 }
364
365 let member = member.context("member is required unless using `--all-roles`")?;
366 let member = resolve_member_name(project_root, member)?;
367 let messages = inbox::purge_delivered_messages(&root, &member, before, purge_all)?;
368 Ok(inbox::InboxPurgeSummary { roles: 1, messages })
369}
370
371pub fn merge_worktree(project_root: &Path, engineer: &str) -> Result<()> {
373 let engineer = resolve_member_name(project_root, engineer)?;
374 match merge::merge_engineer_branch(project_root, &engineer)? {
375 merge::MergeOutcome::Success => Ok(()),
376 merge::MergeOutcome::RebaseConflict(stderr) => {
377 bail!("merge blocked by rebase conflict: {stderr}")
378 }
379 merge::MergeOutcome::MergeFailure(stderr) => bail!("merge failed: {stderr}"),
380 }
381}
382
383pub fn setup_telegram(project_root: &Path) -> Result<()> {
385 telegram::setup_telegram(project_root)
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::team::{board, inbox, team_config_dir, team_config_path};
392 use serial_test::serial;
393
394 struct EnvVarGuard {
395 key: &'static str,
396 original: Option<String>,
397 }
398
399 impl EnvVarGuard {
400 fn unset(key: &'static str) -> Self {
401 let original = std::env::var(key).ok();
402 unsafe {
403 std::env::remove_var(key);
404 }
405 Self { key, original }
406 }
407 }
408
409 impl Drop for EnvVarGuard {
410 fn drop(&mut self) {
411 match self.original.as_deref() {
412 Some(value) => unsafe {
413 std::env::set_var(self.key, value);
414 },
415 None => unsafe {
416 std::env::remove_var(self.key);
417 },
418 }
419 }
420 }
421
422 fn write_team_config(project_root: &Path, yaml: &str) {
423 std::fs::create_dir_all(team_config_dir(project_root)).unwrap();
424 std::fs::write(team_config_path(project_root), yaml).unwrap();
425 }
426
427 #[test]
428 fn send_message_delivers_to_inbox() {
429 let tmp = tempfile::tempdir().unwrap();
430 let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
431 let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
432 send_message(tmp.path(), "architect", "hello").unwrap();
433
434 let root = inbox::inboxes_root(tmp.path());
435 let pending = inbox::pending_messages(&root, "architect").unwrap();
436 assert_eq!(pending.len(), 1);
437 let expected_from = effective_sender(tmp.path(), None);
438 assert_eq!(pending[0].from, expected_from);
439 assert_eq!(pending[0].to, "architect");
440 assert_eq!(pending[0].body, "hello");
441 }
442
443 #[test]
444 fn send_message_ingests_completion_packet_into_workflow_metadata() {
445 let tmp = tempfile::tempdir().unwrap();
446 let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
447 std::fs::create_dir_all(&tasks_dir).unwrap();
448 let task_path = tasks_dir.join("027-completion-packets.md");
449 std::fs::write(
450 &task_path,
451 "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: human\nclass: standard\n---\n\nTask body.\n",
452 )
453 .unwrap();
454
455 send_message(
456 tmp.path(),
457 "architect",
458 r#"Done.
459
460## Completion Packet
461
462```json
463{"task_id":27,"branch":"eng-1-4/task-27","worktree_path":".batty/worktrees/eng-1-4","commit":"abc1234","changed_paths":["src/team/completion.rs"],"tests_run":true,"tests_passed":true,"artifacts":["docs/workflow.md"],"outcome":"ready_for_review"}
464```"#,
465 )
466 .unwrap();
467
468 let metadata = board::read_workflow_metadata(&task_path).unwrap();
469 assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
470 assert_eq!(
471 metadata.worktree_path.as_deref(),
472 Some(".batty/worktrees/eng-1-4")
473 );
474 assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
475 assert_eq!(metadata.tests_run, Some(true));
476 assert_eq!(metadata.tests_passed, Some(true));
477 assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
478 assert!(metadata.review_blockers.is_empty());
479 }
480
481 #[test]
482 fn assign_task_delivers_to_inbox() {
483 let tmp = tempfile::tempdir().unwrap();
484 let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
485 let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
486 let id = assign_task(tmp.path(), "eng-1-1", "fix bug").unwrap();
487 assert!(!id.is_empty());
488
489 let root = inbox::inboxes_root(tmp.path());
490 let pending = inbox::pending_messages(&root, "eng-1-1").unwrap();
491 assert_eq!(pending.len(), 1);
492 let expected_from = effective_sender(tmp.path(), None);
493 assert_eq!(pending[0].from, expected_from);
494 assert_eq!(pending[0].to, "eng-1-1");
495 assert_eq!(pending[0].body, "fix bug");
496 assert_eq!(pending[0].msg_type, inbox::MessageType::Assign);
497 }
498
499 #[test]
500 #[serial]
501 fn send_message_ignores_detected_sender_outside_project() {
502 let tmp = tempfile::tempdir().unwrap();
503 let _member = EnvVarGuard::unset("TMUX_PANE");
504 let original_member = std::env::var("BATTY_MEMBER").ok();
505 unsafe {
506 std::env::set_var("BATTY_MEMBER", "foreign-engineer-9-9");
507 }
508
509 send_message(tmp.path(), "architect", "hello").unwrap();
510
511 match original_member.as_deref() {
512 Some(value) => unsafe {
513 std::env::set_var("BATTY_MEMBER", value);
514 },
515 None => unsafe {
516 std::env::remove_var("BATTY_MEMBER");
517 },
518 }
519
520 let root = inbox::inboxes_root(tmp.path());
521 let pending = inbox::pending_messages(&root, "architect").unwrap();
522 assert_eq!(pending.len(), 1);
523 assert_eq!(pending[0].from, "human");
524 }
525
526 #[test]
527 fn resolve_member_name_maps_unique_role_alias_to_instance() {
528 let tmp = tempfile::tempdir().unwrap();
529 write_team_config(
530 tmp.path(),
531 r#"
532name: test
533roles:
534 - name: human
535 role_type: user
536 talks_to:
537 - sam-designer
538 - name: jordan-pm
539 role_type: manager
540 agent: claude
541 instances: 1
542 - name: sam-designer
543 role_type: engineer
544 agent: codex
545 instances: 1
546 talks_to:
547 - jordan-pm
548"#,
549 );
550
551 assert_eq!(
552 resolve_member_name(tmp.path(), "sam-designer").unwrap(),
553 "sam-designer-1-1"
554 );
555 assert_eq!(
556 resolve_member_name(tmp.path(), "sam-designer-1-1").unwrap(),
557 "sam-designer-1-1"
558 );
559 }
560
561 #[test]
562 fn resolve_member_name_rejects_ambiguous_role_alias() {
563 let tmp = tempfile::tempdir().unwrap();
564 write_team_config(
565 tmp.path(),
566 r#"
567name: test
568roles:
569 - name: jordan-pm
570 role_type: manager
571 agent: claude
572 instances: 2
573 - name: sam-designer
574 role_type: engineer
575 agent: codex
576 instances: 1
577 talks_to:
578 - jordan-pm
579"#,
580 );
581
582 let error = resolve_member_name(tmp.path(), "sam-designer")
583 .unwrap_err()
584 .to_string();
585 assert!(error.contains("matches multiple members"));
586 assert!(error.contains("sam-designer-1-1"));
587 assert!(error.contains("sam-designer-2-1"));
588 }
589
590 #[test]
591 #[serial]
592 fn send_message_delivers_to_unique_instance_inbox() {
593 let tmp = tempfile::tempdir().unwrap();
594 let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
595 let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
596 write_team_config(
597 tmp.path(),
598 r#"
599name: test
600roles:
601 - name: human
602 role_type: user
603 talks_to:
604 - sam-designer
605 - name: jordan-pm
606 role_type: manager
607 agent: claude
608 instances: 1
609 - name: sam-designer
610 role_type: engineer
611 agent: codex
612 instances: 1
613 talks_to:
614 - jordan-pm
615"#,
616 );
617
618 let original_tmux_pane = std::env::var_os("TMUX_PANE");
619 unsafe {
620 std::env::remove_var("TMUX_PANE");
621 }
622 let send_result = send_message(tmp.path(), "sam-designer", "hello");
623 match original_tmux_pane {
624 Some(value) => unsafe {
625 std::env::set_var("TMUX_PANE", value);
626 },
627 None => unsafe {
628 std::env::remove_var("TMUX_PANE");
629 },
630 }
631 send_result.unwrap();
632
633 let root = inbox::inboxes_root(tmp.path());
634 assert!(
635 inbox::pending_messages(&root, "sam-designer")
636 .unwrap()
637 .is_empty()
638 );
639
640 let pending = inbox::pending_messages(&root, "sam-designer-1-1").unwrap();
641 assert_eq!(pending.len(), 1);
642 assert_eq!(pending[0].to, "sam-designer-1-1");
643 assert_eq!(pending[0].body, "hello");
644 }
645
646 #[test]
647 fn truncate_chars_handles_unicode_boundaries() {
648 let body = "Task #109 confirmed complete on main. I'm available for next assignment.";
649 let truncated = truncate_chars(body, 40);
650 assert!(truncated.ends_with("..."));
651 assert!(truncated.starts_with("Task #109 confirmed complete on main."));
652 }
653
654 #[test]
655 fn format_inbox_listing_shows_most_recent_messages_by_default_limit() {
656 let messages: Vec<_> = (0..25)
657 .map(|idx| {
658 (
659 inbox::InboxMessage {
660 id: format!("msg{idx:05}"),
661 from: "architect".to_string(),
662 to: "black-lead".to_string(),
663 body: format!("message {idx}"),
664 msg_type: inbox::MessageType::Send,
665 timestamp: idx,
666 },
667 true,
668 )
669 })
670 .collect();
671
672 let rendered = format_inbox_listing("black-lead", &messages, Some(20));
673 assert!(rendered.contains("Showing 20 of 25 messages for black-lead."));
674 assert!(!rendered.contains("message 0"));
675 assert!(rendered.contains("message 5"));
676 assert!(rendered.contains("message 24"));
677 assert!(!rendered.contains("msg00005"));
678 assert!(!rendered.contains("msg00024"));
679 }
680
681 #[test]
682 fn format_inbox_listing_allows_showing_all_messages() {
683 let messages: Vec<_> = (0..3)
684 .map(|idx| {
685 (
686 inbox::InboxMessage {
687 id: format!("msg{idx:05}"),
688 from: "architect".to_string(),
689 to: "black-lead".to_string(),
690 body: format!("message {idx}"),
691 msg_type: inbox::MessageType::Send,
692 timestamp: idx,
693 },
694 idx % 2 == 0,
695 )
696 })
697 .collect();
698
699 let rendered = format_inbox_listing("black-lead", &messages, None);
700 assert!(!rendered.contains("Showing 20"));
701 assert!(rendered.contains("REF"));
702 assert!(rendered.contains("BODY"));
703 assert!(rendered.contains("message 0"));
704 assert!(rendered.contains("message 1"));
705 assert!(rendered.contains("message 2"));
706 assert!(!rendered.contains("msg00000"));
707 assert!(!rendered.contains("msg00001"));
708 assert!(!rendered.contains("msg00002"));
709 }
710
711 #[test]
712 fn format_inbox_listing_hides_internal_message_ids() {
713 let messages = vec![(
714 inbox::InboxMessage {
715 id: "1773930387654321.M123456P7890Q42.example".to_string(),
716 from: "architect".to_string(),
717 to: "black-lead".to_string(),
718 body: "message body".to_string(),
719 msg_type: inbox::MessageType::Send,
720 timestamp: 1_773_930_725,
721 },
722 true,
723 )];
724
725 let rendered = format_inbox_listing("black-lead", &messages, None);
726 assert!(rendered.contains("1773930725"));
727 assert!(!rendered.contains("1773930387654321.M123456P7890Q42.example"));
728 assert!(!rendered.contains("ID BODY"));
729 }
730
731 #[test]
732 fn inbox_message_refs_use_timestamp_when_unique() {
733 let messages = vec![(
734 inbox::InboxMessage {
735 id: "msg-1".to_string(),
736 from: "architect".to_string(),
737 to: "black-lead".to_string(),
738 body: "message body".to_string(),
739 msg_type: inbox::MessageType::Send,
740 timestamp: 1_773_930_725,
741 },
742 true,
743 )];
744
745 let refs = inbox_message_refs(&messages);
746 assert_eq!(refs, vec!["1773930725".to_string()]);
747 assert_eq!(
748 resolve_inbox_message_indices(&messages, "1773930725"),
749 vec![0]
750 );
751 }
752
753 #[test]
754 fn inbox_message_refs_suffix_same_second_collisions() {
755 let messages = vec![
756 (
757 inbox::InboxMessage {
758 id: "msg-1".to_string(),
759 from: "architect".to_string(),
760 to: "black-lead".to_string(),
761 body: "first".to_string(),
762 msg_type: inbox::MessageType::Send,
763 timestamp: 1_773_930_725,
764 },
765 true,
766 ),
767 (
768 inbox::InboxMessage {
769 id: "msg-2".to_string(),
770 from: "architect".to_string(),
771 to: "black-lead".to_string(),
772 body: "second".to_string(),
773 msg_type: inbox::MessageType::Send,
774 timestamp: 1_773_930_725,
775 },
776 true,
777 ),
778 ];
779
780 let refs = inbox_message_refs(&messages);
781 assert_eq!(
782 refs,
783 vec!["1773930725-1".to_string(), "1773930725-2".to_string()]
784 );
785 assert!(resolve_inbox_message_indices(&messages, "1773930725").is_empty());
786 assert_eq!(
787 resolve_inbox_message_indices(&messages, "1773930725-1"),
788 vec![0]
789 );
790 assert_eq!(
791 resolve_inbox_message_indices(&messages, "1773930725-2"),
792 vec![1]
793 );
794 }
795}