1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
6pub struct ToolDisplayMeta {
7 pub title: String,
8 pub value: String,
9}
10
11impl ToolDisplayMeta {
12 pub fn new(title: impl Into<String>, value: impl Into<String>) -> Self {
13 Self { title: title.into(), value: value.into() }
14 }
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
19pub struct FileDiff {
20 pub path: String,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub old_text: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub new_text: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
30pub struct PlanMeta {
31 pub entries: Vec<PlanMetaEntry>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
36pub struct PlanMetaEntry {
37 pub content: String,
38 pub status: PlanMetaStatus,
39}
40
41#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, JsonSchema)]
43#[serde(rename_all = "snake_case")]
44pub enum PlanMetaStatus {
45 Pending,
46 InProgress,
47 Completed,
48 Cancelled,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
53pub struct ToolResultMeta {
54 pub display: ToolDisplayMeta,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub file_diff: Option<FileDiff>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub plan: Option<PlanMeta>,
59}
60
61impl From<ToolDisplayMeta> for ToolResultMeta {
62 fn from(display: ToolDisplayMeta) -> Self {
63 Self::new(display)
64 }
65}
66
67impl ToolResultMeta {
68 pub fn new(display: ToolDisplayMeta) -> Self {
69 Self { display, file_diff: None, plan: None }
70 }
71
72 pub fn with_plan(display: ToolDisplayMeta, plan: PlanMeta) -> Self {
73 Self { display, file_diff: None, plan: Some(plan) }
74 }
75
76 pub fn with_file_diff(display: ToolDisplayMeta, file_diff: FileDiff) -> Self {
77 Self { display, file_diff: Some(file_diff), plan: None }
78 }
79}
80
81pub fn truncate(s: &str, max_length: usize) -> String {
82 if s.chars().count() <= max_length {
83 s.to_string()
84 } else {
85 let mut truncated = s.chars().take(max_length.saturating_sub(3)).collect::<String>();
86 truncated.push_str("...");
87 truncated
88 }
89}
90
91pub fn basename(path: &str) -> String {
92 let platform_basename = std::path::Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
93
94 if platform_basename.contains('\\') {
95 path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
96 } else {
97 platform_basename.to_string()
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 fn display(title: &str, value: &str) -> ToolDisplayMeta {
106 ToolDisplayMeta::new(title, value)
107 }
108
109 fn assert_serde_roundtrip<T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug>(val: &T) {
110 let json = serde_json::to_string(val).unwrap();
111 let parsed: T = serde_json::from_str(&json).unwrap();
112 assert_eq!(&parsed, val);
113 }
114
115 fn sample_diff(old_text: Option<&str>) -> FileDiff {
116 FileDiff {
117 path: "/tmp/main.rs".to_string(),
118 old_text: old_text.map(str::to_string),
119 new_text: Some("new content".to_string()),
120 }
121 }
122
123 fn sample_plan() -> PlanMeta {
124 PlanMeta {
125 entries: vec![
126 PlanMetaEntry { content: "Research AI agents".into(), status: PlanMetaStatus::Completed },
127 PlanMetaEntry { content: "Implement tracking".into(), status: PlanMetaStatus::InProgress },
128 PlanMetaEntry { content: "Write tests".into(), status: PlanMetaStatus::Pending },
129 ],
130 }
131 }
132
133 #[test]
134 fn test_new_sets_title_and_value() {
135 let meta = display("Read file", "Cargo.toml, 156 lines");
136 assert_eq!(meta.title, "Read file");
137 assert_eq!(meta.value, "Cargo.toml, 156 lines");
138 }
139
140 #[test]
141 fn test_serde_json_shape() {
142 let json = serde_json::to_value(display("Read file", "Cargo.toml")).unwrap();
143 assert_eq!(json["title"], "Read file");
144 assert_eq!(json["value"], "Cargo.toml");
145 }
146
147 #[test]
148 fn test_serde_roundtrips() {
149 assert_serde_roundtrip(&display("Grep", "'TODO' in src (42 matches)"));
150 assert_serde_roundtrip(&sample_diff(Some("old content")));
151 assert_serde_roundtrip(&sample_plan());
152
153 let result_meta: ToolResultMeta = display("Read file", "Cargo.toml, 156 lines").into();
154 assert_serde_roundtrip(&result_meta);
155 }
156
157 #[test]
158 fn test_into_result_meta() {
159 let d = display("Write file", "main.rs");
160 let meta: ToolResultMeta = d.clone().into();
161 assert_eq!(meta, ToolResultMeta { display: d, file_diff: None, plan: None });
162 }
163
164 #[test]
165 fn test_optional_fields_omitted_when_none() {
166 let diff_json = serde_json::to_value(sample_diff(None)).unwrap();
167 assert!(diff_json.get("old_text").is_none());
168
169 let meta_json = serde_json::to_value::<ToolResultMeta>(display("Read", "f.rs").into()).unwrap();
170 assert!(meta_json.get("plan").is_none());
171 assert!(meta_json.get("file_diff").is_none());
172 }
173
174 #[test]
175 fn empty_and_deleted_file_snapshots_remain_distinct() {
176 let empty: FileDiff = serde_json::from_value(serde_json::json!({
177 "path": "/tmp/file", "old_text": "old", "new_text": ""
178 }))
179 .unwrap();
180 let deleted: FileDiff = serde_json::from_value(serde_json::json!({
181 "path": "/tmp/file", "old_text": "old"
182 }))
183 .unwrap();
184 assert_eq!(empty.new_text.as_deref(), Some(""));
185 assert_eq!(deleted.new_text, None);
186 assert_serde_roundtrip(&empty);
187 assert_serde_roundtrip(&deleted);
188 assert_serde_roundtrip(&PlanMetaStatus::Cancelled);
189 }
190
191 #[test]
192 fn test_file_diff_missing_old_text_defaults_to_none() {
193 let parsed: FileDiff = serde_json::from_str(r#"{"path":"/tmp/f.rs","new_text":"content"}"#).unwrap();
194 assert_eq!(parsed.old_text, None);
195 }
196
197 #[test]
198 fn test_truncate() {
199 assert_eq!(truncate("short", 10), "short");
200
201 let long = truncate("cargo check --message-format=json --locked", 20);
202 assert!(long.chars().count() <= 20);
203 assert!(long.ends_with("..."));
204
205 let multibyte = truncate("こんにちは世界テスト文字列", 8);
206 assert_eq!(multibyte.chars().count(), 8);
207 assert!(multibyte.ends_with("..."));
208 }
209
210 #[test]
211 fn test_basename() {
212 for (path, expected) in [
213 ("/Users/josh/code/aether/Cargo.toml", "Cargo.toml"),
214 (r"C:\Users\josh\code\aether\Cargo.toml", "Cargo.toml"),
215 ("Cargo.toml", "Cargo.toml"),
216 ] {
217 assert_eq!(basename(path), expected, "path: {path}");
218 }
219 }
220
221 #[test]
222 fn test_plan_meta_status_serde_snake_case() {
223 let json = serde_json::to_value(PlanMetaStatus::InProgress).unwrap();
224 assert_eq!(json, serde_json::Value::String("in_progress".to_string()));
225 }
226}