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