Skip to main content

utils/
display_meta.rs

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