Skip to main content

virtual_rust/
cargo_runner.rs

1//! Compiles and runs Rust source files that declare external dependencies,
2//! or runs existing Cargo projects directly.
3//!
4//! # Single-file dependencies
5//!
6//! When a `.rs` file contains embedded Cargo manifest sections in `//!`
7//! doc comments (e.g. `[dependencies]`), this module creates a temporary
8//! Cargo project, writes a proper `Cargo.toml`, and delegates to `cargo run`.
9//!
10//! ```rust,ignore
11//! //! [dependencies]
12//! //! rand = "0.8"
13//! //! serde = { version = "1.0", features = ["derive"] }
14//!
15//! use rand::Rng;
16//! use serde::Serialize;
17//!
18//! fn main() {
19//!     let n: i32 = rand::thread_rng().gen_range(1..=100);
20//!     println!("Random number: {n}");
21//! }
22//! ```
23//!
24//! # Cargo projects
25//!
26//! When pointed at a directory containing a `Cargo.toml`, virtual-rust will
27//! compile and run the project with `cargo run` directly:
28//!
29//! ```bash
30//! virtual-rust ./my-project
31//! ```
32
33use std::fs;
34use std::hash::{Hash, Hasher};
35use std::path::{Path, PathBuf};
36use std::process::Command;
37
38// ── Manifest parsing ─────────────────────────────────────────────────
39
40/// Embedded Cargo manifest extracted from `//!` doc comments.
41pub struct EmbeddedManifest {
42    /// Raw TOML content (may contain `[dependencies]`, `[package]`, etc.).
43    pub toml_content: String,
44}
45
46/// Scans `//!` doc comments at the top of a Rust file for Cargo manifest sections.
47///
48/// Returns `Some(manifest)` if a TOML section header (e.g. `[dependencies]`) is found
49/// within the leading `//!` block, `None` otherwise.
50pub fn parse_embedded_manifest(source: &str) -> Option<EmbeddedManifest> {
51    let mut toml_lines = Vec::new();
52    let mut found_section = false;
53
54    for line in source.lines() {
55        let trimmed = line.trim();
56
57        if let Some(rest) = trimmed.strip_prefix("//!") {
58            // Strip a single leading space after `//!` if present
59            let content = rest.strip_prefix(' ').unwrap_or(rest);
60            if content.starts_with('[') {
61                found_section = true;
62            }
63            toml_lines.push(content.to_string());
64        } else if trimmed.is_empty() {
65            // Allow blank lines within the leading doc-comment block
66            if found_section {
67                toml_lines.push(String::new());
68            }
69        } else {
70            // First non-doc-comment, non-empty line ends the manifest
71            break;
72        }
73    }
74
75    if !found_section {
76        return None;
77    }
78
79    Some(EmbeddedManifest {
80        toml_content: toml_lines.join("\n"),
81    })
82}
83
84/// Returns `true` if the source contains an embedded dependency manifest.
85pub fn has_dependencies(source: &str) -> bool {
86    parse_embedded_manifest(source).is_some()
87}
88
89// ── Source cleaning ──────────────────────────────────────────────────
90
91/// Strips leading `//!` manifest lines from the source, returning clean Rust code.
92///
93/// Only removes consecutive `//!` lines (and interleaved blank lines) at the
94/// very start of the file. All other content is preserved.
95fn strip_manifest_comments(source: &str) -> String {
96    let mut result_lines: Vec<&str> = Vec::new();
97    let mut in_header = true;
98
99    for line in source.lines() {
100        if in_header {
101            let trimmed = line.trim();
102            if trimmed.starts_with("//!") || trimmed.is_empty() {
103                continue; // skip manifest & surrounding blank lines
104            }
105            in_header = false;
106        }
107        result_lines.push(line);
108    }
109
110    result_lines.join("\n")
111}
112
113// ── Cargo project generation ─────────────────────────────────────────
114
115/// Creates a deterministic cache directory for the given source file.
116///
117/// Uses a hash of the canonical path so repeated runs reuse the same
118/// directory and benefit from incremental compilation.
119fn project_cache_dir(source_path: Option<&Path>) -> Result<PathBuf, String> {
120    let base = std::env::temp_dir().join("virtual-rust-cache");
121
122    let project_name = match source_path {
123        Some(path) => {
124            let mut hasher = std::collections::hash_map::DefaultHasher::new();
125            path.canonicalize()
126                .unwrap_or_else(|_| path.to_path_buf())
127                .hash(&mut hasher);
128            format!("project_{:x}", hasher.finish())
129        }
130        None => "project_anonymous".to_string(),
131    };
132
133    let dir = base.join(project_name);
134    fs::create_dir_all(&dir).map_err(|e| format!("Failed to create cache directory: {e}"))?;
135    Ok(dir)
136}
137
138/// Generates a `Cargo.toml` string from the embedded manifest.
139fn generate_cargo_toml(manifest: &EmbeddedManifest, source_path: Option<&Path>) -> String {
140    let name = source_path
141        .and_then(|p| p.file_stem())
142        .and_then(|s| s.to_str())
143        .unwrap_or("virtual-rust-script")
144        .to_lowercase()
145        .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
146
147    let has_package = manifest.toml_content.contains("[package]");
148
149    let mut toml = String::new();
150
151    if !has_package {
152        toml.push_str(&format!(
153            "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n"
154        ));
155    }
156
157    toml.push_str(&manifest.toml_content);
158    toml.push('\n');
159    toml
160}
161
162// ── Execution ────────────────────────────────────────────────────────
163
164/// Compiles and runs a Rust source file that has embedded dependencies.
165///
166/// 1. Parses the `//!` manifest from the source
167/// 2. Creates a cached Cargo project directory
168/// 3. Writes `Cargo.toml` and `src/main.rs`
169/// 4. Invokes `cargo run --quiet`
170///
171/// Stdin/stdout/stderr are inherited, so the program interacts with the
172/// terminal normally.
173pub fn run_with_cargo(source: &str, source_path: Option<&Path>) -> Result<(), String> {
174    let manifest =
175        parse_embedded_manifest(source).ok_or("No embedded dependency manifest found")?;
176
177    // Resolve cache directory
178    let project_dir = project_cache_dir(source_path)?;
179    let src_dir = project_dir.join("src");
180    fs::create_dir_all(&src_dir).map_err(|e| format!("Failed to create src directory: {e}"))?;
181
182    // Write Cargo.toml
183    let cargo_toml = generate_cargo_toml(&manifest, source_path);
184    fs::write(project_dir.join("Cargo.toml"), &cargo_toml)
185        .map_err(|e| format!("Failed to write Cargo.toml: {e}"))?;
186
187    // Write cleaned source as src/main.rs
188    let clean_source = strip_manifest_comments(source);
189    fs::write(src_dir.join("main.rs"), &clean_source)
190        .map_err(|e| format!("Failed to write main.rs: {e}"))?;
191
192    // Print status
193    eprintln!(
194        "\x1b[1;32m   Compiling\x1b[0m {} with cargo (dependencies detected)",
195        source_path
196            .and_then(|p| p.file_name())
197            .and_then(|s| s.to_str())
198            .unwrap_or("script")
199    );
200
201    // Run cargo build first for clearer error separation
202    let status = Command::new("cargo")
203        .args(["run", "--quiet"])
204        .current_dir(&project_dir)
205        .status()
206        .map_err(|e| format!("Failed to invoke cargo: {e}. Is cargo installed?"))?;
207
208    if !status.success() {
209        return Err(format!(
210            "Compilation failed (exit code: {})",
211            status.code().unwrap_or(-1)
212        ));
213    }
214
215    Ok(())
216}
217
218// ── Cargo project support ────────────────────────────────────────────
219
220/// Returns `true` if the given path is a Cargo project directory
221/// (i.e. it is a directory containing a `Cargo.toml`).
222pub fn is_cargo_project(path: &Path) -> bool {
223    path.is_dir() && path.join("Cargo.toml").exists()
224}
225
226/// Runs an existing Cargo project directory with `cargo run`.
227///
228/// If the project has no dependencies (empty `[dependencies]` or none at all),
229/// the interpreter *could* be used, but we always delegate to cargo for
230/// full compatibility with the project's build configuration, build scripts,
231/// proc macros, multiple source files, modules, etc.
232pub fn run_cargo_project(project_dir: &Path, extra_args: &[String]) -> Result<(), String> {
233    let cargo_toml = project_dir.join("Cargo.toml");
234    if !cargo_toml.exists() {
235        return Err(format!(
236            "No Cargo.toml found in '{}'",
237            project_dir.display()
238        ));
239    }
240
241    // Read project name from Cargo.toml for display
242    let display_name = read_project_name(&cargo_toml)
243        .unwrap_or_else(|| project_dir.file_name()
244            .and_then(|s| s.to_str())
245            .unwrap_or("project")
246            .to_string());
247
248    eprintln!(
249        "\x1b[1;32m   Compiling\x1b[0m {} (cargo project)",
250        display_name
251    );
252
253    let mut cmd = Command::new("cargo");
254    cmd.arg("run").arg("--quiet").current_dir(project_dir);
255
256    // Pass extra arguments after `--`
257    if !extra_args.is_empty() {
258        cmd.arg("--");
259        cmd.args(extra_args);
260    }
261
262    let status = cmd
263        .status()
264        .map_err(|e| format!("Failed to invoke cargo: {e}. Is cargo installed?"))?;
265
266    if !status.success() {
267        return Err(format!(
268            "cargo run failed (exit code: {})",
269            status.code().unwrap_or(-1)
270        ));
271    }
272
273    Ok(())
274}
275
276/// Reads the `name` field from a Cargo.toml (best-effort, no TOML parser).
277fn read_project_name(cargo_toml: &Path) -> Option<String> {
278    let content = fs::read_to_string(cargo_toml).ok()?;
279    for line in content.lines() {
280        let trimmed = line.trim();
281        if let Some(rest) = trimmed.strip_prefix("name") {
282            let rest = rest.trim();
283            if let Some(rest) = rest.strip_prefix('=') {
284                let rest = rest.trim().trim_matches('"');
285                return Some(rest.to_string());
286            }
287        }
288    }
289    None
290}
291
292// ── Tests ────────────────────────────────────────────────────────────
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn parse_manifest_with_dependencies() {
300        let source = r#"//! [dependencies]
301//! rand = "0.8"
302//! serde = { version = "1.0", features = ["derive"] }
303
304use rand::Rng;
305
306fn main() {
307    println!("hello");
308}
309"#;
310        let manifest = parse_embedded_manifest(source).unwrap();
311        assert!(manifest.toml_content.contains("[dependencies]"));
312        assert!(manifest.toml_content.contains("rand = \"0.8\""));
313        assert!(manifest.toml_content.contains("serde"));
314    }
315
316    #[test]
317    fn parse_manifest_none_without_deps() {
318        let source = r#"fn main() {
319    println!("hello");
320}
321"#;
322        assert!(parse_embedded_manifest(source).is_none());
323    }
324
325    #[test]
326    fn strip_manifest_preserves_code() {
327        let source = r#"//! [dependencies]
328//! rand = "0.8"
329
330use rand::Rng;
331
332fn main() {}
333"#;
334        let cleaned = strip_manifest_comments(source);
335        assert!(!cleaned.contains("//!"));
336        assert!(cleaned.contains("use rand::Rng;"));
337        assert!(cleaned.contains("fn main()"));
338    }
339
340    #[test]
341    fn has_dependencies_detection() {
342        assert!(has_dependencies(
343            "//! [dependencies]\n//! x = \"1\"\nfn main() {}"
344        ));
345        assert!(!has_dependencies("fn main() { println!(\"hi\"); }"));
346    }
347
348    #[test]
349    fn generate_toml_includes_package() {
350        let manifest = EmbeddedManifest {
351            toml_content: "[dependencies]\nrand = \"0.8\"".to_string(),
352        };
353        let toml = generate_cargo_toml(&manifest, None);
354        assert!(toml.contains("[package]"));
355        assert!(toml.contains("edition = \"2021\""));
356        assert!(toml.contains("[dependencies]"));
357        assert!(toml.contains("rand = \"0.8\""));
358    }
359
360    #[test]
361    fn generate_toml_respects_existing_package() {
362        let manifest = EmbeddedManifest {
363            toml_content: "[package]\nname = \"my-script\"\nedition = \"2021\"\n\n[dependencies]\nrand = \"0.8\"".to_string(),
364        };
365        let toml = generate_cargo_toml(&manifest, None);
366        // Should NOT duplicate [package]
367        assert_eq!(toml.matches("[package]").count(), 1);
368        assert!(toml.contains("my-script"));
369    }
370
371    #[test]
372    fn is_cargo_project_detection() {
373        // The virtual-rust project itself is a cargo project
374        let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
375        assert!(is_cargo_project(project_root));
376
377        // A non-existent path is not
378        assert!(!is_cargo_project(Path::new("/nonexistent/fake/path")));
379
380        // A file is not a directory
381        let cargo_toml = project_root.join("Cargo.toml");
382        assert!(!is_cargo_project(&cargo_toml));
383    }
384
385    #[test]
386    fn read_project_name_works() {
387        let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
388        let name = read_project_name(&project_root.join("Cargo.toml"));
389        assert_eq!(name, Some("virtual-rust".to_string()));
390    }
391}