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>, extra_args: &[String]) -> 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 mut cmd = Command::new("cargo");
203    cmd.args(["run", "--quiet"]).current_dir(&project_dir);
204
205    // Pass extra arguments directly to cargo (e.g. --release, -- <program args>)
206    if !extra_args.is_empty() {
207        cmd.args(extra_args);
208    }
209
210    let status = cmd
211        .status()
212        .map_err(|e| format!("Failed to invoke cargo: {e}. Is cargo installed?"))?;
213
214    if !status.success() {
215        return Err(format!(
216            "Compilation failed (exit code: {})",
217            status.code().unwrap_or(-1)
218        ));
219    }
220
221    Ok(())
222}
223
224// ── Cargo project support ────────────────────────────────────────────
225
226/// Returns `true` if the given path is a Cargo project directory
227/// (i.e. it is a directory containing a `Cargo.toml`).
228pub fn is_cargo_project(path: &Path) -> bool {
229    path.is_dir() && path.join("Cargo.toml").exists()
230}
231
232/// Runs an existing Cargo project directory with `cargo run`.
233///
234/// If the project has no dependencies (empty `[dependencies]` or none at all),
235/// the interpreter *could* be used, but we always delegate to cargo for
236/// full compatibility with the project's build configuration, build scripts,
237/// proc macros, multiple source files, modules, etc.
238pub fn run_cargo_project(project_dir: &Path, extra_args: &[String]) -> Result<(), String> {
239    let cargo_toml = project_dir.join("Cargo.toml");
240    if !cargo_toml.exists() {
241        return Err(format!(
242            "No Cargo.toml found in '{}'",
243            project_dir.display()
244        ));
245    }
246
247    // Read project name from Cargo.toml for display
248    let display_name = read_project_name(&cargo_toml)
249        .unwrap_or_else(|| project_dir.file_name()
250            .and_then(|s| s.to_str())
251            .unwrap_or("project")
252            .to_string());
253
254    eprintln!(
255        "\x1b[1;32m   Compiling\x1b[0m {} (cargo project)",
256        display_name
257    );
258
259    let mut cmd = Command::new("cargo");
260    cmd.arg("run").arg("--quiet").current_dir(project_dir);
261
262    // Pass extra arguments directly to cargo (e.g. --bin <name>, --release, -- <program args>)
263    if !extra_args.is_empty() {
264        cmd.args(extra_args);
265    }
266
267    let status = cmd
268        .status()
269        .map_err(|e| format!("Failed to invoke cargo: {e}. Is cargo installed?"))?;
270
271    if !status.success() {
272        return Err(format!(
273            "cargo run failed (exit code: {})",
274            status.code().unwrap_or(-1)
275        ));
276    }
277
278    Ok(())
279}
280
281/// Reads the `name` field from a Cargo.toml (best-effort, no TOML parser).
282fn read_project_name(cargo_toml: &Path) -> Option<String> {
283    let content = fs::read_to_string(cargo_toml).ok()?;
284    for line in content.lines() {
285        let trimmed = line.trim();
286        if let Some(rest) = trimmed.strip_prefix("name") {
287            let rest = rest.trim();
288            if let Some(rest) = rest.strip_prefix('=') {
289                let rest = rest.trim().trim_matches('"');
290                return Some(rest.to_string());
291            }
292        }
293    }
294    None
295}
296
297// ── Tests ────────────────────────────────────────────────────────────
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn parse_manifest_with_dependencies() {
305        let source = r#"//! [dependencies]
306//! rand = "0.8"
307//! serde = { version = "1.0", features = ["derive"] }
308
309use rand::Rng;
310
311fn main() {
312    println!("hello");
313}
314"#;
315        let manifest = parse_embedded_manifest(source).unwrap();
316        assert!(manifest.toml_content.contains("[dependencies]"));
317        assert!(manifest.toml_content.contains("rand = \"0.8\""));
318        assert!(manifest.toml_content.contains("serde"));
319    }
320
321    #[test]
322    fn parse_manifest_none_without_deps() {
323        let source = r#"fn main() {
324    println!("hello");
325}
326"#;
327        assert!(parse_embedded_manifest(source).is_none());
328    }
329
330    #[test]
331    fn strip_manifest_preserves_code() {
332        let source = r#"//! [dependencies]
333//! rand = "0.8"
334
335use rand::Rng;
336
337fn main() {}
338"#;
339        let cleaned = strip_manifest_comments(source);
340        assert!(!cleaned.contains("//!"));
341        assert!(cleaned.contains("use rand::Rng;"));
342        assert!(cleaned.contains("fn main()"));
343    }
344
345    #[test]
346    fn has_dependencies_detection() {
347        assert!(has_dependencies(
348            "//! [dependencies]\n//! x = \"1\"\nfn main() {}"
349        ));
350        assert!(!has_dependencies("fn main() { println!(\"hi\"); }"));
351    }
352
353    #[test]
354    fn generate_toml_includes_package() {
355        let manifest = EmbeddedManifest {
356            toml_content: "[dependencies]\nrand = \"0.8\"".to_string(),
357        };
358        let toml = generate_cargo_toml(&manifest, None);
359        assert!(toml.contains("[package]"));
360        assert!(toml.contains("edition = \"2021\""));
361        assert!(toml.contains("[dependencies]"));
362        assert!(toml.contains("rand = \"0.8\""));
363    }
364
365    #[test]
366    fn generate_toml_respects_existing_package() {
367        let manifest = EmbeddedManifest {
368            toml_content: "[package]\nname = \"my-script\"\nedition = \"2021\"\n\n[dependencies]\nrand = \"0.8\"".to_string(),
369        };
370        let toml = generate_cargo_toml(&manifest, None);
371        // Should NOT duplicate [package]
372        assert_eq!(toml.matches("[package]").count(), 1);
373        assert!(toml.contains("my-script"));
374    }
375
376    #[test]
377    fn is_cargo_project_detection() {
378        // The virtual-rust project itself is a cargo project
379        let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
380        assert!(is_cargo_project(project_root));
381
382        // A non-existent path is not
383        assert!(!is_cargo_project(Path::new("/nonexistent/fake/path")));
384
385        // A file is not a directory
386        let cargo_toml = project_root.join("Cargo.toml");
387        assert!(!is_cargo_project(&cargo_toml));
388    }
389
390    #[test]
391    fn read_project_name_works() {
392        let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
393        let name = read_project_name(&project_root.join("Cargo.toml"));
394        assert_eq!(name, Some("virtual-rust".to_string()));
395    }
396}