Skip to main content

opendev_tools_impl/
notebook_edit.rs

1//! Notebook edit tool — edit Jupyter notebook (.ipynb) cells.
2//!
3//! Supports three edit modes:
4//! - replace: Replace an existing cell's content
5//! - insert: Insert a new cell at a position
6//! - delete: Delete a cell
7//!
8//! Cells can be identified by cell_id (preferred) or cell_number (0-indexed).
9
10use 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/// Tool for editing Jupyter notebook cells.
18#[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        // Resolve path
91        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        // Validate
98        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        // Load notebook
107        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        // Extract cells as owned Vec
118        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                // Save the updated notebook
137                notebook["cells"] = serde_json::json!(new_cells);
138                if let Err(e) = save_notebook(&path, &notebook) {
139                    return ToolResult::fail(e);
140                }
141                tool_result
142            }
143            Err(tool_result) => tool_result,
144        }
145    }
146}
147
148/// Find a cell by ID or number, returning its index.
149fn 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
176/// Convert source string to notebook cell source format (list of lines).
177fn 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
190/// Save the notebook back to disk.
191fn 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/// Replace an existing cell's content. Returns (updated_cells, ToolResult) on success.
203#[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    // Get old source length for reporting
214    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    // Update source
232    cells[index]["source"] = source_to_lines(new_source);
233
234    // Update cell type if specified
235    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/// Insert a new cell. Returns (updated_cells, ToolResult) on success.
257#[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    // Determine insert position
268    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    // Generate a new cell ID
279    let new_cell_id = format!("{:08x}", rand_u32());
280
281    // Build new cell
282    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/// Delete a cell. Returns (updated_cells, ToolResult) on success.
312#[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
341/// Simple pseudo-random u32 (not crypto-secure, just for cell IDs).
342fn 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;