Skip to main content

utils/
display_meta.rs

1use std::path::Path;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6/// Human-readable display metadata for a tool operation.
7#[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/// Full file contents for a diff
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
21pub struct FileDiff {
22    pub path: String,
23    /// Original file content (`None` for new files).
24    #[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/// A snapshot of the agent's current task plan.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
32pub struct PlanMeta {
33    pub entries: Vec<PlanMetaEntry>,
34}
35
36/// A single entry in a plan.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
38pub struct PlanMetaEntry {
39    pub content: String,
40    pub status: PlanMetaStatus,
41}
42
43/// Execution status of a plan entry.
44#[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/// Typed wrapper for the MCP `_meta` field on tool results.
54#[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}