Skip to main content

newt_data/
notebook.rs

1//! nbformat `.ipynb` notebook persistence (Phase 21.4).
2//!
3//! The [Centaur Data Scientist](../../../docs/design/centaur-data-scientist.md)
4//! §4.1 *notebook-artifact* bullet: the human notebook must stay a **faithful,
5//! reviewable, git-diffable artifact** of the agent's work. Where 21.3 ran cells
6//! on a live kernel and reported the outputs *into chat*, 21.4 leaves a durable
7//! record *on disk* — `run_cell(persist_to=…)` appends each executed cell (source
8//! + its outputs) to a real `.ipynb` so a human can open it in JupyterLab, see
9//! exactly what ran, and review the diff in a pull request.
10//!
11//! ## What this module is (and is not)
12//!
13//! This is a **pure `serde_json` `.ipynb` manipulator** — it has no kernel, HTTP,
14//! or websocket dependency, so it is a *normal* module (not behind the `kernel`
15//! feature). An `.ipynb` is just JSON in the
16//! [nbformat v4](https://nbformat.readthedocs.io/en/latest/format_description.html)
17//! schema:
18//!
19//! ```json
20//! { "cells": [ … ], "metadata": { … }, "nbformat": 4, "nbformat_minor": 5 }
21//! ```
22//!
23//! The three public operations mirror the three Phase 21.4 MCP tools:
24//!
25//! - [`read_notebook`] → `notebook_read`: a reviewable summary of every cell.
26//! - [`insert_cell`] → `notebook_insert_cell`: **proposes** a cell (it does *not*
27//!   execute it — a code cell goes in with `execution_count: null, outputs: []`).
28//! - [`persist_cell`] → `notebook_persist_executed_cell`: appends a code cell
29//!   carrying its source **and** already-nbformat-shaped outputs. This is the
30//!   low-level primitive `run_cell(persist_to)` calls after a successful run.
31//!
32//! ## Atomicity (the load-bearing review contract)
33//!
34//! Every write goes through [`atomic_write`]: serialize to a temp file in the
35//! **same directory** as the target, then `rename` it over the target. A rename
36//! within one filesystem is atomic, so a reader (a human's JupyterLab autosave
37//! watcher, a `git diff`) never observes a half-written notebook, and a
38//! serialization failure leaves the original untouched — the same discipline the
39//! conversation store uses for `data.db`. The notebook is an artifact under
40//! review; a torn write would be a corrupt artifact.
41//!
42//! ## Errors
43//!
44//! Every fallible operation returns [`crate::Result`] over [`crate::DataError`]:
45//! a missing file on read, a non-nbformat-4 or corrupt file, or an I/O failure
46//! is a clean typed error — never a panic.
47
48use std::path::Path;
49
50use serde_json::{json, Map, Value};
51
52use crate::{DataError, Result};
53
54/// The nbformat major version this module reads and writes (`nbformat: 4`).
55const NBFORMAT_MAJOR: i64 = 4;
56
57/// The `nbformat_minor` we stamp onto notebooks we create (4.5 is the modern
58/// default — it adds per-cell `id`s, which we mint as fresh UUIDs).
59const NBFORMAT_MINOR: i64 = 5;
60
61/// One cell's reviewable summary, returned by [`read_notebook`].
62///
63/// Deliberately flat and serde-serializable so the MCP `notebook_read` handler
64/// can ferry the `Vec<CellInfo>` straight across the JSON-RPC boundary as pretty
65/// JSON — a human-scannable table of what the notebook holds.
66#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
67pub struct CellInfo {
68    /// Zero-based position of the cell in the notebook's `cells` array.
69    pub index: usize,
70    /// The cell kind: `code`, `markdown`, or `raw`.
71    pub cell_type: String,
72    /// The cell's `source`, joined into one string (nbformat stores source as
73    /// either a single string or an array of line-strings; both fold to this).
74    pub source: String,
75    /// `true` if a code cell has at least one entry in its `outputs` array.
76    pub has_output: bool,
77}
78
79/// The kind of cell [`insert_cell`] builds. A `code` cell gets the executable
80/// scaffolding (`execution_count: null`, `outputs: []`); a `markdown` or `raw`
81/// cell carries only `source`. Anything the caller passes that is not `markdown`
82/// or `raw` is treated as `code` (the safe, executable default).
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum CellType {
85    /// An executable code cell (`execution_count: null`, empty `outputs`).
86    Code,
87    /// A markdown prose cell.
88    Markdown,
89    /// A raw (verbatim) cell.
90    Raw,
91}
92
93impl CellType {
94    /// Map a free-form string (an MCP argument) to a [`CellType`], defaulting to
95    /// [`CellType::Code`] for anything unrecognized — the executable default the
96    /// agent reaches for most.
97    pub fn from_str_lenient(s: &str) -> Self {
98        match s.trim().to_ascii_lowercase().as_str() {
99            "markdown" | "md" => Self::Markdown,
100            "raw" => Self::Raw,
101            _ => Self::Code,
102        }
103    }
104
105    /// The nbformat `cell_type` string this maps to.
106    fn as_nb_str(self) -> &'static str {
107        match self {
108            Self::Code => "code",
109            Self::Markdown => "markdown",
110            Self::Raw => "raw",
111        }
112    }
113}
114
115/// Read and parse the `.ipynb` at `path`, returning a [`CellInfo`] summary of
116/// every cell (Phase 21.4 `notebook_read`).
117///
118/// A missing file, a file that is not valid JSON, or a JSON document whose
119/// `nbformat` is not `4` is a clean [`DataError`] — never a panic. (A genuinely
120/// empty `cells` array is a *valid* notebook and yields an empty `Vec`.)
121pub fn read_notebook(path: &Path) -> Result<Vec<CellInfo>> {
122    let nb = load_notebook(path)?;
123    let cells = nb.get("cells").and_then(Value::as_array);
124    let cells = match cells {
125        Some(c) => c,
126        // nbformat requires a `cells` array; its absence is a malformed notebook.
127        None => {
128            return Err(DataError::InvalidNotebook(format!(
129                "{}: nbformat document has no `cells` array",
130                path.display()
131            )))
132        }
133    };
134
135    Ok(cells
136        .iter()
137        .enumerate()
138        .map(|(index, cell)| {
139            let cell_type = cell
140                .get("cell_type")
141                .and_then(Value::as_str)
142                .unwrap_or("code")
143                .to_string();
144            let source = join_source(cell.get("source"));
145            let has_output = cell
146                .get("outputs")
147                .and_then(Value::as_array)
148                .map(|o| !o.is_empty())
149                .unwrap_or(false);
150            CellInfo {
151                index,
152                cell_type,
153                source,
154                has_output,
155            }
156        })
157        .collect())
158}
159
160/// Build a fresh nbformat cell from `source` and insert it into the notebook at
161/// `path`, **proposing** it without executing (Phase 21.4 `notebook_insert_cell`).
162///
163/// `index` is the zero-based position to insert at; `None` appends. An index past
164/// the end clamps to an append (so the cell is never dropped). A `code` cell is
165/// inserted with `execution_count: null` and `outputs: []` — it has *not* run,
166/// and a reviewer can tell at a glance. If `path` does not yet exist, a minimal
167/// valid nbformat-4 notebook is created first. The write is [atomic](atomic_write).
168///
169/// Returns the index the cell actually landed at.
170pub fn insert_cell(
171    path: &Path,
172    source: &str,
173    index: Option<usize>,
174    cell_type: CellType,
175) -> Result<usize> {
176    let mut nb = load_or_create(path)?;
177    let cell = build_cell(source, cell_type, None, &[]);
178    let at = insert_into_cells(&mut nb, cell, index)?;
179    atomic_write(path, &nb)?;
180    Ok(at)
181}
182
183/// Append a **code** cell carrying `source` and the given already-nbformat-shaped
184/// `outputs` to the notebook at `path` (Phase 21.4 `notebook_persist_executed_cell`).
185///
186/// This is the low-level primitive `run_cell(persist_to)` calls after a
187/// successful run: the `outputs` are the converted [`CellRun`](crate::kernel::CellRun)
188/// (stream/execute_result/display_data/error Values) so the persisted notebook
189/// renders exactly what the cell produced — a faithful artifact. If `path` does
190/// not exist, a minimal nbformat-4 notebook is created first. The write is
191/// [atomic](atomic_write).
192///
193/// Returns the appended index.
194pub fn persist_cell(path: &Path, source: &str, outputs: Vec<Value>) -> Result<usize> {
195    let mut nb = load_or_create(path)?;
196    let cell = build_cell(source, CellType::Code, None, &outputs);
197    let at = insert_into_cells(&mut nb, cell, None)?;
198    atomic_write(path, &nb)?;
199    Ok(at)
200}
201
202// ── pure helpers (no I/O — unit-testable in isolation) ──────────────────────
203
204/// Parse raw `.ipynb` bytes into a notebook [`Value`], validating that it is an
205/// nbformat-4 JSON object. Pulled out (no filesystem touch) so the parse +
206/// validation rules are unit-testable against in-memory strings.
207fn parse_notebook(bytes: &[u8], origin: &str) -> Result<Value> {
208    let value: Value = serde_json::from_slice(bytes)
209        .map_err(|e| DataError::InvalidNotebook(format!("{origin}: not valid JSON: {e}")))?;
210    if !value.is_object() {
211        return Err(DataError::InvalidNotebook(format!(
212            "{origin}: top-level value is not a JSON object"
213        )));
214    }
215    match value.get("nbformat").and_then(Value::as_i64) {
216        Some(NBFORMAT_MAJOR) => Ok(value),
217        Some(other) => Err(DataError::InvalidNotebook(format!(
218            "{origin}: nbformat {other} is unsupported (this build reads nbformat {NBFORMAT_MAJOR})"
219        ))),
220        None => Err(DataError::InvalidNotebook(format!(
221            "{origin}: missing or non-integer `nbformat` field"
222        ))),
223    }
224}
225
226/// A minimal but fully valid empty nbformat-4 notebook (`cells: []`). Used when
227/// `persist_to` / `insert_cell` targets a path that does not yet exist.
228fn empty_notebook() -> Value {
229    json!({
230        "cells": [],
231        "metadata": {},
232        "nbformat": NBFORMAT_MAJOR,
233        "nbformat_minor": NBFORMAT_MINOR,
234    })
235}
236
237/// Build a fresh nbformat cell.
238///
239/// A `code` cell carries `execution_count` (the supplied value, or `null` when
240/// `None`) and an `outputs` array (the supplied outputs, or empty); markdown and
241/// raw cells carry neither (those fields are invalid for them in nbformat). Every
242/// cell gets a fresh `id` (nbformat 4.5) and an empty `metadata` object, and the
243/// `source` is stored as a single string (a valid nbformat shape — readers accept
244/// both a string and a line-array).
245fn build_cell(
246    source: &str,
247    cell_type: CellType,
248    execution_count: Option<i64>,
249    outputs: &[Value],
250) -> Value {
251    let mut cell = Map::new();
252    cell.insert("cell_type".into(), json!(cell_type.as_nb_str()));
253    cell.insert("id".into(), json!(new_cell_id()));
254    cell.insert("metadata".into(), json!({}));
255    cell.insert("source".into(), json!(source));
256    if matches!(cell_type, CellType::Code) {
257        cell.insert(
258            "execution_count".into(),
259            execution_count.map(|n| json!(n)).unwrap_or(Value::Null),
260        );
261        cell.insert("outputs".into(), Value::Array(outputs.to_vec()));
262    }
263    Value::Object(cell)
264}
265
266/// Insert `cell` into the notebook's `cells` array at `index` (or append when
267/// `None`); an out-of-range index clamps to an append. Returns the landed index.
268///
269/// Errors only if the document has no `cells` array (a malformed notebook that
270/// slipped past validation) — which cannot happen for a notebook this module
271/// created or loaded through [`load_or_create`].
272fn insert_into_cells(nb: &mut Value, cell: Value, index: Option<usize>) -> Result<usize> {
273    let cells = nb
274        .get_mut("cells")
275        .and_then(Value::as_array_mut)
276        .ok_or_else(|| {
277            DataError::InvalidNotebook("notebook has no `cells` array to insert into".to_string())
278        })?;
279    let at = match index {
280        Some(i) if i < cells.len() => i,
281        // None, or an index at/past the end, appends (never drops the cell).
282        _ => cells.len(),
283    };
284    cells.insert(at, cell);
285    Ok(at)
286}
287
288/// Join an nbformat `source` field (a string, an array of line-strings, or
289/// absent) into one string. nbformat stores source either way; readers must
290/// accept both, so we normalize on read.
291fn join_source(source: Option<&Value>) -> String {
292    match source {
293        Some(Value::String(s)) => s.clone(),
294        Some(Value::Array(lines)) => lines
295            .iter()
296            .filter_map(Value::as_str)
297            .collect::<Vec<_>>()
298            .concat(),
299        _ => String::new(),
300    }
301}
302
303/// A fresh nbformat 4.5 cell `id` (a v4 UUID string). Stamped on every cell we
304/// build so the notebook conforms to nbformat 4.5 and cell ids stay unique.
305fn new_cell_id() -> String {
306    uuid::Uuid::new_v4().to_string()
307}
308
309// ── filesystem helpers ──────────────────────────────────────────────────────
310
311/// Load + parse the notebook at `path`, surfacing a missing file as a clean
312/// [`DataError`] (not a panic). Used by [`read_notebook`], which must *not*
313/// silently create a notebook for a path the human did not ask to read.
314fn load_notebook(path: &Path) -> Result<Value> {
315    let bytes = std::fs::read(path)?;
316    parse_notebook(&bytes, &path.display().to_string())
317}
318
319/// Load the notebook at `path`, or return a fresh empty nbformat-4 notebook if
320/// the file does not exist (so `persist_to` / `insert_cell` work on a brand-new
321/// path). A file that *exists* but is corrupt or non-nbformat-4 is still a clean
322/// error — only `NotFound` falls through to the empty notebook.
323fn load_or_create(path: &Path) -> Result<Value> {
324    match std::fs::read(path) {
325        Ok(bytes) => parse_notebook(&bytes, &path.display().to_string()),
326        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(empty_notebook()),
327        Err(e) => Err(DataError::Io(e)),
328    }
329}
330
331/// Serialize `nb` to `path` **atomically**: write a temp file in the same
332/// directory, then `rename` it over the target.
333///
334/// Same-directory placement guarantees the `rename` is within one filesystem (so
335/// it is atomic and cannot fail with a cross-device error), and a serialization
336/// or partial-write failure leaves the original notebook untouched — a reader
337/// never sees a half-written `.ipynb`. This mirrors the conversation store's
338/// durable-write discipline (Phase 21.4 — the notebook is an artifact under
339/// review; a torn write would be a corrupt artifact).
340fn atomic_write(path: &Path, nb: &Value) -> Result<()> {
341    // Pretty JSON with a trailing newline: nbformat tools and JupyterLab write
342    // pretty-printed notebooks, and a trailing newline keeps `git diff` clean.
343    let mut serialized = serde_json::to_vec_pretty(nb)
344        .map_err(|e| DataError::InvalidNotebook(format!("failed to serialize notebook: {e}")))?;
345    serialized.push(b'\n');
346
347    let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
348    if let Some(dir) = dir {
349        std::fs::create_dir_all(dir)?;
350    }
351
352    // Temp file in the SAME directory as the target so the rename stays
353    // intra-filesystem. `NamedTempFile::persist` does the atomic rename.
354    let mut tmp = match dir {
355        Some(dir) => tempfile::NamedTempFile::new_in(dir)?,
356        None => tempfile::NamedTempFile::new_in(".")?,
357    };
358    use std::io::Write as _;
359    tmp.write_all(&serialized)?;
360    tmp.flush()?;
361    tmp.persist(path)
362        .map_err(|e| DataError::Io(e.error))
363        .map(|_| ())
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use serde_json::json;
370
371    /// A 2-cell nbformat-4 notebook fixture: a markdown cell and a code cell
372    /// (the code cell carries one stream output, so `has_output` is true).
373    fn two_cell_ipynb() -> String {
374        serde_json::to_string_pretty(&json!({
375            "cells": [
376                {
377                    "cell_type": "markdown",
378                    "metadata": {},
379                    "source": "# Title\n"
380                },
381                {
382                    "cell_type": "code",
383                    "execution_count": 1,
384                    "metadata": {},
385                    "source": ["print(", "'hi')"],
386                    "outputs": [
387                        { "output_type": "stream", "name": "stdout", "text": "hi\n" }
388                    ]
389                }
390            ],
391            "metadata": {},
392            "nbformat": 4,
393            "nbformat_minor": 5
394        }))
395        .unwrap()
396    }
397
398    /// Write `contents` to `path.ipynb` under a fresh tempdir; returns the dir
399    /// (kept alive by the caller) and the file path.
400    fn write_fixture(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) {
401        let dir = tempfile::tempdir().unwrap();
402        let path = dir.path().join("nb.ipynb");
403        std::fs::write(&path, contents).unwrap();
404        (dir, path)
405    }
406
407    // ── read ────────────────────────────────────────────────────────────────
408
409    #[test]
410    fn read_two_cell_notebook_summarizes_each_cell() {
411        let (_dir, path) = write_fixture(&two_cell_ipynb());
412        let cells = read_notebook(&path).unwrap();
413        assert_eq!(cells.len(), 2);
414
415        assert_eq!(cells[0].index, 0);
416        assert_eq!(cells[0].cell_type, "markdown");
417        assert_eq!(cells[0].source, "# Title\n");
418        assert!(!cells[0].has_output, "markdown has no outputs");
419
420        assert_eq!(cells[1].index, 1);
421        assert_eq!(cells[1].cell_type, "code");
422        // The line-array source is joined.
423        assert_eq!(cells[1].source, "print('hi')");
424        assert!(cells[1].has_output, "the code cell has a stream output");
425    }
426
427    #[test]
428    fn read_missing_file_is_clean_error_not_panic() {
429        let dir = tempfile::tempdir().unwrap();
430        let path = dir.path().join("does-not-exist.ipynb");
431        let err = read_notebook(&path).unwrap_err();
432        assert!(matches!(err, DataError::Io(_)), "got {err:?}");
433    }
434
435    #[test]
436    fn read_corrupt_json_is_invalid_notebook_error() {
437        let (_dir, path) = write_fixture("{ this is not json ");
438        let err = read_notebook(&path).unwrap_err();
439        assert!(matches!(err, DataError::InvalidNotebook(_)), "got {err:?}");
440    }
441
442    #[test]
443    fn read_non_nbformat4_is_invalid_notebook_error() {
444        // Valid JSON, valid-looking notebook, but nbformat 3.
445        let nb = json!({ "cells": [], "metadata": {}, "nbformat": 3, "nbformat_minor": 0 });
446        let (_dir, path) = write_fixture(&nb.to_string());
447        let err = read_notebook(&path).unwrap_err();
448        match err {
449            DataError::InvalidNotebook(msg) => assert!(msg.contains("nbformat 3"), "{msg}"),
450            other => panic!("expected InvalidNotebook, got {other:?}"),
451        }
452    }
453
454    #[test]
455    fn read_missing_nbformat_field_is_error() {
456        let nb = json!({ "cells": [], "metadata": {} });
457        let (_dir, path) = write_fixture(&nb.to_string());
458        let err = read_notebook(&path).unwrap_err();
459        assert!(matches!(err, DataError::InvalidNotebook(_)), "got {err:?}");
460    }
461
462    #[test]
463    fn read_nbformat4_without_cells_array_is_error() {
464        let nb = json!({ "metadata": {}, "nbformat": 4, "nbformat_minor": 5 });
465        let (_dir, path) = write_fixture(&nb.to_string());
466        let err = read_notebook(&path).unwrap_err();
467        match err {
468            DataError::InvalidNotebook(msg) => assert!(msg.contains("cells"), "{msg}"),
469            other => panic!("expected InvalidNotebook, got {other:?}"),
470        }
471    }
472
473    // ── insert ────────────────────────────────────────────────────────────────
474
475    #[test]
476    fn insert_at_index_zero_prepends() {
477        let (_dir, path) = write_fixture(&two_cell_ipynb());
478        let at = insert_cell(&path, "x = 1", Some(0), CellType::Code).unwrap();
479        assert_eq!(at, 0);
480        let cells = read_notebook(&path).unwrap();
481        assert_eq!(cells.len(), 3);
482        assert_eq!(cells[0].cell_type, "code");
483        assert_eq!(cells[0].source, "x = 1");
484        // The proposed cell has NOT run: no outputs.
485        assert!(!cells[0].has_output);
486        // The original first cell is now at index 1.
487        assert_eq!(cells[1].cell_type, "markdown");
488    }
489
490    #[test]
491    fn insert_in_the_middle_lands_between() {
492        let (_dir, path) = write_fixture(&two_cell_ipynb());
493        let at = insert_cell(&path, "# note", Some(1), CellType::Markdown).unwrap();
494        assert_eq!(at, 1);
495        let cells = read_notebook(&path).unwrap();
496        assert_eq!(cells.len(), 3);
497        assert_eq!(cells[1].cell_type, "markdown");
498        assert_eq!(cells[1].source, "# note");
499    }
500
501    #[test]
502    fn insert_none_appends() {
503        let (_dir, path) = write_fixture(&two_cell_ipynb());
504        let at = insert_cell(&path, "y = 2", None, CellType::Code).unwrap();
505        assert_eq!(at, 2, "append lands at the end");
506        let cells = read_notebook(&path).unwrap();
507        assert_eq!(cells.len(), 3);
508        assert_eq!(cells[2].source, "y = 2");
509    }
510
511    #[test]
512    fn insert_index_past_end_clamps_to_append() {
513        let (_dir, path) = write_fixture(&two_cell_ipynb());
514        let at = insert_cell(&path, "z = 3", Some(999), CellType::Code).unwrap();
515        assert_eq!(at, 2, "an out-of-range index clamps to an append");
516        let cells = read_notebook(&path).unwrap();
517        assert_eq!(cells.len(), 3);
518        assert_eq!(cells[2].source, "z = 3");
519    }
520
521    #[test]
522    fn insert_creates_notebook_on_missing_path() {
523        let dir = tempfile::tempdir().unwrap();
524        // A nested path that does not exist yet: the parent dir is created too.
525        let path = dir.path().join("sub").join("fresh.ipynb");
526        let at = insert_cell(&path, "first()", None, CellType::Code).unwrap();
527        assert_eq!(at, 0);
528        let cells = read_notebook(&path).unwrap();
529        assert_eq!(cells.len(), 1);
530        assert_eq!(cells[0].source, "first()");
531        assert_eq!(cells[0].cell_type, "code");
532    }
533
534    #[test]
535    fn insert_code_cell_has_nbformat_code_scaffolding() {
536        let dir = tempfile::tempdir().unwrap();
537        let path = dir.path().join("nb.ipynb");
538        insert_cell(&path, "code()", None, CellType::Code).unwrap();
539        // Inspect the raw JSON: a code cell must carry execution_count:null + [].
540        let raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
541        let cell = &raw["cells"][0];
542        assert_eq!(cell["cell_type"], "code");
543        assert!(
544            cell["execution_count"].is_null(),
545            "proposed cell has not run"
546        );
547        assert_eq!(cell["outputs"], json!([]));
548        assert!(cell["id"].is_string(), "nbformat 4.5 cell id");
549    }
550
551    #[test]
552    fn insert_markdown_cell_omits_code_fields() {
553        let dir = tempfile::tempdir().unwrap();
554        let path = dir.path().join("nb.ipynb");
555        insert_cell(&path, "# heading", None, CellType::Markdown).unwrap();
556        let raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
557        let cell = &raw["cells"][0];
558        assert_eq!(cell["cell_type"], "markdown");
559        // execution_count / outputs are invalid on markdown cells — absent.
560        assert!(cell.get("execution_count").is_none());
561        assert!(cell.get("outputs").is_none());
562    }
563
564    // ── persist ─────────────────────────────────────────────────────────────
565
566    #[test]
567    fn persist_cell_appends_code_cell_with_source_and_outputs() {
568        let (_dir, path) = write_fixture(&two_cell_ipynb());
569        let outputs = vec![
570            json!({ "output_type": "stream", "name": "stdout", "text": "42\n" }),
571            json!({
572                "output_type": "execute_result",
573                "data": { "text/plain": "42" },
574                "metadata": {},
575                "execution_count": 7
576            }),
577        ];
578        let at = persist_cell(&path, "print(42); 42", outputs.clone()).unwrap();
579        assert_eq!(at, 2, "persist appends after the two fixture cells");
580
581        // Round-trip through read for the summary view.
582        let cells = read_notebook(&path).unwrap();
583        assert_eq!(cells.len(), 3);
584        assert_eq!(cells[2].cell_type, "code");
585        assert_eq!(cells[2].source, "print(42); 42");
586        assert!(
587            cells[2].has_output,
588            "persisted outputs make has_output true"
589        );
590
591        // Inspect the raw cell: the outputs match the nbformat we passed in.
592        let raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
593        let cell = &raw["cells"][2];
594        assert_eq!(cell["cell_type"], "code");
595        assert_eq!(cell["outputs"], json!(outputs));
596        assert!(
597            cell["execution_count"].is_null(),
598            "persist leaves the cell-level execution_count null; the count lives \
599             inside the execute_result output"
600        );
601    }
602
603    #[test]
604    fn persist_cell_creates_notebook_on_missing_path() {
605        let dir = tempfile::tempdir().unwrap();
606        let path = dir.path().join("run.ipynb");
607        let at = persist_cell(&path, "1 + 1", vec![]).unwrap();
608        assert_eq!(at, 0);
609        let cells = read_notebook(&path).unwrap();
610        assert_eq!(cells.len(), 1);
611        assert_eq!(cells[0].source, "1 + 1");
612        // No outputs passed → empty outputs array, has_output false.
613        assert!(!cells[0].has_output);
614    }
615
616    // ── round-trip + atomicity ──────────────────────────────────────────────
617
618    #[test]
619    fn round_trip_read_insert_read_is_stable() {
620        let (_dir, path) = write_fixture(&two_cell_ipynb());
621        let before = read_notebook(&path).unwrap();
622        assert_eq!(before.len(), 2);
623        insert_cell(&path, "mid()", Some(1), CellType::Code).unwrap();
624        let after = read_notebook(&path).unwrap();
625        assert_eq!(after.len(), 3);
626        // The two original cells survive unchanged around the insert.
627        assert_eq!(after[0], before[0]);
628        assert_eq!(after[0].index, 0);
629        assert_eq!(after[2].source, before[1].source);
630    }
631
632    #[test]
633    fn written_notebook_is_valid_nbformat4_and_reparses() {
634        let dir = tempfile::tempdir().unwrap();
635        let path = dir.path().join("nb.ipynb");
636        persist_cell(&path, "x", vec![]).unwrap();
637        let raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
638        assert_eq!(raw["nbformat"], NBFORMAT_MAJOR);
639        assert_eq!(raw["nbformat_minor"], NBFORMAT_MINOR);
640        assert!(raw["cells"].is_array());
641        assert!(raw["metadata"].is_object());
642        // And the parse-validation accepts what we wrote.
643        assert!(parse_notebook(&std::fs::read(&path).unwrap(), "nb").is_ok());
644    }
645
646    #[test]
647    fn written_notebook_ends_with_trailing_newline() {
648        let dir = tempfile::tempdir().unwrap();
649        let path = dir.path().join("nb.ipynb");
650        persist_cell(&path, "x", vec![]).unwrap();
651        let bytes = std::fs::read(&path).unwrap();
652        assert_eq!(
653            *bytes.last().unwrap(),
654            b'\n',
655            "trailing newline keeps git diffs clean"
656        );
657    }
658
659    #[test]
660    fn atomic_write_leaves_no_partial_file_and_no_stray_temp() {
661        // A successful write leaves exactly one file (the notebook) in the dir —
662        // the temp file was renamed away, not left behind.
663        let dir = tempfile::tempdir().unwrap();
664        let path = dir.path().join("nb.ipynb");
665        persist_cell(&path, "a", vec![]).unwrap();
666        persist_cell(&path, "b", vec![]).unwrap();
667        let entries: Vec<_> = std::fs::read_dir(dir.path())
668            .unwrap()
669            .map(|e| e.unwrap().file_name())
670            .collect();
671        assert_eq!(entries.len(), 1, "only the notebook remains: {entries:?}");
672        assert_eq!(entries[0], std::ffi::OsStr::new("nb.ipynb"));
673    }
674
675    #[test]
676    fn atomic_write_does_not_clobber_original_on_existing_corrupt_target() {
677        // If the target exists but is corrupt, load_or_create errors BEFORE any
678        // write — the corrupt file is left exactly as it was (not truncated).
679        let (_dir, path) = write_fixture("{ broken json");
680        let err = persist_cell(&path, "x", vec![]).unwrap_err();
681        assert!(matches!(err, DataError::InvalidNotebook(_)), "got {err:?}");
682        // The original bytes are untouched.
683        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ broken json");
684    }
685
686    // ── pure-helper unit tests ──────────────────────────────────────────────
687
688    #[test]
689    fn join_source_handles_string_array_and_absent() {
690        assert_eq!(join_source(Some(&json!("a\nb"))), "a\nb");
691        assert_eq!(join_source(Some(&json!(["a", "b", "c"]))), "abc");
692        assert_eq!(join_source(None), "");
693        assert_eq!(
694            join_source(Some(&json!(42))),
695            "",
696            "non-string/array → empty"
697        );
698    }
699
700    #[test]
701    fn cell_type_from_str_is_lenient() {
702        assert_eq!(CellType::from_str_lenient("code"), CellType::Code);
703        assert_eq!(CellType::from_str_lenient("markdown"), CellType::Markdown);
704        assert_eq!(CellType::from_str_lenient("MD"), CellType::Markdown);
705        assert_eq!(CellType::from_str_lenient(" Raw "), CellType::Raw);
706        // Anything unrecognized → Code (the executable default).
707        assert_eq!(CellType::from_str_lenient("gibberish"), CellType::Code);
708        assert_eq!(CellType::from_str_lenient(""), CellType::Code);
709    }
710
711    #[test]
712    fn build_cell_code_vs_markdown_shape() {
713        let code = build_cell("c()", CellType::Code, Some(3), &[json!({"o": 1})]);
714        assert_eq!(code["cell_type"], "code");
715        assert_eq!(code["execution_count"], 3);
716        assert_eq!(code["outputs"], json!([{"o": 1}]));
717
718        let md = build_cell("# h", CellType::Markdown, None, &[]);
719        assert_eq!(md["cell_type"], "markdown");
720        assert!(md.get("execution_count").is_none());
721        assert!(md.get("outputs").is_none());
722    }
723
724    #[test]
725    fn parse_notebook_rejects_non_object_top_level() {
726        let err = parse_notebook(b"[1, 2, 3]", "arr").unwrap_err();
727        assert!(matches!(err, DataError::InvalidNotebook(_)));
728    }
729
730    #[test]
731    fn insert_into_cells_errors_without_cells_array() {
732        let mut nb = json!({ "nbformat": 4 });
733        let err = insert_into_cells(&mut nb, json!({}), None).unwrap_err();
734        assert!(matches!(err, DataError::InvalidNotebook(_)));
735    }
736}