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