1use std::path::PathBuf;
25
26use serde_json::{Value, json};
27
28use lattice_agent::{AgentError, DiffReviewRequest, review_diff};
29use lattice_diff::ProgrammaticDiffBus;
30use lattice_diff::subsystem::DiffOutcome;
31
32pub async fn open_diff(
40 bus: Option<&ProgrammaticDiffBus>,
41 args: &Value,
42 conn_id: u64,
45 review: &crate::mcp::status::ReviewHandle,
49) -> Value {
50 let Some(bus) = bus else {
51 return error_result("openDiff unavailable: IDE server not fully initialized");
52 };
53 let Some(old) = args.get("old_file_path").and_then(|v| v.as_str()) else {
56 return error_result("openDiff: missing old_file_path");
57 };
58 let Some(contents) = args.get("new_file_contents").and_then(|v| v.as_str()) else {
59 return error_result("openDiff: missing new_file_contents");
60 };
61 let new_path = args
62 .get("new_file_path")
63 .and_then(|v| v.as_str())
64 .unwrap_or(old);
65 let tab = args
66 .get("tab_name")
67 .and_then(|v| v.as_str())
68 .unwrap_or("openDiff");
69
70 let request = DiffReviewRequest {
71 old_file_path: PathBuf::from(old),
72 new_file_path: PathBuf::from(new_path),
73 new_contents: contents.to_string(),
74 tab_name: tab.to_string(),
75 origin_session: conn_id,
76 };
77
78 let _review = review.begin();
83
84 match review_diff(bus, request).await {
85 Ok(DiffOutcome::Accept) => saved_result(),
86 Ok(_) | Err(AgentError::Cancelled(_)) => rejected_result(tab),
90 Err(_) => error_result("openDiff failed: editor not reachable"),
91 }
92}
93
94fn saved_result() -> Value {
96 text_result("FILE_SAVED", false)
97}
98
99fn rejected_result(tab: &str) -> Value {
101 json!({
102 "content": [
103 { "type": "text", "text": "DIFF_REJECTED" },
104 { "type": "text", "text": tab },
105 ],
106 "isError": false,
107 })
108}
109
110fn error_result(message: &str) -> Value {
112 text_result(message, true)
113}
114
115fn text_result(text: &str, is_error: bool) -> Value {
117 json!({
118 "content": [{ "type": "text", "text": text }],
119 "isError": is_error,
120 })
121}
122
123#[cfg(test)]
124mod tests {
125 #![allow(clippy::unwrap_used)]
126 use super::*;
127 use lattice_diff::ProgrammaticDiffRequest;
128 use lattice_mode::inbound::make_inbound_raw;
129 use std::sync::Arc;
130 use std::time::Duration;
131 use tokio::sync::Notify;
132
133 fn review() -> crate::mcp::status::ReviewHandle {
135 crate::mcp::status::ReviewState::new(Arc::new(Notify::new()))
136 }
137
138 #[tokio::test]
139 async fn no_bus_is_graceful_error() {
140 let v = open_diff(
141 None,
142 &json!({ "old_file_path": "/a.rs", "new_file_contents": "x" }),
143 0,
144 &review(),
145 )
146 .await;
147 assert_eq!(v["isError"], true);
148 assert!(
149 v["content"][0]["text"]
150 .as_str()
151 .unwrap()
152 .contains("not fully initialized")
153 );
154 }
155
156 #[tokio::test]
157 async fn missing_required_args_are_graceful_errors() {
158 let (bus, _rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
159 let v = open_diff(
161 Some(&bus),
162 &json!({ "old_file_path": "/a.rs" }),
163 0,
164 &review(),
165 )
166 .await;
167 assert_eq!(v["isError"], true);
168 assert!(
169 v["content"][0]["text"]
170 .as_str()
171 .unwrap()
172 .contains("missing new_file_contents")
173 );
174 let v = open_diff(
176 Some(&bus),
177 &json!({ "new_file_contents": "x" }),
178 0,
179 &review(),
180 )
181 .await;
182 assert!(
183 v["content"][0]["text"]
184 .as_str()
185 .unwrap()
186 .contains("missing old_file_path")
187 );
188 }
189
190 #[tokio::test]
191 async fn accept_yields_file_saved() {
192 let (bus, mut rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
193 let call = tokio::spawn({
194 let bus = bus.clone();
195 async move {
196 open_diff(
197 Some(&bus),
198 &json!({
199 "old_file_path": "/a.rs",
200 "new_file_contents": "new",
201 "tab_name": "demo",
202 }),
203 0,
204 &review(),
205 )
206 .await
207 }
208 });
209 let req = recv_soon(&mut rx).await;
211 assert_eq!(req.old_file_path, PathBuf::from("/a.rs"));
212 assert_eq!(req.new_file_path, PathBuf::from("/a.rs"), "defaults to old");
213 assert_eq!(req.new_contents, "new");
214 req.response.send(DiffOutcome::Accept).unwrap();
215
216 let v = call.await.unwrap();
217 assert_eq!(v["isError"], false);
218 assert_eq!(v["content"][0]["text"], "FILE_SAVED");
219 }
220
221 #[tokio::test]
222 async fn reject_yields_diff_rejected_with_tab() {
223 let (bus, mut rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
224 let call = tokio::spawn({
225 let bus = bus.clone();
226 async move {
227 open_diff(
228 Some(&bus),
229 &json!({
230 "old_file_path": "/a.rs",
231 "new_file_path": "/b.rs",
232 "new_file_contents": "new",
233 "tab_name": "the-tab",
234 }),
235 0,
236 &review(),
237 )
238 .await
239 }
240 });
241 let req = recv_soon(&mut rx).await;
242 assert_eq!(
243 req.new_file_path,
244 PathBuf::from("/b.rs"),
245 "explicit new path"
246 );
247 req.response.send(DiffOutcome::Reject).unwrap();
248
249 let v = call.await.unwrap();
250 assert_eq!(v["content"][0]["text"], "DIFF_REJECTED");
251 assert_eq!(v["content"][1]["text"], "the-tab");
252 }
253
254 #[tokio::test]
255 async fn dropped_sender_is_treated_as_reject() {
256 let (bus, mut rx) = make_inbound_raw::<ProgrammaticDiffRequest>(Arc::new(Notify::new()));
259 let call = tokio::spawn({
260 let bus = bus.clone();
261 async move {
262 open_diff(
263 Some(&bus),
264 &json!({
265 "old_file_path": "/a.rs",
266 "new_file_contents": "new",
267 "tab_name": "cancelled",
268 }),
269 0,
270 &review(),
271 )
272 .await
273 }
274 });
275 let req = recv_soon(&mut rx).await;
276 drop(req.response); let v = call.await.unwrap();
278 assert_eq!(v["content"][0]["text"], "DIFF_REJECTED");
279 assert_eq!(v["content"][1]["text"], "cancelled");
280 }
281
282 async fn recv_soon(
285 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ProgrammaticDiffRequest>,
286 ) -> ProgrammaticDiffRequest {
287 for _ in 0..100 {
288 if let Ok(req) = rx.try_recv() {
289 return req;
290 }
291 tokio::time::sleep(Duration::from_millis(5)).await;
292 }
293 panic!("openDiff request never arrived on the bus");
294 }
295}