1use std::path::Path;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
16pub struct ToolDisplayMeta {
17 pub title: String,
18 pub value: String,
19}
20
21impl ToolDisplayMeta {
22 pub fn new(title: impl Into<String>, value: impl Into<String>) -> Self {
23 Self { title: title.into(), value: value.into() }
24 }
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
30pub struct FileDiff {
31 pub path: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub old_text: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub new_text: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
41pub struct PlanMeta {
42 pub entries: Vec<PlanMetaEntry>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
47pub struct PlanMetaEntry {
48 pub content: String,
49 pub status: PlanMetaStatus,
50}
51
52#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, JsonSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum PlanMetaStatus {
56 Pending,
57 InProgress,
58 Completed,
59 Cancelled,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
67pub struct ToolResultMeta {
68 pub display: ToolDisplayMeta,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub file_diff: Option<FileDiff>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub plan: Option<PlanMeta>,
73}
74
75impl From<ToolDisplayMeta> for ToolResultMeta {
76 fn from(display: ToolDisplayMeta) -> Self {
77 Self::new(display)
78 }
79}
80
81impl ToolResultMeta {
82 pub fn new(display: ToolDisplayMeta) -> Self {
84 Self { display, file_diff: None, plan: None }
85 }
86
87 pub fn with_plan(display: ToolDisplayMeta, plan: PlanMeta) -> Self {
89 Self { display, file_diff: None, plan: Some(plan) }
90 }
91
92 pub fn with_file_diff(display: ToolDisplayMeta, file_diff: FileDiff) -> Self {
94 Self { display, file_diff: Some(file_diff), plan: None }
95 }
96}
97
98pub fn extension_hint(path: &str) -> String {
100 Path::new(path).extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase()
101}
102
103impl ToolResultMeta {
104 pub fn into_map(self) -> serde_json::Map<String, serde_json::Value> {
106 match serde_json::to_value(self).expect("ToolResultMeta should serialize") {
107 serde_json::Value::Object(map) => map,
108 _ => unreachable!("ToolResultMeta should serialize to a JSON object"),
109 }
110 }
111
112 pub fn from_map(map: &serde_json::Map<String, serde_json::Value>) -> Option<Self> {
114 serde_json::from_value(serde_json::Value::Object(map.clone())).ok()
115 }
116}
117
118pub fn truncate(s: &str, max_length: usize) -> String {
122 if s.chars().count() <= max_length {
123 s.to_string()
124 } else {
125 let mut truncated = s.chars().take(max_length.saturating_sub(3)).collect::<String>();
126 truncated.push_str("...");
127 truncated
128 }
129}
130
131pub fn basename(path: &str) -> String {
133 let platform_basename = std::path::Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
134
135 if platform_basename.contains('\\') {
136 path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
137 } else {
138 platform_basename.to_string()
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 fn display(title: &str, value: &str) -> ToolDisplayMeta {
147 ToolDisplayMeta::new(title, value)
148 }
149
150 fn assert_serde_roundtrip<T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug>(val: &T) {
151 let json = serde_json::to_string(val).unwrap();
152 let parsed: T = serde_json::from_str(&json).unwrap();
153 assert_eq!(&parsed, val);
154 }
155
156 fn assert_map_roundtrip(meta: &ToolResultMeta) {
157 let map = meta.clone().into_map();
158 let parsed = ToolResultMeta::from_map(&map).expect("should deserialize");
159 assert_eq!(&parsed, meta);
160 }
161
162 fn sample_diff(old_text: Option<&str>) -> FileDiff {
163 FileDiff {
164 path: "/tmp/main.rs".to_string(),
165 old_text: old_text.map(str::to_string),
166 new_text: Some("new content".to_string()),
167 }
168 }
169
170 fn sample_plan() -> PlanMeta {
171 PlanMeta {
172 entries: vec![
173 PlanMetaEntry { content: "Research AI agents".into(), status: PlanMetaStatus::Completed },
174 PlanMetaEntry { content: "Implement tracking".into(), status: PlanMetaStatus::InProgress },
175 PlanMetaEntry { content: "Write tests".into(), status: PlanMetaStatus::Pending },
176 ],
177 }
178 }
179
180 #[test]
181 fn test_new_sets_title_and_value() {
182 let meta = display("Read file", "Cargo.toml, 156 lines");
183 assert_eq!(meta.title, "Read file");
184 assert_eq!(meta.value, "Cargo.toml, 156 lines");
185 }
186
187 #[test]
188 fn test_serde_json_shape() {
189 let json = serde_json::to_value(display("Read file", "Cargo.toml")).unwrap();
190 assert_eq!(json["title"], "Read file");
191 assert_eq!(json["value"], "Cargo.toml");
192 }
193
194 #[test]
195 fn test_serde_roundtrips() {
196 assert_serde_roundtrip(&display("Grep", "'TODO' in src (42 matches)"));
197 assert_serde_roundtrip(&sample_diff(Some("old content")));
198 assert_serde_roundtrip(&sample_plan());
199
200 let result_meta: ToolResultMeta = display("Read file", "Cargo.toml, 156 lines").into();
201 assert_serde_roundtrip(&result_meta);
202 }
203
204 #[test]
205 fn test_tool_result_meta_map_roundtrips() {
206 let plain: ToolResultMeta = display("Read file", "Cargo.toml, 156 lines").into();
207 assert_map_roundtrip(&plain);
208
209 let with_diff = ToolResultMeta::with_file_diff(display("Edit file", "main.rs"), sample_diff(Some("old")));
210 assert_map_roundtrip(&with_diff);
211
212 let with_plan = ToolResultMeta::with_plan(
213 display("Todo", "Research AI agents"),
214 PlanMeta {
215 entries: vec![PlanMetaEntry {
216 content: "Research AI agents".into(),
217 status: PlanMetaStatus::InProgress,
218 }],
219 },
220 );
221 assert_map_roundtrip(&with_plan);
222 }
223
224 #[test]
225 fn test_tool_result_meta_from_invalid_map_returns_none() {
226 let map = serde_json::Map::from_iter([(
227 "display".to_string(),
228 serde_json::Value::String("not an object".to_string()),
229 )]);
230 assert!(ToolResultMeta::from_map(&map).is_none());
231 }
232
233 #[test]
234 fn test_into_result_meta() {
235 let d = display("Write file", "main.rs");
236 let meta: ToolResultMeta = d.clone().into();
237 assert_eq!(meta, ToolResultMeta { display: d, file_diff: None, plan: None });
238 }
239
240 #[test]
241 fn test_optional_fields_omitted_when_none() {
242 let diff_json = serde_json::to_value(sample_diff(None)).unwrap();
243 assert!(diff_json.get("old_text").is_none());
244
245 let meta_json = serde_json::to_value::<ToolResultMeta>(display("Read", "f.rs").into()).unwrap();
246 assert!(meta_json.get("plan").is_none());
247 assert!(meta_json.get("file_diff").is_none());
248 }
249
250 #[test]
251 fn empty_and_deleted_file_snapshots_remain_distinct() {
252 let empty: FileDiff = serde_json::from_value(serde_json::json!({
253 "path": "/tmp/file", "old_text": "old", "new_text": ""
254 }))
255 .unwrap();
256 let deleted: FileDiff = serde_json::from_value(serde_json::json!({
257 "path": "/tmp/file", "old_text": "old"
258 }))
259 .unwrap();
260 assert_eq!(empty.new_text.as_deref(), Some(""));
261 assert_eq!(deleted.new_text, None);
262 assert_serde_roundtrip(&empty);
263 assert_serde_roundtrip(&deleted);
264 assert_serde_roundtrip(&PlanMetaStatus::Cancelled);
265 }
266
267 #[test]
268 fn test_file_diff_missing_old_text_defaults_to_none() {
269 let parsed: FileDiff = serde_json::from_str(r#"{"path":"/tmp/f.rs","new_text":"content"}"#).unwrap();
270 assert_eq!(parsed.old_text, None);
271 }
272
273 #[test]
274 fn test_extension_hint() {
275 for (path, expected) in
276 [("/path/to/main.rs", "rs"), ("README.MD", "md"), ("Makefile", ""), ("/foo/bar/baz.tsx", "tsx")]
277 {
278 assert_eq!(extension_hint(path), expected, "path: {path}");
279 }
280 }
281
282 #[test]
283 fn test_truncate() {
284 assert_eq!(truncate("short", 10), "short");
285
286 let long = truncate("cargo check --message-format=json --locked", 20);
287 assert!(long.chars().count() <= 20);
288 assert!(long.ends_with("..."));
289
290 let multibyte = truncate("こんにちは世界テスト文字列", 8);
291 assert_eq!(multibyte.chars().count(), 8);
292 assert!(multibyte.ends_with("..."));
293 }
294
295 #[test]
296 fn test_basename() {
297 for (path, expected) in [
298 ("/Users/josh/code/aether/Cargo.toml", "Cargo.toml"),
299 (r"C:\Users\josh\code\aether\Cargo.toml", "Cargo.toml"),
300 ("Cargo.toml", "Cargo.toml"),
301 ] {
302 assert_eq!(basename(path), expected, "path: {path}");
303 }
304 }
305
306 #[test]
307 fn test_plan_meta_status_serde_snake_case() {
308 let json = serde_json::to_value(PlanMetaStatus::InProgress).unwrap();
309 assert_eq!(json, serde_json::Value::String("in_progress".to_string()));
310 }
311}