1use serde::{Deserialize, Serialize};
2
3use crate::enums::BindingProtocol;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[serde(tag = "status", rename_all = "snake_case")]
8pub enum ToolResult {
9 Success {
10 data: serde_json::Value,
11 metadata: ToolResultMetadata,
12 },
13 Error {
14 code: String,
15 message: String,
16 reason: Option<String>,
17 retryable: bool,
18 },
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31pub struct ToolResultMetadata {
32 pub tool_id: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub version: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub binding: Option<BindingProtocol>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub latency_ms: Option<u64>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub timestamp: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub request_id: Option<String>,
45}
46
47impl ToolResultMetadata {
48 pub fn for_tool(tool_id: impl Into<String>) -> Self {
52 Self {
53 tool_id: tool_id.into(),
54 version: None,
55 binding: None,
56 latency_ms: None,
57 timestamp: None,
58 request_id: None,
59 }
60 }
61}
62
63impl ToolResult {
64 pub fn is_success(&self) -> bool {
65 matches!(self, ToolResult::Success { .. })
66 }
67
68 pub fn is_retryable(&self) -> bool {
69 matches!(
70 self,
71 ToolResult::Error {
72 retryable: true,
73 ..
74 }
75 )
76 }
77
78 pub fn data(&self) -> Option<&serde_json::Value> {
79 match self {
80 ToolResult::Success { data, .. } => Some(data),
81 _ => None,
82 }
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 fn success() -> ToolResult {
91 ToolResult::Success {
92 data: serde_json::json!({"content": "hello"}),
93 metadata: ToolResultMetadata::for_tool("anos:fs.read"),
94 }
95 }
96
97 #[test]
98 fn success_roundtrip() {
99 let r = success();
100 let j = serde_json::to_string(&r).unwrap();
101 let back: ToolResult = serde_json::from_str(&j).unwrap();
102 assert!(back.is_success());
103 assert_eq!(back.data().unwrap()["content"], "hello");
104 }
105
106 #[test]
107 fn error_retryable() {
108 let r = ToolResult::Error {
109 code: "TIMEOUT".into(),
110 message: "timed out".into(),
111 reason: None,
112 retryable: true,
113 };
114 assert!(!r.is_success());
115 assert!(r.is_retryable());
116 }
117
118 #[test]
119 fn status_tag_uses_snake_case() {
120 let j = serde_json::to_string(&success()).unwrap();
121 assert!(j.contains("\"status\":\"success\""), "got: {j}");
122 }
123
124 #[test]
125 fn metadata_with_only_tool_id_serializes_without_null_fields() {
126 let m = ToolResultMetadata::for_tool("anos:fs.read");
127 let j = serde_json::to_string(&m).unwrap();
128 assert_eq!(j, r#"{"tool_id":"anos:fs.read"}"#);
129 }
130
131 #[test]
132 fn metadata_roundtrips_with_missing_optional_fields() {
133 let j = r#"{"tool_id":"anos:fs.read"}"#;
134 let m: ToolResultMetadata = serde_json::from_str(j).unwrap();
135 assert_eq!(m.tool_id, "anos:fs.read");
136 assert!(m.version.is_none());
137 assert!(m.binding.is_none());
138 assert!(m.latency_ms.is_none());
139 assert!(m.timestamp.is_none());
140 assert!(m.request_id.is_none());
141 }
142
143 #[test]
144 fn metadata_roundtrips_with_server_populated_fields() {
145 let j = r#"{"tool_id":"anos:fs.read","version":"0.1.2","binding":"Cli","latency_ms":7,"timestamp":"2026-04-21T10:00:00Z","request_id":"01HY..."}"#;
146 let m: ToolResultMetadata = serde_json::from_str(j).unwrap();
147 assert_eq!(m.tool_id, "anos:fs.read");
148 assert_eq!(m.version.as_deref(), Some("0.1.2"));
149 assert_eq!(m.binding, Some(BindingProtocol::Cli));
150 assert_eq!(m.latency_ms, Some(7));
151 assert!(m.timestamp.is_some());
152 assert!(m.request_id.is_some());
153 }
154}