venus_sync/
outputs.rs

1//! Output cache for storing cell outputs.
2//!
3//! Caches cell outputs (text, HTML, images) for embedding in `.ipynb` files.
4
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::{SyncError, SyncResult};
10use crate::ipynb::{CellOutput, OutputData};
11
12/// Cache for cell outputs.
13pub struct OutputCache {
14    /// Cache directory path
15    cache_dir: PathBuf,
16
17    /// In-memory cache
18    outputs: HashMap<String, CellOutput>,
19
20    /// Execution counter for proper Jupyter numbering
21    execution_count: u32,
22}
23
24impl OutputCache {
25    /// Create a new output cache.
26    pub fn new(cache_dir: impl AsRef<Path>) -> SyncResult<Self> {
27        let cache_dir = cache_dir.as_ref().to_path_buf();
28        fs::create_dir_all(&cache_dir)?;
29
30        let mut cache = Self {
31            cache_dir,
32            outputs: HashMap::new(),
33            execution_count: 0,
34        };
35
36        cache.load_from_disk()?;
37        Ok(cache)
38    }
39
40    /// Get the next execution count.
41    fn next_execution_count(&mut self) -> u32 {
42        self.execution_count += 1;
43        self.execution_count
44    }
45
46    /// Create an ExecuteResult output with proper execution count.
47    fn make_execute_result(&mut self, data: OutputData) -> CellOutput {
48        CellOutput::ExecuteResult {
49            execution_count: self.next_execution_count(),
50            data,
51            metadata: serde_json::json!({}),
52        }
53    }
54
55    /// Get cached output for a cell.
56    pub fn get_output(&self, cell_name: &str) -> Option<CellOutput> {
57        self.outputs.get(cell_name).cloned()
58    }
59
60    /// Store text output for a cell.
61    pub fn store_text(&mut self, cell_name: &str, text: &str) {
62        let data = OutputData {
63            text_plain: Some(vec![text.to_string()]),
64            ..Default::default()
65        };
66        let output = self.make_execute_result(data);
67        self.outputs.insert(cell_name.to_string(), output);
68    }
69
70    /// Store HTML output for a cell.
71    pub fn store_html(&mut self, cell_name: &str, html: &str) {
72        let data = OutputData {
73            text_html: Some(html.lines().map(String::from).collect()),
74            text_plain: Some(vec!["[HTML Output]".to_string()]),
75            ..Default::default()
76        };
77        let output = self.make_execute_result(data);
78        self.outputs.insert(cell_name.to_string(), output);
79    }
80
81    /// Store PNG image output for a cell.
82    pub fn store_png(&mut self, cell_name: &str, png_data: &[u8]) {
83        use base64::Engine;
84        let encoded = base64::engine::general_purpose::STANDARD.encode(png_data);
85
86        let data = OutputData {
87            image_png: Some(encoded),
88            text_plain: Some(vec!["[Image]".to_string()]),
89            ..Default::default()
90        };
91        let output = self.make_execute_result(data);
92        self.outputs.insert(cell_name.to_string(), output);
93    }
94
95    /// Store SVG output for a cell.
96    pub fn store_svg(&mut self, cell_name: &str, svg: &str) {
97        let data = OutputData {
98            image_svg: Some(svg.lines().map(String::from).collect()),
99            text_plain: Some(vec!["[SVG Image]".to_string()]),
100            ..Default::default()
101        };
102        let output = self.make_execute_result(data);
103        self.outputs.insert(cell_name.to_string(), output);
104    }
105
106    /// Store JSON output for a cell.
107    pub fn store_json(&mut self, cell_name: &str, json: serde_json::Value) {
108        let text_repr = serde_json::to_string_pretty(&json).unwrap_or_default();
109        let data = OutputData {
110            application_json: Some(json),
111            text_plain: Some(vec![text_repr]),
112            ..Default::default()
113        };
114        let output = self.make_execute_result(data);
115        self.outputs.insert(cell_name.to_string(), output);
116    }
117
118    /// Store error output for a cell.
119    pub fn store_error(&mut self, cell_name: &str, error: &str) {
120        let output = CellOutput::Error {
121            ename: "ExecutionError".to_string(),
122            evalue: error.to_string(),
123            traceback: error.lines().map(String::from).collect(),
124        };
125        self.outputs.insert(cell_name.to_string(), output);
126    }
127
128    /// Clear all cached outputs.
129    pub fn clear(&mut self) {
130        self.outputs.clear();
131    }
132
133    /// Save cache to disk.
134    pub fn save_to_disk(&self) -> SyncResult<()> {
135        for (name, output) in &self.outputs {
136            let path = self.output_path(name);
137            let json = serde_json::to_string_pretty(output)?;
138            fs::write(&path, json).map_err(|e| SyncError::WriteError {
139                path: path.clone(),
140                message: e.to_string(),
141            })?;
142        }
143        Ok(())
144    }
145
146    /// Load cache from disk.
147    fn load_from_disk(&mut self) -> SyncResult<()> {
148        if !self.cache_dir.exists() {
149            return Ok(());
150        }
151
152        for entry in fs::read_dir(&self.cache_dir)? {
153            let entry = entry?;
154            let path = entry.path();
155
156            if path.extension().is_some_and(|e| e == "json")
157                && let Some(name) = path.file_stem().and_then(|s| s.to_str())
158                && let Ok(content) = fs::read_to_string(&path)
159                && let Ok(output) = serde_json::from_str(&content)
160            {
161                self.outputs.insert(name.to_string(), output);
162            }
163        }
164
165        Ok(())
166    }
167
168    /// Get the path for a cached output.
169    fn output_path(&self, cell_name: &str) -> PathBuf {
170        self.cache_dir.join(format!("{}.json", cell_name))
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_store_and_retrieve_text() {
180        let temp = tempfile::TempDir::new().unwrap();
181        let mut cache = OutputCache::new(temp.path()).unwrap();
182
183        cache.store_text("hello", "Hello, world!");
184
185        let output = cache.get_output("hello").unwrap();
186        match output {
187            CellOutput::ExecuteResult { data, .. } => {
188                assert!(data.text_plain.is_some());
189                assert!(data.text_plain.unwrap()[0].contains("Hello"));
190            }
191            _ => panic!("Expected ExecuteResult"),
192        }
193    }
194
195    #[test]
196    fn test_store_and_retrieve_html() {
197        let temp = tempfile::TempDir::new().unwrap();
198        let mut cache = OutputCache::new(temp.path()).unwrap();
199
200        cache.store_html("table", "<table><tr><td>Data</td></tr></table>");
201
202        let output = cache.get_output("table").unwrap();
203        match output {
204            CellOutput::ExecuteResult { data, .. } => {
205                assert!(data.text_html.is_some());
206            }
207            _ => panic!("Expected ExecuteResult"),
208        }
209    }
210
211    #[test]
212    fn test_persist_and_reload() {
213        let temp = tempfile::TempDir::new().unwrap();
214
215        // Create and populate cache
216        {
217            let mut cache = OutputCache::new(temp.path()).unwrap();
218            cache.store_text("test", "Test output");
219            cache.save_to_disk().unwrap();
220        }
221
222        // Reload cache
223        {
224            let cache = OutputCache::new(temp.path()).unwrap();
225            assert!(cache.get_output("test").is_some());
226        }
227    }
228}