1use std::path::PathBuf;
16
17use serde_json::{Value, json};
18
19use lattice_agent::{AgentError, EditorAccess};
20use lattice_grammar::Utf16Pos;
21
22pub async fn open_file(editor: &EditorAccess, args: &Value) -> Value {
24 let Some(path) = args.get("filePath").and_then(|v| v.as_str()) else {
25 return result(false, "openFile: missing filePath");
26 };
27 let column = parse_position(args);
28 match editor.open_file(PathBuf::from(path), column).await {
29 Ok(()) => result(true, "ok"),
30 Err(e) => result(false, &write_error_message(e)),
31 }
32}
33
34pub async fn save_document(editor: &EditorAccess, args: &Value) -> Value {
36 let Some(path) = args.get("filePath").and_then(|v| v.as_str()) else {
37 return result(false, "saveDocument: missing filePath");
38 };
39 match editor.save_document(PathBuf::from(path)).await {
40 Ok(()) => result(true, "ok"),
41 Err(e) => result(false, &write_error_message(e)),
42 }
43}
44
45pub async fn close_tab(editor: &EditorAccess, args: &Value, conn_id: u64) -> Value {
48 let Some(tab) = args.get("tab_name").and_then(|v| v.as_str()) else {
49 return result(false, "close_tab: missing tab_name");
50 };
51 match editor.close_tab(conn_id, tab.to_string()).await {
52 Ok(()) => result(true, "ok"),
53 Err(e) => result(false, &write_error_message(e)),
54 }
55}
56
57pub async fn close_all_diff_tabs(editor: &EditorAccess, conn_id: u64) -> Value {
60 match editor.close_session_diffs(conn_id).await {
61 Ok(()) => result(true, "ok"),
62 Err(e) => result(false, &write_error_message(e)),
63 }
64}
65
66fn write_error_message(e: AgentError) -> String {
74 match e {
75 AgentError::Bus(m) | AgentError::Cancelled(m) | AgentError::Io(m) => m,
76 }
77}
78
79fn parse_position(args: &Value) -> Option<Utf16Pos> {
87 let start = args.get("selection").and_then(|s| s.get("start"))?;
88 let line = start.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
89 let col = start.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
90 Some(Utf16Pos { line, col })
91}
92
93fn result(success: bool, message: &str) -> Value {
95 json!({ "success": success, "message": message })
96}
97
98#[cfg(test)]
99mod tests {
100 #![allow(clippy::unwrap_used)]
101 use super::*;
102 use std::sync::{Arc, Mutex};
103
104 fn editor_without_bus() -> EditorAccess {
105 EditorAccess::new(
106 Arc::new(Mutex::new(lattice_agent::EditorStateCache::default())),
107 None,
108 vec![],
109 None,
110 )
111 }
112
113 #[tokio::test]
114 async fn write_with_no_bus_is_graceful_failure() {
115 let v = open_file(&editor_without_bus(), &json!({ "filePath": "/a.rs" })).await;
116 assert_eq!(v["success"], false);
117 assert!(
118 v["message"]
119 .as_str()
120 .unwrap()
121 .contains("not fully initialized")
122 );
123 }
124
125 #[tokio::test]
126 async fn missing_argument_is_graceful_failure() {
127 let v = open_file(&editor_without_bus(), &json!({})).await;
128 assert_eq!(v["success"], false);
129 assert!(v["message"].as_str().unwrap().contains("missing filePath"));
130 let v = close_tab(&editor_without_bus(), &json!({}), 0).await;
131 assert!(v["message"].as_str().unwrap().contains("missing tab_name"));
132 }
133
134 #[test]
135 fn parse_position_reads_selection_start_else_none() {
136 assert_eq!(parse_position(&json!({})), None);
138 let p = parse_position(&json!({ "selection": { "start": { "line": 4, "character": 2 } } }));
140 assert_eq!(p, Some(Utf16Pos { line: 4, col: 2 }));
141 }
142
143 #[tokio::test]
144 async fn open_file_round_trips_through_a_live_bus() {
145 use lattice_agent::EditorStateCache;
146 use lattice_mode::inbound::make_inbound;
147 use std::time::Duration;
148 use tokio::sync::Notify;
149
150 let cache = Arc::new(Mutex::new(EditorStateCache::default()));
151 let (bus, mut drain) = make_inbound(
152 Arc::new(Notify::new()),
153 lattice_agent::make_handler(cache.clone()),
154 );
155 let editor = EditorAccess::new(cache, None, vec![], Some(bus));
156
157 let call =
161 tokio::spawn(async move { open_file(&editor, &json!({ "filePath": "/a.rs" })).await });
162 let mut effects = Vec::new();
163 for _ in 0..50 {
164 effects = drain();
165 if !effects.is_empty() {
166 break;
167 }
168 tokio::time::sleep(Duration::from_millis(5)).await;
169 }
170 assert_eq!(effects.len(), 1, "openFile maps to one Effect");
171 let v = call.await.unwrap();
172 assert_eq!(v["success"], true);
173 }
174}