Skip to main content

cpex_core/cmf/
content.rs

1// Location: ./crates/cpex-core/src/cmf/content.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// CMF domain objects and ContentPart hierarchy.
7//
8// Domain objects (ToolCall, Resource, etc.) are standalone structs
9// reusable outside of message content parts. ContentPart is a tagged
10// enum that wraps them for message serialization.
11//
12// Mirrors the Python types in cpex/framework/cmf/message.py.
13
14use std::collections::HashMap;
15
16use serde::{Deserialize, Serialize};
17
18use super::enums::ResourceType;
19use super::message::Message;
20
21// ---------------------------------------------------------------------------
22// Domain Objects
23// ---------------------------------------------------------------------------
24
25/// Normalized tool/function invocation request.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ToolCall {
28    /// Unique request correlation ID.
29    pub tool_call_id: String,
30    /// Tool name.
31    pub name: String,
32    /// Arguments as a JSON-serializable map.
33    #[serde(default)]
34    pub arguments: HashMap<String, serde_json::Value>,
35    /// Optional namespace for namespaced tools.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub namespace: Option<String>,
38}
39
40/// Result from tool execution.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ToolResult {
43    /// Correlation ID linking to the corresponding tool call.
44    pub tool_call_id: String,
45    /// Name of the tool that was executed.
46    pub tool_name: String,
47    /// Result content (any JSON-serializable value).
48    #[serde(default)]
49    pub content: serde_json::Value,
50    /// Whether the result represents an error.
51    #[serde(default)]
52    pub is_error: bool,
53}
54
55/// Embedded resource with content (MCP).
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct Resource {
58    /// Unique request correlation ID.
59    pub resource_request_id: String,
60    /// Unique identifier in URI format.
61    pub uri: String,
62    /// Human-readable name.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub name: Option<String>,
65    /// What this resource contains.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub description: Option<String>,
68    /// The kind of resource.
69    pub resource_type: ResourceType,
70    /// Text content if embedded.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub content: Option<String>,
73    /// Binary content if embedded (base64 in JSON).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub blob: Option<Vec<u8>>,
76    /// MIME type of content.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub mime_type: Option<String>,
79    /// Size information.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub size_bytes: Option<u64>,
82    /// Metadata (classification, retention, etc.).
83    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
84    pub annotations: HashMap<String, serde_json::Value>,
85    /// Version tracking.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub version: Option<String>,
88}
89
90impl Resource {
91    /// Whether content or blob is embedded.
92    pub fn is_embedded(&self) -> bool {
93        self.content.is_some() || self.blob.is_some()
94    }
95
96    /// Get text content if available.
97    pub fn get_text_content(&self) -> Option<&str> {
98        self.content.as_deref()
99    }
100}
101
102/// Lightweight resource reference without embedded content.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ResourceReference {
105    /// Correlation ID linking to the originating resource request.
106    pub resource_request_id: String,
107    /// Resource URI.
108    pub uri: String,
109    /// Human-readable name.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub name: Option<String>,
112    /// Type of resource.
113    pub resource_type: ResourceType,
114    /// Line number or byte offset for partial references.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub range_start: Option<u64>,
117    /// End of range.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub range_end: Option<u64>,
120    /// CSS/XPath/JSONPath selector.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub selector: Option<String>,
123}
124
125/// Prompt template invocation request (MCP).
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct PromptRequest {
128    /// Request ID for correlation.
129    pub prompt_request_id: String,
130    /// Prompt template name.
131    pub name: String,
132    /// Arguments to pass to the template.
133    #[serde(default)]
134    pub arguments: HashMap<String, serde_json::Value>,
135    /// Source server for multi-server scenarios.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub server_id: Option<String>,
138}
139
140/// Rendered prompt template result.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct PromptResult {
143    /// ID of the corresponding prompt request.
144    pub prompt_request_id: String,
145    /// Name of the prompt that was rendered.
146    pub prompt_name: String,
147    /// Rendered messages (prompts produce messages).
148    #[serde(default)]
149    pub messages: Vec<Message>,
150    /// Single text result for simple prompts.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub content: Option<String>,
153    /// Whether rendering failed.
154    #[serde(default)]
155    pub is_error: bool,
156    /// Error details if rendering failed.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub error_message: Option<String>,
159}
160
161// ---------------------------------------------------------------------------
162// Media Source Types
163// ---------------------------------------------------------------------------
164
165/// Image source data.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct ImageSource {
168    /// Source type: "url" or "base64".
169    #[serde(rename = "type")]
170    pub source_type: String,
171    /// URL or base64-encoded string.
172    pub data: String,
173    /// MIME type (e.g., image/jpeg).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub media_type: Option<String>,
176}
177
178/// Video source data.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct VideoSource {
181    /// Source type: "url" or "base64".
182    #[serde(rename = "type")]
183    pub source_type: String,
184    /// URL or base64-encoded string.
185    pub data: String,
186    /// MIME type (e.g., video/mp4).
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub media_type: Option<String>,
189    /// Duration in milliseconds.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub duration_ms: Option<u64>,
192}
193
194/// Audio source data.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct AudioSource {
197    /// Source type: "url" or "base64".
198    #[serde(rename = "type")]
199    pub source_type: String,
200    /// URL or base64-encoded string.
201    pub data: String,
202    /// MIME type (e.g., audio/mp3).
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub media_type: Option<String>,
205    /// Duration in milliseconds.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub duration_ms: Option<u64>,
208}
209
210/// Document source data (PDF, Word, etc.).
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct DocumentSource {
213    /// Source type: "url" or "base64".
214    #[serde(rename = "type")]
215    pub source_type: String,
216    /// URL or base64-encoded string.
217    pub data: String,
218    /// MIME type (e.g., application/pdf).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub media_type: Option<String>,
221    /// Document title.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub title: Option<String>,
224}
225
226// ---------------------------------------------------------------------------
227// ContentPart — Tagged Enum
228// ---------------------------------------------------------------------------
229
230/// A typed content part in a CMF message.
231///
232/// Discriminated by the `content_type` field. Each variant wraps
233/// either a text string or a domain object.
234///
235/// Mirrors the Python `ContentPartUnion` discriminated union in
236/// `cpex/framework/cmf/message.py`.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(tag = "content_type")]
239pub enum ContentPart {
240    /// Plain text content.
241    #[serde(rename = "text")]
242    Text { text: String },
243
244    /// Chain-of-thought reasoning.
245    #[serde(rename = "thinking")]
246    Thinking { text: String },
247
248    /// Tool/function invocation request.
249    #[serde(rename = "tool_call")]
250    ToolCall { content: ToolCall },
251
252    /// Result from tool execution.
253    #[serde(rename = "tool_result")]
254    ToolResult { content: ToolResult },
255
256    /// Embedded resource with content.
257    #[serde(rename = "resource")]
258    Resource { content: Resource },
259
260    /// Lightweight resource reference.
261    #[serde(rename = "resource_ref")]
262    ResourceRef { content: ResourceReference },
263
264    /// Prompt template invocation request.
265    #[serde(rename = "prompt_request")]
266    PromptRequest { content: PromptRequest },
267
268    /// Rendered prompt template result.
269    #[serde(rename = "prompt_result")]
270    PromptResult { content: PromptResult },
271
272    /// Image content.
273    #[serde(rename = "image")]
274    Image { content: ImageSource },
275
276    /// Video content.
277    #[serde(rename = "video")]
278    Video { content: VideoSource },
279
280    /// Audio content.
281    #[serde(rename = "audio")]
282    Audio { content: AudioSource },
283
284    /// Document content.
285    #[serde(rename = "document")]
286    Document { content: DocumentSource },
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_text_content_part_serde() {
295        let json = r#"{"content_type":"text","text":"Hello, world!"}"#;
296        let part: ContentPart = serde_json::from_str(json).unwrap();
297        match &part {
298            ContentPart::Text { text } => assert_eq!(text, "Hello, world!"),
299            _ => panic!("expected Text variant"),
300        }
301        let roundtrip = serde_json::to_string(&part).unwrap();
302        let part2: ContentPart = serde_json::from_str(&roundtrip).unwrap();
303        match part2 {
304            ContentPart::Text { text } => assert_eq!(text, "Hello, world!"),
305            _ => panic!("expected Text variant"),
306        }
307    }
308
309    #[test]
310    fn test_tool_call_content_part_serde() {
311        let json = r#"{
312            "content_type": "tool_call",
313            "content": {
314                "tool_call_id": "tc_001",
315                "name": "get_weather",
316                "arguments": {"city": "London"}
317            }
318        }"#;
319        let part: ContentPart = serde_json::from_str(json).unwrap();
320        match &part {
321            ContentPart::ToolCall { content } => {
322                assert_eq!(content.name, "get_weather");
323                assert_eq!(content.tool_call_id, "tc_001");
324                assert_eq!(content.arguments["city"], "London");
325            },
326            _ => panic!("expected ToolCall variant"),
327        }
328    }
329
330    #[test]
331    fn test_tool_result_content_part_serde() {
332        let json = r#"{
333            "content_type": "tool_result",
334            "content": {
335                "tool_call_id": "tc_001",
336                "tool_name": "get_weather",
337                "content": {"temp": 20, "unit": "C"},
338                "is_error": false
339            }
340        }"#;
341        let part: ContentPart = serde_json::from_str(json).unwrap();
342        match &part {
343            ContentPart::ToolResult { content } => {
344                assert_eq!(content.tool_name, "get_weather");
345                assert!(!content.is_error);
346            },
347            _ => panic!("expected ToolResult variant"),
348        }
349    }
350
351    #[test]
352    fn test_resource_content_part_serde() {
353        let json = r#"{
354            "content_type": "resource",
355            "content": {
356                "resource_request_id": "rr_001",
357                "uri": "file:///data.txt",
358                "resource_type": "file",
359                "content": "Hello from file"
360            }
361        }"#;
362        let part: ContentPart = serde_json::from_str(json).unwrap();
363        match &part {
364            ContentPart::Resource { content } => {
365                assert_eq!(content.uri, "file:///data.txt");
366                assert!(content.is_embedded());
367                assert_eq!(content.get_text_content(), Some("Hello from file"));
368            },
369            _ => panic!("expected Resource variant"),
370        }
371    }
372
373    #[test]
374    fn test_resource_ref_content_part_serde() {
375        let json = r#"{
376            "content_type": "resource_ref",
377            "content": {
378                "resource_request_id": "rr_002",
379                "uri": "db://users/42",
380                "resource_type": "database"
381            }
382        }"#;
383        let part: ContentPart = serde_json::from_str(json).unwrap();
384        match &part {
385            ContentPart::ResourceRef { content } => {
386                assert_eq!(content.uri, "db://users/42");
387                assert_eq!(content.resource_type, ResourceType::Database);
388            },
389            _ => panic!("expected ResourceRef variant"),
390        }
391    }
392
393    #[test]
394    fn test_image_content_part_serde() {
395        let json = r#"{
396            "content_type": "image",
397            "content": {
398                "type": "url",
399                "data": "https://example.com/photo.jpg",
400                "media_type": "image/jpeg"
401            }
402        }"#;
403        let part: ContentPart = serde_json::from_str(json).unwrap();
404        match &part {
405            ContentPart::Image { content } => {
406                assert_eq!(content.source_type, "url");
407                assert_eq!(content.data, "https://example.com/photo.jpg");
408            },
409            _ => panic!("expected Image variant"),
410        }
411    }
412
413    #[test]
414    fn test_prompt_request_content_part_serde() {
415        let json = r#"{
416            "content_type": "prompt_request",
417            "content": {
418                "prompt_request_id": "pr_001",
419                "name": "summarize",
420                "arguments": {"text": "Long document..."}
421            }
422        }"#;
423        let part: ContentPart = serde_json::from_str(json).unwrap();
424        match &part {
425            ContentPart::PromptRequest { content } => {
426                assert_eq!(content.name, "summarize");
427            },
428            _ => panic!("expected PromptRequest variant"),
429        }
430    }
431
432    #[test]
433    fn test_thinking_content_part_serde() {
434        let json = r#"{"content_type":"thinking","text":"Let me analyze..."}"#;
435        let part: ContentPart = serde_json::from_str(json).unwrap();
436        match &part {
437            ContentPart::Thinking { text } => assert_eq!(text, "Let me analyze..."),
438            _ => panic!("expected Thinking variant"),
439        }
440    }
441
442    #[test]
443    fn test_tool_call_construction() {
444        let tc = ToolCall {
445            tool_call_id: "tc_001".into(),
446            name: "search".into(),
447            arguments: [("query".to_string(), serde_json::json!("rust"))].into(),
448            namespace: None,
449        };
450        assert_eq!(tc.name, "search");
451        assert_eq!(tc.arguments["query"], "rust");
452    }
453
454    #[test]
455    fn test_resource_is_embedded() {
456        let embedded = Resource {
457            resource_request_id: "rr_001".into(),
458            uri: "file:///data.txt".into(),
459            name: None,
460            description: None,
461            resource_type: ResourceType::File,
462            content: Some("data".into()),
463            blob: None,
464            mime_type: None,
465            size_bytes: None,
466            annotations: HashMap::new(),
467            version: None,
468        };
469        assert!(embedded.is_embedded());
470
471        let not_embedded = Resource {
472            resource_request_id: "rr_002".into(),
473            uri: "file:///other.txt".into(),
474            name: None,
475            description: None,
476            resource_type: ResourceType::File,
477            content: None,
478            blob: None,
479            mime_type: None,
480            size_bytes: None,
481            annotations: HashMap::new(),
482            version: None,
483        };
484        assert!(!not_embedded.is_embedded());
485    }
486}