Skip to main content

honcho_ai/
message.rs

1//! Message wrapper — construction, getters, custom Debug/Display.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::sync::Arc;
6
7use chrono::{DateTime, Utc};
8use serde_json::Value;
9
10use crate::types::message::MessageResponse;
11
12/// Max number of characters of message content rendered by the `Debug` impl
13/// before the output is truncated with an ellipsis.
14const DEBUG_CONTENT_MAX_CHARS: usize = 50;
15
16pub(crate) struct MessageInner {
17    workspace_id: String,
18    id: String,
19    content: String,
20    peer_id: String,
21    session_id: String,
22    metadata: HashMap<String, Value>,
23    created_at: DateTime<Utc>,
24    token_count: u64,
25}
26
27/// An enriched message in a Honcho workspace.
28///
29/// Wraps the raw [`MessageResponse`] with workspace context and provides
30/// convenient field accessors.
31#[derive(Clone)]
32pub struct Message {
33    inner: Arc<MessageInner>,
34}
35
36impl Message {
37    /// Wraps a raw [`MessageResponse`] into a [`Message`].
38    ///
39    /// The workspace identity is taken directly from the server response
40    /// (`resp.workspace_id`), which is authoritative.
41    pub(crate) fn from_raw(resp: MessageResponse) -> Self {
42        Self {
43            inner: Arc::new(MessageInner {
44                workspace_id: resp.workspace_id,
45                id: resp.id,
46                content: resp.content,
47                peer_id: resp.peer_id,
48                session_id: resp.session_id,
49                metadata: resp.metadata,
50                created_at: resp.created_at,
51                token_count: resp.token_count,
52            }),
53        }
54    }
55
56    /// The message's unique identifier.
57    #[must_use]
58    pub fn id(&self) -> &str {
59        &self.inner.id
60    }
61
62    /// The message content text.
63    #[must_use]
64    pub fn content(&self) -> &str {
65        &self.inner.content
66    }
67
68    /// ID of the peer that authored this message.
69    #[must_use]
70    pub fn peer_id(&self) -> &str {
71        &self.inner.peer_id
72    }
73
74    /// ID of the session this message belongs to.
75    #[must_use]
76    pub fn session_id(&self) -> &str {
77        &self.inner.session_id
78    }
79
80    /// Arbitrary key-value metadata attached to the message.
81    #[must_use]
82    pub fn metadata(&self) -> &HashMap<String, Value> {
83        &self.inner.metadata
84    }
85
86    /// When this message was created.
87    #[must_use]
88    pub fn created_at(&self) -> DateTime<Utc> {
89        self.inner.created_at
90    }
91
92    /// Token count for the message content.
93    #[must_use]
94    pub fn token_count(&self) -> u64 {
95        self.inner.token_count
96    }
97
98    /// The workspace this message belongs to.
99    #[must_use]
100    pub fn workspace_id(&self) -> &str {
101        &self.inner.workspace_id
102    }
103}
104
105/// Zero-allocation `Debug` adapter that renders at most
106/// [`DEBUG_CONTENT_MAX_CHARS`] characters of a string, appending an ellipsis
107/// when the content is longer. Avoids the per-`Debug` `format!` allocation that
108/// a `Cow::Owned` truncation would incur for long message content.
109struct Truncated<'a>(&'a str);
110
111impl fmt::Debug for Truncated<'_> {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self.0.char_indices().nth(DEBUG_CONTENT_MAX_CHARS) {
114            // `escape_debug` is an iterator-based `Display`, so this writes the
115            // quoted/escaped prefix directly into the formatter without
116            // allocating an intermediate `String`.
117            Some((idx, _)) => write!(f, "\"{}...\"", self.0[..idx].escape_debug()),
118            None => fmt::Debug::fmt(self.0, f),
119        }
120    }
121}
122
123impl fmt::Debug for Message {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.debug_struct("Message")
126            .field("id", &self.inner.id)
127            .field("content", &Truncated(&self.inner.content))
128            .field("peer_id", &self.inner.peer_id)
129            .field("session_id", &self.inner.session_id)
130            .finish_non_exhaustive()
131    }
132}
133
134impl fmt::Display for Message {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.write_str(&self.inner.content)
137    }
138}
139
140#[cfg(test)]
141#[allow(
142    clippy::unwrap_used,
143    clippy::expect_used,
144    clippy::panic,
145    clippy::unnecessary_wraps,
146    clippy::needless_pass_by_value,
147    clippy::unused_async
148)]
149mod tests {
150    use static_assertions::assert_impl_all;
151
152    use super::*;
153
154    assert_impl_all!(Message: Send, Sync, Clone, fmt::Debug, fmt::Display);
155
156    fn fake_response() -> MessageResponse {
157        MessageResponse {
158            id: "msg_1".to_owned(),
159            content: "hello world".to_owned(),
160            peer_id: "peer_a".to_owned(),
161            session_id: "sess_x".to_owned(),
162            metadata: HashMap::new(),
163            created_at: Utc::now(),
164            workspace_id: "ws_1".to_owned(),
165            token_count: 3,
166        }
167    }
168
169    #[test]
170    fn from_raw_maps_fields() {
171        let resp = fake_response();
172        let msg = Message::from_raw(resp);
173        assert_eq!(msg.id(), "msg_1");
174        assert_eq!(msg.content(), "hello world");
175        assert_eq!(msg.peer_id(), "peer_a");
176        assert_eq!(msg.session_id(), "sess_x");
177        assert_eq!(msg.workspace_id(), "ws_1");
178        assert_eq!(msg.token_count(), 3);
179        assert!(msg.metadata().is_empty());
180    }
181
182    #[test]
183    fn from_raw_stores_response_workspace_id() {
184        // The workspace identity is taken from the server response.
185        let mut resp = fake_response();
186        resp.workspace_id = "ws_from_server".to_owned();
187        let msg = Message::from_raw(resp);
188        assert_eq!(msg.workspace_id(), "ws_from_server");
189    }
190
191    #[test]
192    fn debug_truncates_long_content() {
193        let mut resp = fake_response();
194        resp.content = "a".repeat(80);
195        let msg = Message::from_raw(resp);
196        let dbg = format!("{msg:?}");
197        assert!(dbg.contains("aaa..."));
198        assert!(!dbg.contains(&"a".repeat(80)));
199    }
200
201    #[test]
202    fn debug_short_content_not_truncated() {
203        let resp = fake_response();
204        let msg = Message::from_raw(resp);
205        let dbg = format!("{msg:?}");
206        assert!(dbg.contains("hello world"));
207        assert!(!dbg.contains("..."));
208    }
209
210    #[test]
211    fn display_returns_full_content() {
212        let mut resp = fake_response();
213        resp.content = "a".repeat(80);
214        let msg = Message::from_raw(resp);
215        assert_eq!(format!("{msg}"), "a".repeat(80));
216    }
217
218    #[test]
219    fn debug_truncation_multibyte_utf8() {
220        let mut resp = fake_response();
221        resp.content = "\u{4e00}".repeat(60);
222        let msg = Message::from_raw(resp);
223        let dbg = format!("{msg:?}");
224        assert!(dbg.contains("..."));
225    }
226}