venus_sync/
ipynb.rs

1//! Jupyter notebook (.ipynb) generation.
2//!
3//! Converts Venus cells to Jupyter notebook format.
4
5use std::fs;
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::{SyncError, SyncResult};
11use crate::outputs::OutputCache;
12use crate::parser::{CellType, NotebookCell, NotebookMetadata};
13
14/// A Jupyter notebook.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct JupyterNotebook {
17    /// Notebook metadata
18    pub metadata: JupyterMetadata,
19
20    /// Format version (always 4)
21    pub nbformat: u32,
22
23    /// Minor format version
24    pub nbformat_minor: u32,
25
26    /// Notebook cells
27    pub cells: Vec<JupyterCell>,
28}
29
30/// Jupyter notebook metadata.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct JupyterMetadata {
33    /// Kernel specification
34    pub kernelspec: KernelSpec,
35
36    /// Language info
37    pub language_info: LanguageInfo,
38
39    /// Venus-specific metadata for round-trip
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub venus: Option<VenusMetadata>,
42}
43
44/// Kernel specification.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct KernelSpec {
47    /// Display name
48    pub display_name: String,
49
50    /// Language
51    pub language: String,
52
53    /// Kernel name
54    pub name: String,
55}
56
57/// Language information.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct LanguageInfo {
60    /// File extension
61    pub file_extension: String,
62
63    /// MIME type
64    pub mimetype: String,
65
66    /// Language name
67    pub name: String,
68
69    /// Version
70    pub version: String,
71}
72
73/// Venus-specific metadata.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct VenusMetadata {
76    /// Source file path
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub source_file: Option<String>,
79
80    /// Venus version
81    pub version: String,
82}
83
84/// A Jupyter cell.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct JupyterCell {
87    /// Cell type
88    pub cell_type: String,
89
90    /// Cell metadata
91    pub metadata: CellMetadata,
92
93    /// Cell source (lines)
94    pub source: Vec<String>,
95
96    /// Cell outputs (for code cells)
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub outputs: Option<Vec<CellOutput>>,
99
100    /// Execution count (for code cells)
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub execution_count: Option<u32>,
103}
104
105/// Cell metadata.
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
107pub struct CellMetadata {
108    /// Venus cell name
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub venus_cell: Option<String>,
111
112    /// Whether the cell is editable
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub editable: Option<bool>,
115
116    /// Tags
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub tags: Option<Vec<String>>,
119}
120
121/// Cell output.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(tag = "output_type")]
124pub enum CellOutput {
125    /// Standard output/error
126    #[serde(rename = "stream")]
127    Stream { name: String, text: Vec<String> },
128
129    /// Rich display data
130    #[serde(rename = "execute_result")]
131    ExecuteResult {
132        execution_count: u32,
133        data: OutputData,
134        metadata: serde_json::Value,
135    },
136
137    /// Display data
138    #[serde(rename = "display_data")]
139    DisplayData {
140        data: OutputData,
141        metadata: serde_json::Value,
142    },
143
144    /// Error output
145    #[serde(rename = "error")]
146    Error {
147        ename: String,
148        evalue: String,
149        traceback: Vec<String>,
150    },
151}
152
153/// Output data with multiple representations.
154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155pub struct OutputData {
156    /// Plain text
157    #[serde(rename = "text/plain", skip_serializing_if = "Option::is_none")]
158    pub text_plain: Option<Vec<String>>,
159
160    /// HTML
161    #[serde(rename = "text/html", skip_serializing_if = "Option::is_none")]
162    pub text_html: Option<Vec<String>>,
163
164    /// PNG image (base64)
165    #[serde(rename = "image/png", skip_serializing_if = "Option::is_none")]
166    pub image_png: Option<String>,
167
168    /// SVG image
169    #[serde(rename = "image/svg+xml", skip_serializing_if = "Option::is_none")]
170    pub image_svg: Option<Vec<String>>,
171
172    /// JSON data
173    #[serde(rename = "application/json", skip_serializing_if = "Option::is_none")]
174    pub application_json: Option<serde_json::Value>,
175}
176
177impl JupyterNotebook {
178    /// Create a new empty notebook.
179    pub fn new() -> Self {
180        Self {
181            metadata: JupyterMetadata::default(),
182            nbformat: 4,
183            nbformat_minor: 5,
184            cells: Vec::new(),
185        }
186    }
187
188    /// Write the notebook to a file.
189    pub fn write_to_file(&self, path: impl AsRef<Path>) -> SyncResult<()> {
190        let path = path.as_ref();
191        let json = serde_json::to_string_pretty(self)?;
192        fs::write(path, json).map_err(|e| SyncError::WriteError {
193            path: path.to_path_buf(),
194            message: e.to_string(),
195        })?;
196        Ok(())
197    }
198
199    /// Read a notebook from a file.
200    pub fn read_from_file(path: impl AsRef<Path>) -> SyncResult<Self> {
201        let path = path.as_ref();
202        let content = fs::read_to_string(path).map_err(|e| SyncError::ReadError {
203            path: path.to_path_buf(),
204            message: e.to_string(),
205        })?;
206        let notebook: Self = serde_json::from_str(&content)?;
207        Ok(notebook)
208    }
209}
210
211impl Default for JupyterNotebook {
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217impl Default for JupyterMetadata {
218    fn default() -> Self {
219        Self {
220            kernelspec: KernelSpec {
221                display_name: "Rust (Venus)".to_string(),
222                language: "rust".to_string(),
223                name: "venus".to_string(),
224            },
225            language_info: LanguageInfo {
226                file_extension: ".rs".to_string(),
227                mimetype: "text/rust".to_string(),
228                name: "rust".to_string(),
229                version: "1.0".to_string(),
230            },
231            venus: Some(VenusMetadata {
232                source_file: None,
233                version: env!("CARGO_PKG_VERSION").to_string(),
234            }),
235        }
236    }
237}
238
239/// Generator for Jupyter notebooks from Venus cells.
240pub struct IpynbGenerator {
241    /// Execution counter
242    execution_count: u32,
243}
244
245impl IpynbGenerator {
246    /// Create a new generator.
247    pub fn new() -> Self {
248        Self { execution_count: 1 }
249    }
250
251    /// Generate a Jupyter notebook from Venus cells.
252    pub fn generate(
253        &mut self,
254        metadata: &NotebookMetadata,
255        cells: &[NotebookCell],
256        cache: Option<&OutputCache>,
257    ) -> SyncResult<JupyterNotebook> {
258        let mut notebook = JupyterNotebook::new();
259
260        // Set metadata
261        if let Some(title) = &metadata.title {
262            notebook.metadata.venus = Some(VenusMetadata {
263                source_file: Some(title.clone()),
264                version: env!("CARGO_PKG_VERSION").to_string(),
265            });
266        }
267
268        // Convert cells
269        for cell in cells {
270            let jupyter_cell = self.convert_cell(cell, cache)?;
271            notebook.cells.push(jupyter_cell);
272        }
273
274        Ok(notebook)
275    }
276
277    /// Convert a Venus cell to a Jupyter cell.
278    fn convert_cell(
279        &mut self,
280        cell: &NotebookCell,
281        cache: Option<&OutputCache>,
282    ) -> SyncResult<JupyterCell> {
283        match cell.cell_type {
284            CellType::Markdown => {
285                let source = cell.markdown.as_deref().unwrap_or("");
286                Ok(JupyterCell {
287                    cell_type: "markdown".to_string(),
288                    metadata: CellMetadata {
289                        venus_cell: Some(cell.name.clone()),
290                        editable: Some(true),
291                        tags: None,
292                    },
293                    source: source.lines().map(|l| format!("{}\n", l)).collect(),
294                    outputs: None,
295                    execution_count: None,
296                })
297            }
298            CellType::Code => {
299                let source = cell.source.as_deref().unwrap_or("");
300                let exec_count = self.execution_count;
301                self.execution_count += 1;
302
303                // Get cached output if available
304                let outputs = if let Some(cache) = cache {
305                    cache.get_output(&cell.name).map(|o| vec![o])
306                } else {
307                    None
308                };
309
310                Ok(JupyterCell {
311                    cell_type: "code".to_string(),
312                    metadata: CellMetadata {
313                        venus_cell: Some(cell.name.clone()),
314                        editable: Some(true),
315                        tags: if cell.has_dependencies {
316                            Some(vec!["has-dependencies".to_string()])
317                        } else {
318                            None
319                        },
320                    },
321                    source: source.lines().map(|l| format!("{}\n", l)).collect(),
322                    outputs: Some(outputs.unwrap_or_default()),
323                    execution_count: Some(exec_count),
324                })
325            }
326        }
327    }
328}
329
330impl Default for IpynbGenerator {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_empty_notebook() {
342        let notebook = JupyterNotebook::new();
343        assert_eq!(notebook.nbformat, 4);
344        assert!(notebook.cells.is_empty());
345    }
346
347    #[test]
348    fn test_generate_markdown_cell() {
349        let mut generator = IpynbGenerator::new();
350
351        let cell = NotebookCell {
352            name: "intro".to_string(),
353            cell_type: CellType::Markdown,
354            markdown: Some("# Hello\n\nThis is a test.".to_string()),
355            source: None,
356            has_dependencies: false,
357        };
358
359        let jupyter_cell = generator.convert_cell(&cell, None).unwrap();
360
361        assert_eq!(jupyter_cell.cell_type, "markdown");
362        assert!(jupyter_cell.source.len() >= 2);
363        assert!(jupyter_cell.outputs.is_none());
364    }
365
366    #[test]
367    fn test_generate_code_cell() {
368        let mut generator = IpynbGenerator::new();
369
370        let cell = NotebookCell {
371            name: "compute".to_string(),
372            cell_type: CellType::Code,
373            markdown: None,
374            source: Some("#[venus::cell]\npub fn compute() -> i32 { 42 }".to_string()),
375            has_dependencies: false,
376        };
377
378        let jupyter_cell = generator.convert_cell(&cell, None).unwrap();
379
380        assert_eq!(jupyter_cell.cell_type, "code");
381        assert!(jupyter_cell.execution_count.is_some());
382        assert!(jupyter_cell.outputs.is_some());
383    }
384
385    #[test]
386    fn test_notebook_serialization() {
387        let notebook = JupyterNotebook::new();
388        let json = serde_json::to_string_pretty(&notebook).unwrap();
389
390        assert!(json.contains("nbformat"));
391        assert!(json.contains("metadata"));
392        assert!(json.contains("cells"));
393    }
394}