1use std::path::Path;
49
50use serde_json::{json, Map, Value};
51
52use crate::{DataError, Result};
53
54const NBFORMAT_MAJOR: i64 = 4;
56
57const NBFORMAT_MINOR: i64 = 5;
60
61#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
67pub struct CellInfo {
68 pub index: usize,
70 pub cell_type: String,
72 pub source: String,
75 pub has_output: bool,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum CellType {
85 Code,
87 Markdown,
89 Raw,
91}
92
93impl CellType {
94 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 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
115pub 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 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
160pub 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
183pub 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
202fn 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
226fn empty_notebook() -> Value {
229 json!({
230 "cells": [],
231 "metadata": {},
232 "nbformat": NBFORMAT_MAJOR,
233 "nbformat_minor": NBFORMAT_MINOR,
234 })
235}
236
237fn 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
266fn 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 _ => cells.len(),
283 };
284 cells.insert(at, cell);
285 Ok(at)
286}
287
288fn 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
303fn new_cell_id() -> String {
306 uuid::Uuid::new_v4().to_string()
307}
308
309fn load_notebook(path: &Path) -> Result<Value> {
315 let bytes = std::fs::read(path)?;
316 parse_notebook(&bytes, &path.display().to_string())
317}
318
319fn 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
331fn atomic_write(path: &Path, nb: &Value) -> Result<()> {
341 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 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 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 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 #[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 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 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 #[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 assert!(!cells[0].has_output);
486 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 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 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 assert!(cell.get("execution_count").is_none());
561 assert!(cell.get("outputs").is_none());
562 }
563
564 #[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 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 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 assert!(!cells[0].has_output);
614 }
615
616 #[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 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 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 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 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 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ broken json");
684 }
685
686 #[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 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}