Skip to main content

batty_cli/team/
messaging.rs

1//! Message routing, inbox operations, merge, and Telegram setup.
2
3use 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
13/// Resolve a member instance name (e.g. "eng-1-2") to its role definition name
14/// (e.g. "engineer"). Returns the name itself if no config is available.
15fn resolve_role_name(project_root: &Path, member_name: &str) -> String {
16    // "human" is not a member instance — it's the CLI user
17    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    // Fallback: the name might already be a role name
29    member_name.to_string()
30}
31
32/// Resolve a caller-facing role/member name to a concrete member instance.
33///
34/// Examples:
35/// - exact member names pass through unchanged (`sam-designer-1-1`)
36/// - unique role aliases resolve to their single member instance (`sam-designer`)
37/// - ambiguous aliases error and require an explicit member name
38pub(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
70/// Send a message to a role via their Maildir inbox.
71///
72/// The sender is auto-detected from the `@batty_role` tmux pane option
73/// (set during layout). Falls back to "human" if not in a batty pane.
74/// Enforces communication routing rules from team config.
75pub 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    // Enforce routing: check talks_to rules
89    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
113/// Detect who is calling `batty send` by reading the `@batty_role` option
114/// from the current tmux pane.
115pub(crate) fn detect_sender() -> Option<String> {
116    // 1. Check BATTY_MEMBER env var (set by SDK mode shim subprocess)
117    if let Ok(member) = std::env::var("BATTY_MEMBER") {
118        if !member.is_empty() {
119            return Some(member);
120        }
121    }
122
123    // 2. Fall back to tmux pane role detection (PTY mode)
124    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
169/// Assign a task to an engineer via their Maildir inbox.
170pub 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
195/// List inbox messages for a member.
196pub 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
298/// Read a specific message from a member's inbox by ID, ID prefix, or REF.
299pub 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
330/// Acknowledge (mark delivered) a message in a member's inbox by ID, prefix, or REF.
331pub 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
348/// Purge delivered messages from one inbox or all inboxes.
349pub 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
371/// Merge an engineer's worktree branch.
372pub 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
383/// Run the interactive Telegram setup wizard.
384pub 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}