venus_core/compile/
production.rs

1//! Production builder for Venus notebooks.
2//!
3//! Generates standalone binaries that execute all cells.
4
5use std::collections::HashSet;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use crate::error::{Error, Result};
11use crate::graph::{CellInfo, CellParser, GraphEngine};
12
13use super::cargo_generator::{generate_cargo_toml, ManifestConfig, ReleaseProfile};
14use super::dependency_parser::DependencyParser;
15use super::source_processor::NotebookSourceProcessor;
16use super::CompilerConfig;
17
18/// Builder for standalone production binaries.
19///
20/// Unlike interactive execution (via `venus run` or `venus serve`), production
21/// builds do not use [`crate::state::StateManager`] since the output is a
22/// self-contained executable with its own execution flow.
23pub struct ProductionBuilder {
24    /// Compiler configuration
25    config: CompilerConfig,
26
27    /// Parsed cells
28    cells: Vec<CellInfo>,
29
30    /// Dependency graph
31    graph: GraphEngine,
32
33    /// Dependency parser
34    parser: DependencyParser,
35
36    /// Original notebook source
37    source: String,
38
39    /// Notebook file path (for error messages)
40    notebook_path: PathBuf,
41}
42
43impl ProductionBuilder {
44    /// Create a new production builder.
45    pub fn new(config: CompilerConfig) -> Self {
46        Self {
47            config,
48            cells: Vec::new(),
49            graph: GraphEngine::new(),
50            parser: DependencyParser::new(),
51            source: String::new(),
52            notebook_path: PathBuf::new(),
53        }
54    }
55
56    /// Load a notebook from the given path.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if:
61    /// - The file cannot be read
62    /// - The notebook cannot be parsed
63    /// - There are duplicate cell names
64    /// - There are cyclic dependencies
65    pub fn load(&mut self, path: impl AsRef<Path>) -> Result<()> {
66        let path = path.as_ref();
67        self.notebook_path = path.to_path_buf();
68        self.source = fs::read_to_string(path)?;
69
70        // Parse cells
71        let mut parser = CellParser::new();
72        let parse_result = parser.parse_file(path)?;
73        self.cells = parse_result.code_cells;
74
75        // Validate unique cell names
76        self.validate_unique_cell_names()?;
77
78        // Build dependency graph
79        self.graph = GraphEngine::new();
80        for cell in &mut self.cells {
81            let real_id = self.graph.add_cell(cell.clone());
82            cell.id = real_id;
83        }
84        self.graph.resolve_dependencies()?;
85
86        // Parse external dependencies
87        self.parser.parse(&self.source);
88
89        Ok(())
90    }
91
92    /// Validate that all cell names are unique.
93    fn validate_unique_cell_names(&self) -> Result<()> {
94        let mut seen = HashSet::new();
95        for cell in &self.cells {
96            if !seen.insert(&cell.name) {
97                return Err(Error::Compilation {
98                    cell_id: Some(cell.name.clone()),
99                    message: format!(
100                        "Duplicate cell name '{}' in notebook '{}'",
101                        cell.name,
102                        self.notebook_path.display()
103                    ),
104                });
105            }
106        }
107        Ok(())
108    }
109
110    /// Build a standalone binary.
111    ///
112    /// # Arguments
113    ///
114    /// * `output_path` - Path for the output binary
115    /// * `release` - Whether to build with optimizations
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if:
120    /// - The build directory cannot be created
121    /// - Cargo fails to compile the project
122    /// - The binary cannot be copied to the output path
123    pub fn build(&self, output_path: impl AsRef<Path>, release: bool) -> Result<PathBuf> {
124        let output_path = output_path.as_ref();
125        let build_dir = self.config.build_dir.join("production");
126
127        fs::create_dir_all(&build_dir)?;
128
129        // Generate Cargo.toml
130        let cargo_toml = self.generate_cargo_toml()?;
131        fs::write(build_dir.join("Cargo.toml"), cargo_toml)?;
132
133        // Generate main.rs
134        let main_rs = self.generate_main_rs()?;
135        let src_dir = build_dir.join("src");
136        fs::create_dir_all(&src_dir)?;
137        fs::write(src_dir.join("main.rs"), main_rs)?;
138
139        // Build with cargo
140        let mut cmd = Command::new("cargo");
141        cmd.current_dir(&build_dir).arg("build");
142
143        if release {
144            cmd.arg("--release");
145        }
146
147        // Capture output
148        cmd.arg("--message-format=short");
149
150        let output = cmd.output().map_err(|e| Error::Compilation {
151            cell_id: None,
152            message: format!(
153                "Failed to run cargo (working dir: {}): {}",
154                build_dir.display(),
155                e
156            ),
157        })?;
158
159        if !output.status.success() {
160            let stderr = String::from_utf8_lossy(&output.stderr);
161            return Err(Error::Compilation {
162                cell_id: None,
163                message: format!(
164                    "Production build failed for '{}':\n{}",
165                    self.notebook_path.display(),
166                    stderr
167                ),
168            });
169        }
170
171        // Copy the binary to output path
172        let profile = if release { "release" } else { "debug" };
173        let binary_name = self.binary_name();
174        let built_binary = build_dir
175            .join("target")
176            .join(profile)
177            .join(&binary_name);
178
179        fs::copy(&built_binary, output_path)?;
180
181        // Make executable on Unix
182        #[cfg(unix)]
183        {
184            use std::os::unix::fs::PermissionsExt;
185            let mut perms = fs::metadata(output_path)?.permissions();
186            perms.set_mode(0o755);
187            fs::set_permissions(output_path, perms)?;
188        }
189
190        Ok(output_path.to_path_buf())
191    }
192
193    /// Get the number of cells.
194    pub fn cell_count(&self) -> usize {
195        self.cells.len()
196    }
197
198    /// Get the dependency count.
199    pub fn dependency_count(&self) -> usize {
200        self.parser.dependencies().len()
201    }
202
203    /// Generate Cargo.toml for the production binary.
204    fn generate_cargo_toml(&self) -> Result<String> {
205        // Derive binary name from notebook filename
206        let name = self
207            .notebook_path
208            .file_stem()
209            .and_then(|s| s.to_str())
210            .unwrap_or("notebook")
211            .replace('-', "_");
212
213        // Get the notebook directory for resolving relative paths
214        let notebook_dir = self.notebook_path.parent().ok_or_else(|| Error::Compilation {
215            cell_id: None,
216            message: format!(
217                "Could not determine parent directory for notebook: {}",
218                self.notebook_path.display()
219            ),
220        })?;
221
222        // Validate all path dependencies can be resolved
223        for dep in self.parser.dependencies() {
224            if let Some(path) = &dep.path
225                && path.is_relative() {
226                    let full_path = notebook_dir.join(path);
227                    full_path.canonicalize().map_err(|e| Error::Compilation {
228                        cell_id: None,
229                        message: format!(
230                            "Failed to resolve path dependency '{}' ({}): {}",
231                            dep.name,
232                            full_path.display(),
233                            e
234                        ),
235                    })?;
236                }
237        }
238
239        let config = ManifestConfig {
240            name: &name,
241            version: "0.1.0",
242            edition: "2021",
243            lib_crate_types: None,
244            release_profile: Some(ReleaseProfile::production()),
245            standalone_workspace: true,
246        };
247
248        Ok(generate_cargo_toml(
249            &config,
250            self.parser.dependencies(),
251            true, // Always include serde for consistency
252            Some(notebook_dir),
253        ))
254    }
255
256    /// Generate main.rs with all cells and execution logic.
257    fn generate_main_rs(&self) -> Result<String> {
258        let mut code = String::new();
259
260        // Header
261        code.push_str("//! Generated by Venus - standalone notebook binary.\n");
262        code.push_str("//!\n");
263        code.push_str(&format!(
264            "//! Source: {}\n",
265            self.notebook_path.display()
266        ));
267        code.push('\n');
268        code.push_str("#![allow(unused_imports)]\n");
269        code.push_str("#![allow(dead_code)]\n");
270        code.push_str("#![allow(clippy::ptr_arg)]\n");
271        code.push('\n');
272
273        // Process source using proper syn-based parsing
274        let processed_source =
275            NotebookSourceProcessor::process_for_production(&self.source).map_err(|e| {
276                Error::Compilation {
277                    cell_id: None,
278                    message: format!(
279                        "Failed to parse notebook source '{}': {}",
280                        self.notebook_path.display(),
281                        e
282                    ),
283                }
284            })?;
285        code.push_str(&processed_source);
286        code.push('\n');
287
288        // Main function
289        code.push_str("fn main() {\n");
290        code.push_str("    println!(\"═══════════════════════════════════════════════════\");\n");
291        code.push_str(&format!(
292            "    println!(\"  Venus Notebook: {}\");\n",
293            self.notebook_path
294                .file_name()
295                .and_then(|s| s.to_str())
296                .unwrap_or("notebook")
297        ));
298        code.push_str("    println!(\"═══════════════════════════════════════════════════\");\n");
299        code.push_str("    println!();\n\n");
300
301        // Execute cells in topological order
302        let order = self.graph.topological_order()?;
303
304        for cell_id in &order {
305            let cell = self
306                .cells
307                .iter()
308                .find(|c| c.id == *cell_id)
309                .ok_or_else(|| Error::CellNotFound(format!("{:?}", cell_id)))?;
310
311            // Build call arguments
312            let args: Vec<String> = cell
313                .dependencies
314                .iter()
315                .map(|dep| {
316                    if dep.is_ref {
317                        if dep.is_mut {
318                            format!("&mut {}", dep.param_name)
319                        } else {
320                            format!("&{}", dep.param_name)
321                        }
322                    } else {
323                        dep.param_name.clone()
324                    }
325                })
326                .collect();
327
328            // Generate cell execution
329            code.push_str(&format!("    println!(\"▶ Running: {}\");\n", cell.name));
330
331            // Call the cell function and store result
332            code.push_str(&format!(
333                "    let {} = {}({});\n",
334                cell.name,
335                cell.name,
336                args.join(", ")
337            ));
338
339            // Print output
340            code.push_str(&format!(
341                "    println!(\"  → {{:?}}\", {});\n",
342                cell.name
343            ));
344            code.push_str("    println!();\n");
345        }
346
347        code.push_str("    println!(\"═══════════════════════════════════════════════════\");\n");
348        code.push_str(&format!(
349            "    println!(\"  Completed {} cell(s)\");\n",
350            order.len()
351        ));
352        code.push_str("    println!(\"═══════════════════════════════════════════════════\");\n");
353        code.push_str("}\n");
354
355        Ok(code)
356    }
357
358    /// Get the platform-specific binary name.
359    fn binary_name(&self) -> String {
360        let name = self
361            .notebook_path
362            .file_stem()
363            .and_then(|s| s.to_str())
364            .unwrap_or("notebook")
365            .replace('-', "_");
366
367        #[cfg(target_os = "windows")]
368        {
369            format!("{}.exe", name)
370        }
371        #[cfg(not(target_os = "windows"))]
372        {
373            name
374        }
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn test_binary_name() {
384        let config = CompilerConfig::default();
385        let mut builder = ProductionBuilder::new(config);
386        builder.notebook_path = PathBuf::from("my-notebook.rs");
387
388        let name = builder.binary_name();
389
390        #[cfg(target_os = "windows")]
391        assert_eq!(name, "my_notebook.exe");
392
393        #[cfg(not(target_os = "windows"))]
394        assert_eq!(name, "my_notebook");
395    }
396
397    #[test]
398    fn test_validate_unique_cell_names() {
399        use crate::graph::{CellId, SourceSpan};
400
401        let config = CompilerConfig::default();
402        let mut builder = ProductionBuilder::new(config);
403        builder.notebook_path = PathBuf::from("test.rs");
404
405        let span = SourceSpan {
406            start_line: 1,
407            start_col: 0,
408            end_line: 1,
409            end_col: 10,
410        };
411
412        // Unique names should pass
413        builder.cells = vec![
414            CellInfo {
415                id: CellId::new(0),
416                name: "foo".to_string(),
417                display_name: "foo".to_string(),
418                dependencies: vec![],
419                return_type: "i32".to_string(),
420                doc_comment: None,
421                source_code: String::new(),
422                source_file: PathBuf::new(),
423                span: span.clone(),
424            },
425            CellInfo {
426                id: CellId::new(1),
427                name: "bar".to_string(),
428                display_name: "bar".to_string(),
429                dependencies: vec![],
430                return_type: "i32".to_string(),
431                doc_comment: None,
432                source_code: String::new(),
433                source_file: PathBuf::new(),
434                span: span.clone(),
435            },
436        ];
437        assert!(builder.validate_unique_cell_names().is_ok());
438
439        // Duplicate names should fail
440        builder.cells.push(CellInfo {
441            id: CellId::new(2),
442            name: "foo".to_string(), // Duplicate!
443            display_name: "foo".to_string(),
444            dependencies: vec![],
445            return_type: "i32".to_string(),
446            doc_comment: None,
447            source_code: String::new(),
448            source_file: PathBuf::new(),
449            span,
450        });
451        assert!(builder.validate_unique_cell_names().is_err());
452    }
453}