use std::path::Path;
use serde_json::{json, Map, Value};
use crate::{DataError, Result};
const NBFORMAT_MAJOR: i64 = 4;
const NBFORMAT_MINOR: i64 = 5;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CellInfo {
pub index: usize,
pub cell_type: String,
pub source: String,
pub has_output: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellType {
Code,
Markdown,
Raw,
}
impl CellType {
pub fn from_str_lenient(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"markdown" | "md" => Self::Markdown,
"raw" => Self::Raw,
_ => Self::Code,
}
}
fn as_nb_str(self) -> &'static str {
match self {
Self::Code => "code",
Self::Markdown => "markdown",
Self::Raw => "raw",
}
}
}
pub fn read_notebook(path: &Path) -> Result<Vec<CellInfo>> {
let nb = load_notebook(path)?;
let cells = nb.get("cells").and_then(Value::as_array);
let cells = match cells {
Some(c) => c,
None => {
return Err(DataError::InvalidNotebook(format!(
"{}: nbformat document has no `cells` array",
path.display()
)))
}
};
Ok(cells
.iter()
.enumerate()
.map(|(index, cell)| {
let cell_type = cell
.get("cell_type")
.and_then(Value::as_str)
.unwrap_or("code")
.to_string();
let source = join_source(cell.get("source"));
let has_output = cell
.get("outputs")
.and_then(Value::as_array)
.map(|o| !o.is_empty())
.unwrap_or(false);
CellInfo {
index,
cell_type,
source,
has_output,
}
})
.collect())
}
pub fn insert_cell(
path: &Path,
source: &str,
index: Option<usize>,
cell_type: CellType,
) -> Result<usize> {
let mut nb = load_or_create(path)?;
let cell = build_cell(source, cell_type, None, &[]);
let at = insert_into_cells(&mut nb, cell, index)?;
atomic_write(path, &nb)?;
Ok(at)
}
pub fn persist_cell(path: &Path, source: &str, outputs: Vec<Value>) -> Result<usize> {
let mut nb = load_or_create(path)?;
let cell = build_cell(source, CellType::Code, None, &outputs);
let at = insert_into_cells(&mut nb, cell, None)?;
atomic_write(path, &nb)?;
Ok(at)
}
fn parse_notebook(bytes: &[u8], origin: &str) -> Result<Value> {
let value: Value = serde_json::from_slice(bytes)
.map_err(|e| DataError::InvalidNotebook(format!("{origin}: not valid JSON: {e}")))?;
if !value.is_object() {
return Err(DataError::InvalidNotebook(format!(
"{origin}: top-level value is not a JSON object"
)));
}
match value.get("nbformat").and_then(Value::as_i64) {
Some(NBFORMAT_MAJOR) => Ok(value),
Some(other) => Err(DataError::InvalidNotebook(format!(
"{origin}: nbformat {other} is unsupported (this build reads nbformat {NBFORMAT_MAJOR})"
))),
None => Err(DataError::InvalidNotebook(format!(
"{origin}: missing or non-integer `nbformat` field"
))),
}
}
fn empty_notebook() -> Value {
json!({
"cells": [],
"metadata": {},
"nbformat": NBFORMAT_MAJOR,
"nbformat_minor": NBFORMAT_MINOR,
})
}
fn build_cell(
source: &str,
cell_type: CellType,
execution_count: Option<i64>,
outputs: &[Value],
) -> Value {
let mut cell = Map::new();
cell.insert("cell_type".into(), json!(cell_type.as_nb_str()));
cell.insert("id".into(), json!(new_cell_id()));
cell.insert("metadata".into(), json!({}));
cell.insert("source".into(), json!(source));
if matches!(cell_type, CellType::Code) {
cell.insert(
"execution_count".into(),
execution_count.map(|n| json!(n)).unwrap_or(Value::Null),
);
cell.insert("outputs".into(), Value::Array(outputs.to_vec()));
}
Value::Object(cell)
}
fn insert_into_cells(nb: &mut Value, cell: Value, index: Option<usize>) -> Result<usize> {
let cells = nb
.get_mut("cells")
.and_then(Value::as_array_mut)
.ok_or_else(|| {
DataError::InvalidNotebook("notebook has no `cells` array to insert into".to_string())
})?;
let at = match index {
Some(i) if i < cells.len() => i,
_ => cells.len(),
};
cells.insert(at, cell);
Ok(at)
}
fn join_source(source: Option<&Value>) -> String {
match source {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(lines)) => lines
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.concat(),
_ => String::new(),
}
}
fn new_cell_id() -> String {
uuid::Uuid::new_v4().to_string()
}
fn load_notebook(path: &Path) -> Result<Value> {
let bytes = std::fs::read(path)?;
parse_notebook(&bytes, &path.display().to_string())
}
fn load_or_create(path: &Path) -> Result<Value> {
match std::fs::read(path) {
Ok(bytes) => parse_notebook(&bytes, &path.display().to_string()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(empty_notebook()),
Err(e) => Err(DataError::Io(e)),
}
}
fn atomic_write(path: &Path, nb: &Value) -> Result<()> {
let mut serialized = serde_json::to_vec_pretty(nb)
.map_err(|e| DataError::InvalidNotebook(format!("failed to serialize notebook: {e}")))?;
serialized.push(b'\n');
let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(dir) = dir {
std::fs::create_dir_all(dir)?;
}
let mut tmp = match dir {
Some(dir) => tempfile::NamedTempFile::new_in(dir)?,
None => tempfile::NamedTempFile::new_in(".")?,
};
use std::io::Write as _;
tmp.write_all(&serialized)?;
tmp.flush()?;
tmp.persist(path)
.map_err(|e| DataError::Io(e.error))
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn two_cell_ipynb() -> String {
serde_json::to_string_pretty(&json!({
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": "# Title\n"
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"source": ["print(", "'hi')"],
"outputs": [
{ "output_type": "stream", "name": "stdout", "text": "hi\n" }
]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 5
}))
.unwrap()
}
fn write_fixture(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nb.ipynb");
std::fs::write(&path, contents).unwrap();
(dir, path)
}
#[test]
fn read_two_cell_notebook_summarizes_each_cell() {
let (_dir, path) = write_fixture(&two_cell_ipynb());
let cells = read_notebook(&path).unwrap();
assert_eq!(cells.len(), 2);
assert_eq!(cells[0].index, 0);
assert_eq!(cells[0].cell_type, "markdown");
assert_eq!(cells[0].source, "# Title\n");
assert!(!cells[0].has_output, "markdown has no outputs");
assert_eq!(cells[1].index, 1);
assert_eq!(cells[1].cell_type, "code");
assert_eq!(cells[1].source, "print('hi')");
assert!(cells[1].has_output, "the code cell has a stream output");
}
#[test]
fn read_missing_file_is_clean_error_not_panic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.ipynb");
let err = read_notebook(&path).unwrap_err();
assert!(matches!(err, DataError::Io(_)), "got {err:?}");
}
#[test]
fn read_corrupt_json_is_invalid_notebook_error() {
let (_dir, path) = write_fixture("{ this is not json ");
let err = read_notebook(&path).unwrap_err();
assert!(matches!(err, DataError::InvalidNotebook(_)), "got {err:?}");
}
#[test]
fn read_non_nbformat4_is_invalid_notebook_error() {
let nb = json!({ "cells": [], "metadata": {}, "nbformat": 3, "nbformat_minor": 0 });
let (_dir, path) = write_fixture(&nb.to_string());
let err = read_notebook(&path).unwrap_err();
match err {
DataError::InvalidNotebook(msg) => assert!(msg.contains("nbformat 3"), "{msg}"),
other => panic!("expected InvalidNotebook, got {other:?}"),
}
}
#[test]
fn read_missing_nbformat_field_is_error() {
let nb = json!({ "cells": [], "metadata": {} });
let (_dir, path) = write_fixture(&nb.to_string());
let err = read_notebook(&path).unwrap_err();
assert!(matches!(err, DataError::InvalidNotebook(_)), "got {err:?}");
}
#[test]
fn read_nbformat4_without_cells_array_is_error() {
let nb = json!({ "metadata": {}, "nbformat": 4, "nbformat_minor": 5 });
let (_dir, path) = write_fixture(&nb.to_string());
let err = read_notebook(&path).unwrap_err();
match err {
DataError::InvalidNotebook(msg) => assert!(msg.contains("cells"), "{msg}"),
other => panic!("expected InvalidNotebook, got {other:?}"),
}
}
#[test]
fn insert_at_index_zero_prepends() {
let (_dir, path) = write_fixture(&two_cell_ipynb());
let at = insert_cell(&path, "x = 1", Some(0), CellType::Code).unwrap();
assert_eq!(at, 0);
let cells = read_notebook(&path).unwrap();
assert_eq!(cells.len(), 3);
assert_eq!(cells[0].cell_type, "code");
assert_eq!(cells[0].source, "x = 1");
assert!(!cells[0].has_output);
assert_eq!(cells[1].cell_type, "markdown");
}
#[test]
fn insert_in_the_middle_lands_between() {
let (_dir, path) = write_fixture(&two_cell_ipynb());
let at = insert_cell(&path, "# note", Some(1), CellType::Markdown).unwrap();
assert_eq!(at, 1);
let cells = read_notebook(&path).unwrap();
assert_eq!(cells.len(), 3);
assert_eq!(cells[1].cell_type, "markdown");
assert_eq!(cells[1].source, "# note");
}
#[test]
fn insert_none_appends() {
let (_dir, path) = write_fixture(&two_cell_ipynb());
let at = insert_cell(&path, "y = 2", None, CellType::Code).unwrap();
assert_eq!(at, 2, "append lands at the end");
let cells = read_notebook(&path).unwrap();
assert_eq!(cells.len(), 3);
assert_eq!(cells[2].source, "y = 2");
}
#[test]
fn insert_index_past_end_clamps_to_append() {
let (_dir, path) = write_fixture(&two_cell_ipynb());
let at = insert_cell(&path, "z = 3", Some(999), CellType::Code).unwrap();
assert_eq!(at, 2, "an out-of-range index clamps to an append");
let cells = read_notebook(&path).unwrap();
assert_eq!(cells.len(), 3);
assert_eq!(cells[2].source, "z = 3");
}
#[test]
fn insert_creates_notebook_on_missing_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub").join("fresh.ipynb");
let at = insert_cell(&path, "first()", None, CellType::Code).unwrap();
assert_eq!(at, 0);
let cells = read_notebook(&path).unwrap();
assert_eq!(cells.len(), 1);
assert_eq!(cells[0].source, "first()");
assert_eq!(cells[0].cell_type, "code");
}
#[test]
fn insert_code_cell_has_nbformat_code_scaffolding() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nb.ipynb");
insert_cell(&path, "code()", None, CellType::Code).unwrap();
let raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let cell = &raw["cells"][0];
assert_eq!(cell["cell_type"], "code");
assert!(
cell["execution_count"].is_null(),
"proposed cell has not run"
);
assert_eq!(cell["outputs"], json!([]));
assert!(cell["id"].is_string(), "nbformat 4.5 cell id");
}
#[test]
fn insert_markdown_cell_omits_code_fields() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nb.ipynb");
insert_cell(&path, "# heading", None, CellType::Markdown).unwrap();
let raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let cell = &raw["cells"][0];
assert_eq!(cell["cell_type"], "markdown");
assert!(cell.get("execution_count").is_none());
assert!(cell.get("outputs").is_none());
}
#[test]
fn persist_cell_appends_code_cell_with_source_and_outputs() {
let (_dir, path) = write_fixture(&two_cell_ipynb());
let outputs = vec![
json!({ "output_type": "stream", "name": "stdout", "text": "42\n" }),
json!({
"output_type": "execute_result",
"data": { "text/plain": "42" },
"metadata": {},
"execution_count": 7
}),
];
let at = persist_cell(&path, "print(42); 42", outputs.clone()).unwrap();
assert_eq!(at, 2, "persist appends after the two fixture cells");
let cells = read_notebook(&path).unwrap();
assert_eq!(cells.len(), 3);
assert_eq!(cells[2].cell_type, "code");
assert_eq!(cells[2].source, "print(42); 42");
assert!(
cells[2].has_output,
"persisted outputs make has_output true"
);
let raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let cell = &raw["cells"][2];
assert_eq!(cell["cell_type"], "code");
assert_eq!(cell["outputs"], json!(outputs));
assert!(
cell["execution_count"].is_null(),
"persist leaves the cell-level execution_count null; the count lives \
inside the execute_result output"
);
}
#[test]
fn persist_cell_creates_notebook_on_missing_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("run.ipynb");
let at = persist_cell(&path, "1 + 1", vec![]).unwrap();
assert_eq!(at, 0);
let cells = read_notebook(&path).unwrap();
assert_eq!(cells.len(), 1);
assert_eq!(cells[0].source, "1 + 1");
assert!(!cells[0].has_output);
}
#[test]
fn round_trip_read_insert_read_is_stable() {
let (_dir, path) = write_fixture(&two_cell_ipynb());
let before = read_notebook(&path).unwrap();
assert_eq!(before.len(), 2);
insert_cell(&path, "mid()", Some(1), CellType::Code).unwrap();
let after = read_notebook(&path).unwrap();
assert_eq!(after.len(), 3);
assert_eq!(after[0], before[0]);
assert_eq!(after[0].index, 0);
assert_eq!(after[2].source, before[1].source);
}
#[test]
fn written_notebook_is_valid_nbformat4_and_reparses() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nb.ipynb");
persist_cell(&path, "x", vec![]).unwrap();
let raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(raw["nbformat"], NBFORMAT_MAJOR);
assert_eq!(raw["nbformat_minor"], NBFORMAT_MINOR);
assert!(raw["cells"].is_array());
assert!(raw["metadata"].is_object());
assert!(parse_notebook(&std::fs::read(&path).unwrap(), "nb").is_ok());
}
#[test]
fn written_notebook_ends_with_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nb.ipynb");
persist_cell(&path, "x", vec![]).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(
*bytes.last().unwrap(),
b'\n',
"trailing newline keeps git diffs clean"
);
}
#[test]
fn atomic_write_leaves_no_partial_file_and_no_stray_temp() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nb.ipynb");
persist_cell(&path, "a", vec![]).unwrap();
persist_cell(&path, "b", vec![]).unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(entries.len(), 1, "only the notebook remains: {entries:?}");
assert_eq!(entries[0], std::ffi::OsStr::new("nb.ipynb"));
}
#[test]
fn atomic_write_does_not_clobber_original_on_existing_corrupt_target() {
let (_dir, path) = write_fixture("{ broken json");
let err = persist_cell(&path, "x", vec![]).unwrap_err();
assert!(matches!(err, DataError::InvalidNotebook(_)), "got {err:?}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ broken json");
}
#[test]
fn join_source_handles_string_array_and_absent() {
assert_eq!(join_source(Some(&json!("a\nb"))), "a\nb");
assert_eq!(join_source(Some(&json!(["a", "b", "c"]))), "abc");
assert_eq!(join_source(None), "");
assert_eq!(
join_source(Some(&json!(42))),
"",
"non-string/array → empty"
);
}
#[test]
fn cell_type_from_str_is_lenient() {
assert_eq!(CellType::from_str_lenient("code"), CellType::Code);
assert_eq!(CellType::from_str_lenient("markdown"), CellType::Markdown);
assert_eq!(CellType::from_str_lenient("MD"), CellType::Markdown);
assert_eq!(CellType::from_str_lenient(" Raw "), CellType::Raw);
assert_eq!(CellType::from_str_lenient("gibberish"), CellType::Code);
assert_eq!(CellType::from_str_lenient(""), CellType::Code);
}
#[test]
fn build_cell_code_vs_markdown_shape() {
let code = build_cell("c()", CellType::Code, Some(3), &[json!({"o": 1})]);
assert_eq!(code["cell_type"], "code");
assert_eq!(code["execution_count"], 3);
assert_eq!(code["outputs"], json!([{"o": 1}]));
let md = build_cell("# h", CellType::Markdown, None, &[]);
assert_eq!(md["cell_type"], "markdown");
assert!(md.get("execution_count").is_none());
assert!(md.get("outputs").is_none());
}
#[test]
fn parse_notebook_rejects_non_object_top_level() {
let err = parse_notebook(b"[1, 2, 3]", "arr").unwrap_err();
assert!(matches!(err, DataError::InvalidNotebook(_)));
}
#[test]
fn insert_into_cells_errors_without_cells_array() {
let mut nb = json!({ "nbformat": 4 });
let err = insert_into_cells(&mut nb, json!({}), None).unwrap_err();
assert!(matches!(err, DataError::InvalidNotebook(_)));
}
}