Skip to main content

nexo_core/agent/
notebook_edit_tool.rs

1#![allow(clippy::all)]
2
3//! `NotebookEdit` Jupyter `.ipynb` cell editor.
4//!
5//! Cell-level edits with output-preservation. Pure Rust round-trip
6//! through `serde_json::Value` — no `jupyter` binary required, no
7//! `nbformat` Python dep. The notebook is a well-defined JSON
8//! document (nbformat 4.x); unknown top-level fields survive
9//! untouched (forward-compat).
10//!
11//! Formatting uses an indent of 1 space; a replace at the end of the
12//! cell list auto-converts to an insert. `cell_id` accepts both
13//! UUIDv4-style ids and a `cell-N` numeric fallback.
14//!
15//! Scope:
16//!   * Replace / insert / delete a single cell.
17//!   * cell_id may be a UUID-style id (`notebook.cells[i].id`) or a
18//!     `cell-N` numeric index fallback.
19//!   * Code-cell replaces clear `execution_count` + `outputs`.
20//!   * Out of scope: `Read-before-Edit` guard (no shared file-state
21//!     in the agent runtime); attribution tracking
22//!     (`fileHistoryTrackEdit`); cell-type conversion mid-replace
23//!     (we accept `cell_type` arg but do not transmute existing
24//!     cells).
25
26use super::context::AgentContext;
27use super::tool_registry::ToolHandler;
28use async_trait::async_trait;
29use nexo_llm::ToolDef;
30use serde_json::{json, Map, Value};
31use std::path::PathBuf;
32
33/// Indent used by Jupyter's canonical writer (`json.dumps(indent=1)`
34/// in `nbformat`).
35pub const IPYNB_INDENT: usize = 1;
36
37pub struct NotebookEditTool;
38
39impl NotebookEditTool {
40    pub fn tool_def() -> ToolDef {
41        ToolDef {
42            name: "NotebookEdit".to_string(),
43            description: "Edit a single cell in a Jupyter notebook (.ipynb). Three edit modes: `replace` (default — overwrite the cell's source), `insert` (add a new cell after the anchor), `delete` (remove the cell). Round-trips through serde_json so unknown nbformat fields survive untouched. Code-cell replaces clear execution_count + outputs (the diff stays sane); markdown cells preserve all metadata.".to_string(),
44            parameters: json!({
45                "type": "object",
46                "properties": {
47                    "notebook_path": {
48                        "type": "string",
49                        "description": "Absolute path to the .ipynb file."
50                    },
51                    "cell_id": {
52                        "type": "string",
53                        "description": "ID of the cell to operate on. Either a UUID-style id (notebook.cells[i].id) or a `cell-N` numeric index fallback. For `insert`, the new cell goes AFTER this anchor; omit (or empty) to insert at position 0."
54                    },
55                    "new_source": {
56                        "type": "string",
57                        "description": "Source code / markdown body. Required for `replace` + `insert`; ignored for `delete`."
58                    },
59                    "cell_type": {
60                        "type": "string",
61                        "enum": ["code", "markdown"],
62                        "description": "Cell type — required for `insert`, optional for `replace` (defaults to the existing cell's type)."
63                    },
64                    "edit_mode": {
65                        "type": "string",
66                        "enum": ["replace", "insert", "delete"],
67                        "description": "Defaults to `replace`."
68                    }
69                },
70                "required": ["notebook_path"]
71            }),
72        }
73    }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum EditMode {
78    Replace,
79    Insert,
80    Delete,
81}
82
83fn parse_edit_mode(s: Option<&str>) -> anyhow::Result<EditMode> {
84    match s.unwrap_or("replace") {
85        "replace" => Ok(EditMode::Replace),
86        "insert" => Ok(EditMode::Insert),
87        "delete" => Ok(EditMode::Delete),
88        other => Err(anyhow::anyhow!(
89            "edit_mode must be replace|insert|delete, got `{other}`"
90        )),
91    }
92}
93
94/// Accepts the `cell-N` form returning `Some(N)`. Only used as a
95/// fallback when the literal id lookup misses.
96fn parse_cell_index(cell_id: &str) -> Option<usize> {
97    cell_id
98        .strip_prefix("cell-")
99        .and_then(|rest| rest.parse::<usize>().ok())
100}
101
102/// Resolve `cell_id` to a position in `cells`. Tries the literal
103/// `cells[i].id == cell_id` match first, then the `cell-N`
104/// numeric-index fallback.
105fn find_cell_index(cells: &[Value], cell_id: &str) -> Option<usize> {
106    for (idx, cell) in cells.iter().enumerate() {
107        if cell
108            .get("id")
109            .and_then(|v| v.as_str())
110            .map_or(false, |s| s == cell_id)
111        {
112            return Some(idx);
113        }
114    }
115    parse_cell_index(cell_id).filter(|&n| n < cells.len())
116}
117
118fn nbformat_supports_cell_id(notebook: &Value) -> bool {
119    let major = notebook
120        .get("nbformat")
121        .and_then(|v| v.as_u64())
122        .unwrap_or(0);
123    let minor = notebook
124        .get("nbformat_minor")
125        .and_then(|v| v.as_u64())
126        .unwrap_or(0);
127    major > 4 || (major == 4 && minor >= 5)
128}
129
130/// 12-char base-36 cell id. Generated only when `nbformat >= 4.5`
131/// so older notebooks stay valid.
132fn fresh_cell_id() -> String {
133    use std::time::{SystemTime, UNIX_EPOCH};
134    // Cheap pseudo-random — combines high-resolution time with a
135    // process-local counter. Good enough for nbformat ids (not a
136    // CSPRNG, and doesn't need to be).
137    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
138    let nanos = SystemTime::now()
139        .duration_since(UNIX_EPOCH)
140        .map(|d| d.subsec_nanos() as u64 ^ d.as_secs())
141        .unwrap_or(0);
142    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
143    let mut value =
144        nanos.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ n.wrapping_mul(0xbf58_476d_1ce4_e5b9);
145    let alphabet: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
146    let mut out = String::with_capacity(12);
147    for _ in 0..12 {
148        let idx = (value % 36) as usize;
149        out.push(alphabet[idx] as char);
150        value /= 36;
151        if value == 0 {
152            value = nanos.wrapping_add(n).wrapping_mul(2654435769);
153        }
154    }
155    out
156}
157
158fn build_new_cell(cell_type: &str, source: &str, fresh_id: Option<String>) -> Value {
159    let mut cell = Map::new();
160    cell.insert("cell_type".to_string(), json!(cell_type));
161    if let Some(id) = fresh_id {
162        cell.insert("id".to_string(), json!(id));
163    }
164    cell.insert("source".to_string(), json!(source));
165    cell.insert("metadata".to_string(), json!({}));
166    if cell_type == "code" {
167        cell.insert("execution_count".to_string(), Value::Null);
168        cell.insert("outputs".to_string(), json!([]));
169    }
170    Value::Object(cell)
171}
172
173#[async_trait]
174impl ToolHandler for NotebookEditTool {
175    #[allow(unused_assignments)]
176    async fn call(&self, _ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
177        let notebook_path = args
178            .get("notebook_path")
179            .and_then(|v| v.as_str())
180            .ok_or_else(|| anyhow::anyhow!("NotebookEdit requires `notebook_path`"))?;
181        let path = PathBuf::from(notebook_path);
182        if !path.is_absolute() {
183            return Err(anyhow::anyhow!(
184                "NotebookEdit: `notebook_path` must be absolute"
185            ));
186        }
187        if path.extension().and_then(|s| s.to_str()) != Some("ipynb") {
188            return Err(anyhow::anyhow!(
189                "NotebookEdit: file must end in .ipynb (use FileEdit for other types)"
190            ));
191        }
192
193        let edit_mode = parse_edit_mode(args.get("edit_mode").and_then(|v| v.as_str()))?;
194        let cell_id = args.get("cell_id").and_then(|v| v.as_str()).unwrap_or("");
195        let new_source = args
196            .get("new_source")
197            .and_then(|v| v.as_str())
198            .unwrap_or("");
199        let cell_type = args.get("cell_type").and_then(|v| v.as_str());
200
201        if matches!(edit_mode, EditMode::Replace | EditMode::Insert)
202            && new_source.is_empty()
203            && args.get("new_source").is_none()
204        {
205            return Err(anyhow::anyhow!(
206                "NotebookEdit: `new_source` is required for replace/insert"
207            ));
208        }
209        if matches!(edit_mode, EditMode::Insert) && cell_type.is_none() {
210            return Err(anyhow::anyhow!(
211                "NotebookEdit: `cell_type` is required when edit_mode=insert"
212            ));
213        }
214
215        let raw = std::fs::read_to_string(&path)
216            .map_err(|e| anyhow::anyhow!("NotebookEdit: read failed: {e}"))?;
217        let mut notebook: Value = serde_json::from_str(&raw)
218            .map_err(|e| anyhow::anyhow!("NotebookEdit: invalid JSON: {e}"))?;
219        let nbformat_minor_5 = nbformat_supports_cell_id(&notebook);
220
221        let language = notebook
222            .pointer("/metadata/language_info/name")
223            .and_then(|v| v.as_str())
224            .unwrap_or("python")
225            .to_string();
226
227        let cells = notebook
228            .get_mut("cells")
229            .and_then(|v| v.as_array_mut())
230            .ok_or_else(|| anyhow::anyhow!("NotebookEdit: notebook has no `cells` array"))?;
231
232        let original_cell_count = cells.len();
233        let mut effective_mode = edit_mode;
234        let mut effective_cell_type = cell_type.map(str::to_string);
235        let mut returned_cell_id: Option<String> = None;
236        let mut anchor_index: Option<usize> = None;
237
238        // Resolve anchor index. For `insert`, the new cell goes
239        // AFTER the anchor; an empty cell_id means "position 0".
240        // For replace/delete, anchor must exist.
241        if cell_id.is_empty() {
242            if matches!(edit_mode, EditMode::Insert) {
243                anchor_index = Some(0);
244            } else {
245                return Err(anyhow::anyhow!(
246                    "NotebookEdit: `cell_id` is required for {} (only insert may omit it)",
247                    match edit_mode {
248                        EditMode::Replace => "replace",
249                        EditMode::Delete => "delete",
250                        EditMode::Insert => unreachable!(),
251                    }
252                ));
253            }
254        } else {
255            anchor_index = find_cell_index(cells, cell_id);
256            if anchor_index.is_none() {
257                let available_ids: Vec<String> = cells
258                    .iter()
259                    .enumerate()
260                    .map(|(i, c)| {
261                        c.get("id")
262                            .and_then(|v| v.as_str())
263                            .map(|s| s.to_string())
264                            .unwrap_or_else(|| format!("cell-{i}"))
265                    })
266                    .take(10)
267                    .collect();
268                return Err(anyhow::anyhow!(
269                    "NotebookEdit: cell_id `{cell_id}` not found. Available (up to 10): {available}",
270                    available = available_ids.join(", ")
271                ));
272            }
273        }
274
275        match effective_mode {
276            EditMode::Delete => {
277                let idx = anchor_index.unwrap();
278                cells.remove(idx);
279                returned_cell_id = Some(cell_id.to_string());
280            }
281            EditMode::Insert => {
282                let mut idx = anchor_index.unwrap();
283                if !cell_id.is_empty() {
284                    idx += 1; // insert AFTER the anchor
285                }
286                let ct = effective_cell_type.clone().unwrap_or_else(|| "code".into());
287                let fresh_id = if nbformat_minor_5 {
288                    Some(fresh_cell_id())
289                } else {
290                    None
291                };
292                returned_cell_id = fresh_id.clone();
293                let cell = build_new_cell(&ct, new_source, fresh_id);
294                if idx > cells.len() {
295                    return Err(anyhow::anyhow!(
296                        "NotebookEdit: anchor index {idx} > total cells {}",
297                        cells.len()
298                    ));
299                }
300                cells.insert(idx, cell);
301            }
302            EditMode::Replace => {
303                let idx = anchor_index.unwrap();
304                if idx == cells.len() {
305                    // Defensive: replace-at-end auto-converts to
306                    // insert.
307                    let ct = effective_cell_type.clone().unwrap_or_else(|| "code".into());
308                    let fresh_id = if nbformat_minor_5 {
309                        Some(fresh_cell_id())
310                    } else {
311                        None
312                    };
313                    returned_cell_id = fresh_id.clone();
314                    cells.push(build_new_cell(&ct, new_source, fresh_id));
315                    effective_mode = EditMode::Insert;
316                } else {
317                    let target = cells.get_mut(idx).unwrap();
318                    let target_obj = target
319                        .as_object_mut()
320                        .ok_or_else(|| anyhow::anyhow!("NotebookEdit: cell is not an object"))?;
321                    target_obj.insert("source".to_string(), json!(new_source));
322                    let current_type = target_obj
323                        .get("cell_type")
324                        .and_then(|v| v.as_str())
325                        .unwrap_or("code")
326                        .to_string();
327                    if current_type == "code" {
328                        target_obj.insert("execution_count".to_string(), Value::Null);
329                        target_obj.insert("outputs".to_string(), json!([]));
330                    }
331                    if let Some(ct) = &effective_cell_type {
332                        if ct != &current_type {
333                            target_obj.insert("cell_type".to_string(), json!(ct));
334                        }
335                    } else {
336                        effective_cell_type = Some(current_type);
337                    }
338                    returned_cell_id = target_obj
339                        .get("id")
340                        .and_then(|v| v.as_str())
341                        .map(str::to_string);
342                }
343            }
344        }
345
346        // Re-serialise with Jupyter's canonical 1-space indent.
347        let updated = pretty_indent(&notebook, IPYNB_INDENT);
348        std::fs::write(&path, &updated)
349            .map_err(|e| anyhow::anyhow!("NotebookEdit: write failed: {e}"))?;
350
351        let total_cells = notebook
352            .get("cells")
353            .and_then(|v| v.as_array())
354            .map(|a| a.len())
355            .unwrap_or(0);
356
357        Ok(json!({
358            "notebook_path": path.display().to_string(),
359            "edit_mode": match effective_mode {
360                EditMode::Replace => "replace",
361                EditMode::Insert => "insert",
362                EditMode::Delete => "delete",
363            },
364            "cell_id": returned_cell_id,
365            "cell_type": effective_cell_type.unwrap_or_else(|| "code".into()),
366            "language": language,
367            "total_cells": total_cells,
368            "cells_delta": total_cells as i64 - original_cell_count as i64,
369        }))
370    }
371}
372
373/// Custom pretty-print emitting Jupyter's 1-space indent. The
374/// stdlib `serde_json::to_string_pretty` always uses 2 spaces, and
375/// Jupyter's `nbformat` writes with `indent=1` — diffs against the
376/// canonical format would be huge otherwise.
377fn pretty_indent(value: &Value, spaces: usize) -> String {
378    let indent = " ".repeat(spaces);
379    let mut out = Vec::new();
380    let mut ser = serde_json::Serializer::with_formatter(
381        &mut out,
382        serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()),
383    );
384    use serde::Serialize;
385    value.serialize(&mut ser).expect("serde to Vec never fails");
386    String::from_utf8(out).expect("serde emits valid UTF-8")
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::session::SessionManager;
393    use nexo_broker::AnyBroker;
394    use nexo_config::types::agents::{
395        AgentConfig, AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, ModelConfig,
396        OutboundAllowlistConfig, WorkspaceGitConfig,
397    };
398    use std::sync::Arc;
399    use tempfile::TempDir;
400
401    fn ctx() -> AgentContext {
402        let cfg = AgentConfig {
403            id: "a".into(),
404            model: ModelConfig {
405                provider: "x".into(),
406                model: "y".into(),
407            },
408            plugins: Vec::new(),
409            heartbeat: HeartbeatConfig::default(),
410            config: AgentRuntimeConfig::default(),
411            system_prompt: String::new(),
412            workspace: String::new(),
413            skills: Vec::new(),
414            skills_dir: "./skills".into(),
415            skill_overrides: Default::default(),
416            transcripts_dir: String::new(),
417            dreaming: DreamingYamlConfig::default(),
418            workspace_git: WorkspaceGitConfig::default(),
419            tool_rate_limits: None,
420            tool_args_validation: None,
421            extra_docs: Vec::new(),
422            inbound_bindings: Vec::new(),
423            allowed_tools: Vec::new(),
424            sender_rate_limit: None,
425            allowed_delegates: Vec::new(),
426            accept_delegates_from: Vec::new(),
427            description: String::new(),
428            google_auth: None,
429            credentials: Default::default(),
430            link_understanding: serde_json::Value::Null,
431            web_search: serde_json::Value::Null,
432            pairing_policy: serde_json::Value::Null,
433            language: None,
434            locale_prompts: Default::default(),
435            outbound_allowlist: OutboundAllowlistConfig::default(),
436            context_optimization: None,
437            dispatch_policy: Default::default(),
438            plan_mode: Default::default(),
439            remote_triggers: Vec::new(),
440            lsp: nexo_config::types::lsp::LspPolicy::default(),
441            config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
442            team: nexo_config::types::team::TeamPolicy::default(),
443            proactive: Default::default(),
444            repl: Default::default(),
445            auto_dream: None,
446            assistant_mode: None,
447            away_summary: None,
448            brief: None,
449            channels: None,
450            auto_approve: false,
451            extract_memories: None,
452            event_subscribers: Vec::new(),
453            tenant_id: None,
454            extensions_config: std::collections::BTreeMap::new(),
455            active: true,
456        };
457        AgentContext::new(
458            "a",
459            Arc::new(cfg),
460            AnyBroker::local(),
461            Arc::new(SessionManager::new(std::time::Duration::from_secs(60), 8)),
462        )
463    }
464
465    fn sample_notebook() -> Value {
466        json!({
467            "cells": [
468                {
469                    "cell_type": "code",
470                    "id": "alpha",
471                    "metadata": {},
472                    "source": "print('hello')",
473                    "execution_count": 7,
474                    "outputs": [{"output_type": "stream", "name": "stdout", "text": "hello\n"}]
475                },
476                {
477                    "cell_type": "markdown",
478                    "id": "beta",
479                    "metadata": {},
480                    "source": "# header"
481                }
482            ],
483            "metadata": {
484                "language_info": {"name": "python"},
485                "kernelspec": {"name": "python3", "display_name": "Python 3"}
486            },
487            "nbformat": 4,
488            "nbformat_minor": 5,
489            "x_unknown_field": "must round-trip"
490        })
491    }
492
493    fn write_notebook(dir: &TempDir, name: &str, body: &Value) -> PathBuf {
494        let p = dir.path().join(name);
495        std::fs::write(&p, pretty_indent(body, IPYNB_INDENT)).unwrap();
496        p
497    }
498
499    fn read_notebook(p: &PathBuf) -> Value {
500        let raw = std::fs::read_to_string(p).unwrap();
501        serde_json::from_str(&raw).unwrap()
502    }
503
504    #[tokio::test]
505    async fn replace_clears_outputs_and_execution_count() {
506        let dir = TempDir::new().unwrap();
507        let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
508        let res = NotebookEditTool
509            .call(
510                &ctx(),
511                json!({
512                    "notebook_path": p.display().to_string(),
513                    "cell_id": "alpha",
514                    "new_source": "print('world')",
515                    "edit_mode": "replace"
516                }),
517            )
518            .await
519            .unwrap();
520        assert_eq!(res["edit_mode"], "replace");
521        assert_eq!(res["cell_id"], "alpha");
522        let nb = read_notebook(&p);
523        let alpha = &nb["cells"][0];
524        assert_eq!(alpha["source"], "print('world')");
525        assert_eq!(alpha["execution_count"], Value::Null);
526        assert_eq!(alpha["outputs"].as_array().unwrap().len(), 0);
527        // Untouched cell preserved.
528        assert_eq!(nb["cells"][1]["source"], "# header");
529        // Unknown top-level field round-trips.
530        assert_eq!(nb["x_unknown_field"], "must round-trip");
531    }
532
533    #[tokio::test]
534    async fn insert_after_anchor_grows_total_cells() {
535        let dir = TempDir::new().unwrap();
536        let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
537        let res = NotebookEditTool
538            .call(
539                &ctx(),
540                json!({
541                    "notebook_path": p.display().to_string(),
542                    "cell_id": "alpha",
543                    "new_source": "x = 1",
544                    "cell_type": "code",
545                    "edit_mode": "insert"
546                }),
547            )
548            .await
549            .unwrap();
550        assert_eq!(res["edit_mode"], "insert");
551        assert_eq!(res["cells_delta"], 1);
552        assert_eq!(res["total_cells"], 3);
553        let nb = read_notebook(&p);
554        // alpha kept first, new cell at index 1, beta pushed to 2.
555        assert_eq!(nb["cells"][0]["id"], "alpha");
556        assert_eq!(nb["cells"][1]["source"], "x = 1");
557        assert_eq!(nb["cells"][2]["id"], "beta");
558        // Fresh id present for nbformat 4.5+.
559        assert!(nb["cells"][1]["id"].is_string());
560    }
561
562    #[tokio::test]
563    async fn insert_with_empty_cell_id_goes_to_position_zero() {
564        let dir = TempDir::new().unwrap();
565        let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
566        NotebookEditTool
567            .call(
568                &ctx(),
569                json!({
570                    "notebook_path": p.display().to_string(),
571                    "cell_id": "",
572                    "new_source": "import os",
573                    "cell_type": "code",
574                    "edit_mode": "insert"
575                }),
576            )
577            .await
578            .unwrap();
579        let nb = read_notebook(&p);
580        assert_eq!(nb["cells"][0]["source"], "import os");
581        assert_eq!(nb["cells"][1]["id"], "alpha");
582    }
583
584    #[tokio::test]
585    async fn delete_shrinks_total_cells() {
586        let dir = TempDir::new().unwrap();
587        let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
588        let res = NotebookEditTool
589            .call(
590                &ctx(),
591                json!({
592                    "notebook_path": p.display().to_string(),
593                    "cell_id": "beta",
594                    "edit_mode": "delete"
595                }),
596            )
597            .await
598            .unwrap();
599        assert_eq!(res["edit_mode"], "delete");
600        assert_eq!(res["cells_delta"], -1);
601        let nb = read_notebook(&p);
602        assert_eq!(nb["cells"].as_array().unwrap().len(), 1);
603        assert_eq!(nb["cells"][0]["id"], "alpha");
604    }
605
606    #[tokio::test]
607    async fn cell_n_index_fallback_when_no_uuid_id() {
608        let dir = TempDir::new().unwrap();
609        let mut nb = sample_notebook();
610        // Drop the explicit `id` fields so the model must fall back
611        // to the cell-N convention.
612        for cell in nb["cells"].as_array_mut().unwrap() {
613            cell.as_object_mut().unwrap().remove("id");
614        }
615        let p = write_notebook(&dir, "n.ipynb", &nb);
616        let res = NotebookEditTool
617            .call(
618                &ctx(),
619                json!({
620                    "notebook_path": p.display().to_string(),
621                    "cell_id": "cell-0",
622                    "new_source": "y = 2",
623                    "edit_mode": "replace"
624                }),
625            )
626            .await
627            .unwrap();
628        assert_eq!(res["edit_mode"], "replace");
629        let written = read_notebook(&p);
630        assert_eq!(written["cells"][0]["source"], "y = 2");
631    }
632
633    #[tokio::test]
634    async fn missing_cell_id_lists_available() {
635        let dir = TempDir::new().unwrap();
636        let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
637        let err = NotebookEditTool
638            .call(
639                &ctx(),
640                json!({
641                    "notebook_path": p.display().to_string(),
642                    "cell_id": "imaginary",
643                    "new_source": "noop",
644                    "edit_mode": "replace"
645                }),
646            )
647            .await
648            .unwrap_err()
649            .to_string();
650        assert!(err.contains("not found"), "got: {err}");
651        assert!(err.contains("alpha"), "got: {err}");
652    }
653
654    #[tokio::test]
655    async fn refuses_non_ipynb_file() {
656        let dir = TempDir::new().unwrap();
657        let p = dir.path().join("plain.json");
658        std::fs::write(&p, "{}").unwrap();
659        let err = NotebookEditTool
660            .call(
661                &ctx(),
662                json!({
663                    "notebook_path": p.display().to_string(),
664                    "cell_id": "x",
665                    "new_source": "y",
666                    "edit_mode": "replace"
667                }),
668            )
669            .await
670            .unwrap_err()
671            .to_string();
672        assert!(err.contains(".ipynb"), "got: {err}");
673    }
674
675    #[tokio::test]
676    async fn refuses_relative_path() {
677        let err = NotebookEditTool
678            .call(
679                &ctx(),
680                json!({
681                    "notebook_path": "notebooks/n.ipynb",
682                    "cell_id": "x",
683                    "new_source": "y",
684                    "edit_mode": "replace"
685                }),
686            )
687            .await
688            .unwrap_err()
689            .to_string();
690        assert!(err.contains("absolute"), "got: {err}");
691    }
692
693    #[tokio::test]
694    async fn insert_requires_cell_type() {
695        let dir = TempDir::new().unwrap();
696        let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
697        let err = NotebookEditTool
698            .call(
699                &ctx(),
700                json!({
701                    "notebook_path": p.display().to_string(),
702                    "cell_id": "alpha",
703                    "new_source": "x",
704                    "edit_mode": "insert"
705                }),
706            )
707            .await
708            .unwrap_err()
709            .to_string();
710        assert!(err.contains("cell_type"), "got: {err}");
711    }
712
713    #[tokio::test]
714    async fn parse_cell_index_works() {
715        assert_eq!(parse_cell_index("cell-3"), Some(3));
716        assert_eq!(parse_cell_index("cell-0"), Some(0));
717        assert_eq!(parse_cell_index("cell-foo"), None);
718        assert_eq!(parse_cell_index("alpha"), None);
719    }
720
721    #[tokio::test]
722    async fn fresh_cell_id_is_12_chars_lower_alnum() {
723        let id = fresh_cell_id();
724        assert_eq!(id.len(), 12);
725        assert!(id
726            .chars()
727            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
728    }
729
730    #[tokio::test]
731    async fn round_trip_preserves_unknown_fields() {
732        let dir = TempDir::new().unwrap();
733        let p = write_notebook(&dir, "n.ipynb", &sample_notebook());
734        // Touch any cell — the rest must round-trip.
735        NotebookEditTool
736            .call(
737                &ctx(),
738                json!({
739                    "notebook_path": p.display().to_string(),
740                    "cell_id": "alpha",
741                    "new_source": "x = 1",
742                    "edit_mode": "replace"
743                }),
744            )
745            .await
746            .unwrap();
747        let nb = read_notebook(&p);
748        assert_eq!(nb["x_unknown_field"], "must round-trip");
749        assert_eq!(nb["nbformat"], 4);
750        assert_eq!(nb["nbformat_minor"], 5);
751        assert_eq!(nb["metadata"]["kernelspec"]["name"], "python3");
752    }
753}