provenant/utils/
notebook.rs1use std::path::Path;
17
18use serde_json::Value;
19
20pub fn is_notebook(path: &Path) -> bool {
22 path.extension()
23 .and_then(|ext| ext.to_str())
24 .is_some_and(|ext| ext.eq_ignore_ascii_case("ipynb"))
25}
26
27pub fn extract_notebook_content(json_text: &str) -> Option<String> {
36 let json: Value = serde_json::from_str(json_text).ok()?;
37 let cells = json.get("cells")?.as_array()?;
38
39 let mut parts: Vec<String> = Vec::new();
40 for cell in cells {
41 if let Some(source) = cell.get("source").and_then(collect_text) {
42 parts.push(source);
43 }
44 if let Some(outputs) = cell.get("outputs").and_then(Value::as_array) {
45 for output in outputs {
46 if let Some(text) = output.get("text").and_then(collect_text) {
48 parts.push(text);
49 }
50 if let Some(text) = output
52 .get("data")
53 .and_then(|data| data.get("text/plain"))
54 .and_then(collect_text)
55 {
56 parts.push(text);
57 }
58 }
59 }
60 }
61
62 if parts.is_empty() {
63 None
64 } else {
65 Some(
69 parts
70 .iter()
71 .map(|part| part.trim_end_matches('\n'))
72 .collect::<Vec<_>>()
73 .join("\n"),
74 )
75 }
76}
77
78fn collect_text(value: &Value) -> Option<String> {
82 match value {
83 Value::String(s) => (!s.is_empty()).then(|| s.clone()),
84 Value::Array(items) => {
85 let combined: String = items.iter().filter_map(Value::as_str).collect();
86 (!combined.is_empty()).then_some(combined)
87 }
88 _ => None,
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use std::path::PathBuf;
96
97 #[test]
98 fn test_is_notebook() {
99 assert!(is_notebook(&PathBuf::from("Analysis.ipynb")));
100 assert!(is_notebook(&PathBuf::from("a/b/NOTEBOOK.IPYNB")));
101 assert!(!is_notebook(&PathBuf::from("script.py")));
102 assert!(!is_notebook(&PathBuf::from("data.json")));
103 }
104
105 #[test]
106 fn test_extract_source_array_and_stream_output() {
107 let json = r#"{
109 "cells": [
110 {"cell_type":"code","source":["@show typeof(C)\n","C[1:10,:]\n"],
111 "outputs":[{"output_type":"stream","name":"stdout",
112 "text":["\t(c) Brendan O'Donoghue, Stanford University, 2012\n"]}]}
113 ]
114 }"#;
115 let text = extract_notebook_content(json).expect("should extract");
116 assert!(text.contains("@show typeof(C)\nC[1:10,:]"));
118 assert!(text.contains("(c) Brendan O'Donoghue, Stanford University, 2012"));
120 assert!(!text.contains("\", \""));
122 }
123
124 #[test]
125 fn test_extract_skips_binary_output_data() {
126 let json = r#"{
127 "cells": [
128 {"cell_type":"code","source":"print(1)",
129 "outputs":[{"output_type":"display_data",
130 "data":{"image/png":"iVBORw0KGgoAAAANSU","text/plain":"<Figure>"}}]}
131 ]
132 }"#;
133 let text = extract_notebook_content(json).expect("should extract");
134 assert!(text.contains("print(1)"));
135 assert!(text.contains("<Figure>"));
136 assert!(!text.contains("iVBORw0KGgoAAAANSU"));
137 }
138
139 #[test]
140 fn test_extract_source_string_form() {
141 let json = r##"{"cells":[{"cell_type":"markdown","source":"# Title\nSome prose"}]}"##;
142 let text = extract_notebook_content(json).expect("should extract");
143 assert_eq!(text, "# Title\nSome prose");
144 }
145
146 #[test]
147 fn test_extract_invalid_or_empty_returns_none() {
148 assert!(extract_notebook_content("not json").is_none());
149 assert!(extract_notebook_content(r#"{"nbformat":4}"#).is_none());
150 assert!(extract_notebook_content(r#"{"cells":[]}"#).is_none());
151 }
152
153 #[test]
154 fn test_extract_no_blank_line_between_parts() {
155 let json = r#"{
157 "cells": [
158 {"cell_type":"code","source":"a()","outputs":[{"output_type":"stream","text":["one\n","two\n"]}]},
159 {"cell_type":"code","source":"b()"}
160 ]
161 }"#;
162 let text = extract_notebook_content(json).expect("should extract");
163 assert!(
164 !text.contains("\n\n"),
165 "no blank lines between parts: {text:?}"
166 );
167 assert!(text.contains("two\nb()"));
168 }
169}