1use std::collections::HashMap;
11
12use crate::path_utils::{resolve_file_path, validate_path_access};
13use std::path::PathBuf;
14
15use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
16
17#[derive(Debug)]
19pub struct NotebookEditTool;
20
21#[async_trait::async_trait]
22impl BaseTool for NotebookEditTool {
23 fn name(&self) -> &str {
24 "notebook_edit"
25 }
26
27 fn description(&self) -> &str {
28 "Edit Jupyter notebook (.ipynb) cells. Supports replace, insert, and delete \
29 operations. Identify cells by cell_id or cell_number (0-indexed)."
30 }
31
32 fn parameter_schema(&self) -> serde_json::Value {
33 serde_json::json!({
34 "type": "object",
35 "properties": {
36 "notebook_path": {
37 "type": "string",
38 "description": "Path to the .ipynb file"
39 },
40 "new_source": {
41 "type": "string",
42 "description": "New cell source content"
43 },
44 "cell_id": {
45 "type": "string",
46 "description": "Cell ID to edit (preferred)"
47 },
48 "cell_number": {
49 "type": "integer",
50 "description": "0-indexed cell position (alternative to cell_id)"
51 },
52 "cell_type": {
53 "type": "string",
54 "description": "Cell type: 'code' or 'markdown'",
55 "enum": ["code", "markdown"]
56 },
57 "edit_mode": {
58 "type": "string",
59 "description": "Operation: 'replace' (default), 'insert', or 'delete'",
60 "enum": ["replace", "insert", "delete"]
61 }
62 },
63 "required": ["notebook_path"]
64 })
65 }
66
67 async fn execute(
68 &self,
69 args: HashMap<String, serde_json::Value>,
70 ctx: &ToolContext,
71 ) -> ToolResult {
72 let notebook_path = match args.get("notebook_path").and_then(|v| v.as_str()) {
73 Some(p) => p,
74 None => return ToolResult::fail("notebook_path is required"),
75 };
76
77 let new_source = args
78 .get("new_source")
79 .and_then(|v| v.as_str())
80 .unwrap_or("");
81
82 let cell_id = args.get("cell_id").and_then(|v| v.as_str());
83 let cell_number = args.get("cell_number").and_then(|v| v.as_i64());
84 let cell_type = args.get("cell_type").and_then(|v| v.as_str());
85 let edit_mode = args
86 .get("edit_mode")
87 .and_then(|v| v.as_str())
88 .unwrap_or("replace");
89
90 let path = resolve_file_path(notebook_path, &ctx.working_dir);
92
93 if let Err(msg) = validate_path_access(&path, &ctx.working_dir) {
94 return ToolResult::fail(msg);
95 }
96
97 if !path.exists() {
99 return ToolResult::fail(format!("Notebook not found: {notebook_path}"));
100 }
101
102 if path.extension().and_then(|e| e.to_str()) != Some("ipynb") {
103 return ToolResult::fail(format!("Not a Jupyter notebook file: {notebook_path}"));
104 }
105
106 let content = match std::fs::read_to_string(&path) {
108 Ok(c) => c,
109 Err(e) => return ToolResult::fail(format!("Failed to read notebook: {e}")),
110 };
111
112 let mut notebook: serde_json::Value = match serde_json::from_str(&content) {
113 Ok(v) => v,
114 Err(e) => return ToolResult::fail(format!("Invalid notebook JSON: {e}")),
115 };
116
117 let cells = match notebook.get("cells").and_then(|v| v.as_array()) {
119 Some(c) => c.clone(),
120 None => return ToolResult::fail("Notebook has no 'cells' array"),
121 };
122
123 let result = match edit_mode {
124 "replace" => replace_cell(cells, new_source, cell_id, cell_number, cell_type),
125 "insert" => insert_cell(cells, new_source, cell_id, cell_number, cell_type),
126 "delete" => delete_cell(cells, cell_id, cell_number),
127 other => {
128 return ToolResult::fail(format!(
129 "Unknown edit_mode: {other}. Use 'replace', 'insert', or 'delete'."
130 ));
131 }
132 };
133
134 match result {
135 Ok((new_cells, tool_result)) => {
136 notebook["cells"] = serde_json::json!(new_cells);
138 if let Err(e) = save_notebook(&path, ¬ebook) {
139 return ToolResult::fail(e);
140 }
141 tool_result
142 }
143 Err(tool_result) => tool_result,
144 }
145 }
146}
147
148fn find_cell_index(
150 cells: &[serde_json::Value],
151 cell_id: Option<&str>,
152 cell_number: Option<i64>,
153) -> Result<usize, String> {
154 if let Some(id) = cell_id {
155 for (i, cell) in cells.iter().enumerate() {
156 if cell.get("id").and_then(|v| v.as_str()) == Some(id) {
157 return Ok(i);
158 }
159 }
160 return Err(format!("Cell with ID '{id}' not found"));
161 }
162
163 if let Some(num) = cell_number {
164 if num < 0 || num as usize >= cells.len() {
165 return Err(format!(
166 "Cell number {num} out of range (0-{})",
167 cells.len().saturating_sub(1)
168 ));
169 }
170 return Ok(num as usize);
171 }
172
173 Err("Either cell_id or cell_number must be provided".to_string())
174}
175
176fn source_to_lines(source: &str) -> serde_json::Value {
178 let lines: Vec<&str> = source.split('\n').collect();
179 let mut result: Vec<String> = Vec::new();
180 for (i, line) in lines.iter().enumerate() {
181 if i < lines.len() - 1 {
182 result.push(format!("{line}\n"));
183 } else {
184 result.push(line.to_string());
185 }
186 }
187 serde_json::json!(result)
188}
189
190fn save_notebook(path: &PathBuf, notebook: &serde_json::Value) -> Result<(), String> {
192 let json = serde_json::to_string_pretty(notebook)
193 .map_err(|e| format!("Failed to serialize notebook: {e}"))?;
194 let json = if json.ends_with('\n') {
195 json
196 } else {
197 format!("{json}\n")
198 };
199 std::fs::write(path, &json).map_err(|e| format!("Failed to write notebook: {e}"))
200}
201
202#[allow(clippy::result_large_err)]
204fn replace_cell(
205 mut cells: Vec<serde_json::Value>,
206 new_source: &str,
207 cell_id: Option<&str>,
208 cell_number: Option<i64>,
209 cell_type: Option<&str>,
210) -> Result<(Vec<serde_json::Value>, ToolResult), ToolResult> {
211 let index = find_cell_index(&cells, cell_id, cell_number).map_err(ToolResult::fail)?;
212
213 let old_source_len = cells[index]
215 .get("source")
216 .and_then(|v| v.as_array())
217 .map(|arr| {
218 arr.iter()
219 .filter_map(|v| v.as_str())
220 .map(|s| s.len())
221 .sum::<usize>()
222 })
223 .unwrap_or(0);
224
225 let result_cell_id = cells[index]
226 .get("id")
227 .and_then(|v| v.as_str())
228 .unwrap_or("unknown")
229 .to_string();
230
231 cells[index]["source"] = source_to_lines(new_source);
233
234 if let Some(ct) = cell_type {
236 cells[index]["cell_type"] = serde_json::json!(ct);
237 }
238
239 let mut metadata = HashMap::new();
240 metadata.insert("cell_id".into(), serde_json::json!(result_cell_id));
241 metadata.insert("cell_number".into(), serde_json::json!(index));
242 metadata.insert("edit_mode".into(), serde_json::json!("replace"));
243
244 Ok((
245 cells,
246 ToolResult::ok_with_metadata(
247 format!(
248 "Replaced cell {result_cell_id} content ({old_source_len} -> {} chars)",
249 new_source.len()
250 ),
251 metadata,
252 ),
253 ))
254}
255
256#[allow(clippy::result_large_err)]
258fn insert_cell(
259 mut cells: Vec<serde_json::Value>,
260 new_source: &str,
261 after_cell_id: Option<&str>,
262 at_position: Option<i64>,
263 cell_type: Option<&str>,
264) -> Result<(Vec<serde_json::Value>, ToolResult), ToolResult> {
265 let cell_type = cell_type.unwrap_or("code");
266
267 let insert_pos = if let Some(id) = after_cell_id {
269 let idx = find_cell_index(&cells, Some(id), None).map_err(ToolResult::fail)?;
270 idx + 1
271 } else if let Some(pos) = at_position {
272 let pos = pos.max(0) as usize;
273 pos.min(cells.len())
274 } else {
275 cells.len()
276 };
277
278 let new_cell_id = format!("{:08x}", rand_u32());
280
281 let mut new_cell = serde_json::json!({
283 "id": new_cell_id,
284 "cell_type": cell_type,
285 "metadata": {},
286 "source": source_to_lines(new_source),
287 });
288
289 if cell_type == "code" {
290 new_cell["execution_count"] = serde_json::Value::Null;
291 new_cell["outputs"] = serde_json::json!([]);
292 }
293
294 cells.insert(insert_pos, new_cell);
295
296 let mut metadata = HashMap::new();
297 metadata.insert("cell_id".into(), serde_json::json!(new_cell_id));
298 metadata.insert("cell_number".into(), serde_json::json!(insert_pos));
299 metadata.insert("edit_mode".into(), serde_json::json!("insert"));
300 metadata.insert("cell_type".into(), serde_json::json!(cell_type));
301
302 Ok((
303 cells,
304 ToolResult::ok_with_metadata(
305 format!("Inserted new {cell_type} cell at position {insert_pos}"),
306 metadata,
307 ),
308 ))
309}
310
311#[allow(clippy::result_large_err)]
313fn delete_cell(
314 mut cells: Vec<serde_json::Value>,
315 cell_id: Option<&str>,
316 cell_number: Option<i64>,
317) -> Result<(Vec<serde_json::Value>, ToolResult), ToolResult> {
318 let index = find_cell_index(&cells, cell_id, cell_number).map_err(ToolResult::fail)?;
319
320 let deleted = cells.remove(index);
321 let deleted_cell_id = deleted
322 .get("id")
323 .and_then(|v| v.as_str())
324 .unwrap_or("unknown")
325 .to_string();
326
327 let mut metadata = HashMap::new();
328 metadata.insert("cell_id".into(), serde_json::json!(deleted_cell_id));
329 metadata.insert("cell_number".into(), serde_json::json!(index));
330 metadata.insert("edit_mode".into(), serde_json::json!("delete"));
331
332 Ok((
333 cells,
334 ToolResult::ok_with_metadata(
335 format!("Deleted cell {deleted_cell_id} (was at position {index})"),
336 metadata,
337 ),
338 ))
339}
340
341fn rand_u32() -> u32 {
343 use std::time::SystemTime;
344 let seed = SystemTime::now()
345 .duration_since(SystemTime::UNIX_EPOCH)
346 .unwrap_or_default()
347 .as_nanos() as u32;
348 let mut x = seed;
349 x ^= x << 13;
350 x ^= x >> 17;
351 x ^= x << 5;
352 x.wrapping_mul(0x9E3779B9)
353}
354
355#[cfg(test)]
356#[path = "notebook_edit_tests.rs"]
357mod tests;