Skip to main content

virtual_rust/
cargo_runner.rs

1//! Compiles and runs Rust source files that declare external dependencies,
2//! runs existing Cargo projects, or assembles loose `.rs` directories into
3//! temporary Cargo projects.
4//!
5//! # Single-file dependencies
6//!
7//! When a `.rs` file contains embedded Cargo manifest sections in `//!`
8//! doc comments (e.g. `[dependencies]`), this module creates a temporary
9//! Cargo project, writes a proper `Cargo.toml`, and delegates to `cargo run`.
10//!
11//! ```rust,ignore
12//! //! [dependencies]
13//! //! rand = "0.8"
14//! //! serde = { version = "1.0", features = ["derive"] }
15//!
16//! use rand::Rng;
17//! use serde::Serialize;
18//!
19//! fn main() {
20//!     let n: i32 = rand::thread_rng().gen_range(1..=100);
21//!     println!("Random number: {n}");
22//! }
23//! ```
24//!
25//! # Cargo projects
26//!
27//! When pointed at a directory containing a `Cargo.toml`, virtual-rust will
28//! compile and run the project with `cargo run` directly:
29//!
30//! ```bash
31//! virtual-rust ./my-project
32//! ```
33//!
34//! # Loose `.rs` directories
35//!
36//! When pointed at a directory containing `.rs` files but **no** `Cargo.toml`,
37//! virtual-rust will automatically assemble them into a temporary Cargo project.
38//! The file containing `fn main()` becomes `src/main.rs`, and all other files
39//! are copied as sibling modules under `src/`.
40//!
41//! ```bash
42//! virtual-rust ./my-scripts/    # directory with main.rs, utils.rs, etc.
43//! ```
44
45use std::fs;
46use std::hash::{Hash, Hasher};
47use std::path::{Path, PathBuf};
48use std::process::Command;
49
50// ── Manifest parsing ─────────────────────────────────────────────────
51
52/// Embedded Cargo manifest extracted from `//!` doc comments.
53pub struct EmbeddedManifest {
54    /// Raw TOML content (may contain `[dependencies]`, `[package]`, etc.).
55    pub toml_content: String,
56}
57
58/// Scans `//!` doc comments at the top of a Rust file for Cargo manifest sections.
59///
60/// Returns `Some(manifest)` if a TOML section header (e.g. `[dependencies]`) is found
61/// within the leading `//!` block, `None` otherwise.
62pub fn parse_embedded_manifest(source: &str) -> Option<EmbeddedManifest> {
63    let mut toml_lines = Vec::new();
64    let mut found_section = false;
65
66    for line in source.lines() {
67        let trimmed = line.trim();
68
69        if let Some(rest) = trimmed.strip_prefix("//!") {
70            // Strip a single leading space after `//!` if present
71            let content = rest.strip_prefix(' ').unwrap_or(rest);
72            if content.starts_with('[') {
73                found_section = true;
74            }
75            toml_lines.push(content.to_string());
76        } else if trimmed.is_empty() {
77            // Allow blank lines within the leading doc-comment block
78            if found_section {
79                toml_lines.push(String::new());
80            }
81        } else {
82            // First non-doc-comment, non-empty line ends the manifest
83            break;
84        }
85    }
86
87    if !found_section {
88        return None;
89    }
90
91    Some(EmbeddedManifest {
92        toml_content: toml_lines.join("\n"),
93    })
94}
95
96/// Returns `true` if the source contains an embedded dependency manifest.
97pub fn has_dependencies(source: &str) -> bool {
98    parse_embedded_manifest(source).is_some()
99}
100
101// ── Source cleaning ──────────────────────────────────────────────────
102
103/// Strips leading `//!` manifest lines from the source, returning clean Rust code.
104///
105/// Only removes consecutive `//!` lines (and interleaved blank lines) at the
106/// very start of the file. All other content is preserved.
107fn strip_manifest_comments(source: &str) -> String {
108    let mut result_lines: Vec<&str> = Vec::new();
109    let mut in_header = true;
110
111    for line in source.lines() {
112        if in_header {
113            let trimmed = line.trim();
114            if trimmed.starts_with("//!") || trimmed.is_empty() {
115                continue; // skip manifest & surrounding blank lines
116            }
117            in_header = false;
118        }
119        result_lines.push(line);
120    }
121
122    result_lines.join("\n")
123}
124
125// ── Cargo project generation ─────────────────────────────────────────
126
127/// Creates a deterministic cache directory for the given source file.
128///
129/// Uses a hash of the canonical path so repeated runs reuse the same
130/// directory and benefit from incremental compilation.
131fn project_cache_dir(source_path: Option<&Path>) -> Result<PathBuf, String> {
132    let base = std::env::temp_dir().join("virtual-rust-cache");
133
134    let project_name = match source_path {
135        Some(path) => {
136            let mut hasher = std::collections::hash_map::DefaultHasher::new();
137            path.canonicalize()
138                .unwrap_or_else(|_| path.to_path_buf())
139                .hash(&mut hasher);
140            format!("project_{:x}", hasher.finish())
141        }
142        None => "project_anonymous".to_string(),
143    };
144
145    let dir = base.join(project_name);
146    fs::create_dir_all(&dir).map_err(|e| format!("Failed to create cache directory: {e}"))?;
147    Ok(dir)
148}
149
150/// Generates a `Cargo.toml` string from the embedded manifest.
151fn generate_cargo_toml(manifest: &EmbeddedManifest, source_path: Option<&Path>) -> String {
152    let name = source_path
153        .and_then(|p| p.file_stem())
154        .and_then(|s| s.to_str())
155        .unwrap_or("virtual-rust-script")
156        .to_lowercase()
157        .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
158
159    let has_package = manifest.toml_content.contains("[package]");
160
161    let mut toml = String::new();
162
163    if !has_package {
164        toml.push_str(&format!(
165            "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n"
166        ));
167    }
168
169    toml.push_str(&manifest.toml_content);
170    toml.push('\n');
171    toml
172}
173
174// ── Execution ────────────────────────────────────────────────────────
175
176/// Compiles and runs a Rust source file that has embedded dependencies.
177///
178/// 1. Parses the `//!` manifest from the source
179/// 2. Creates a cached Cargo project directory
180/// 3. Writes `Cargo.toml` and `src/main.rs`
181/// 4. Invokes `cargo run --quiet`
182///
183/// Stdin/stdout/stderr are inherited, so the program interacts with the
184/// terminal normally.
185pub fn run_with_cargo(source: &str, source_path: Option<&Path>, extra_args: &[String]) -> Result<(), String> {
186    let manifest =
187        parse_embedded_manifest(source).ok_or("No embedded dependency manifest found")?;
188
189    // Resolve cache directory
190    let project_dir = project_cache_dir(source_path)?;
191    let src_dir = project_dir.join("src");
192    fs::create_dir_all(&src_dir).map_err(|e| format!("Failed to create src directory: {e}"))?;
193
194    // Write Cargo.toml
195    let cargo_toml = generate_cargo_toml(&manifest, source_path);
196    fs::write(project_dir.join("Cargo.toml"), &cargo_toml)
197        .map_err(|e| format!("Failed to write Cargo.toml: {e}"))?;
198
199    // Write cleaned source as src/main.rs
200    let clean_source = strip_manifest_comments(source);
201    fs::write(src_dir.join("main.rs"), &clean_source)
202        .map_err(|e| format!("Failed to write main.rs: {e}"))?;
203
204    // Print status
205    eprintln!(
206        "\x1b[1;32m   Compiling\x1b[0m {} with cargo (dependencies detected)",
207        source_path
208            .and_then(|p| p.file_name())
209            .and_then(|s| s.to_str())
210            .unwrap_or("script")
211    );
212
213    // Run cargo build first for clearer error separation
214    let mut cmd = Command::new("cargo");
215    cmd.args(["run", "--quiet"]).current_dir(&project_dir);
216
217    // Pass extra arguments directly to cargo (e.g. --release, -- <program args>)
218    if !extra_args.is_empty() {
219        cmd.args(extra_args);
220    }
221
222    let status = cmd
223        .status()
224        .map_err(|e| format!("Failed to invoke cargo: {e}. Is cargo installed?"))?;
225
226    if !status.success() {
227        return Err(format!(
228            "Compilation failed (exit code: {})",
229            status.code().unwrap_or(-1)
230        ));
231    }
232
233    Ok(())
234}
235
236// ── Cargo project support ────────────────────────────────────────────
237
238/// Returns `true` if the given path is a Cargo project directory
239/// (i.e. it is a directory containing a `Cargo.toml`).
240pub fn is_cargo_project(path: &Path) -> bool {
241    path.is_dir() && path.join("Cargo.toml").exists()
242}
243
244/// Returns `true` if the given path is a directory containing `.rs` files
245/// but no `Cargo.toml` (i.e. a loose collection of Rust source files).
246pub fn is_rust_source_dir(path: &Path) -> bool {
247    if !path.is_dir() || path.join("Cargo.toml").exists() {
248        return false;
249    }
250    // Check for at least one .rs file in the directory
251    match fs::read_dir(path) {
252        Ok(entries) => entries
253            .filter_map(|e| e.ok())
254            .any(|e| {
255                e.path().extension().and_then(|ext| ext.to_str()) == Some("rs")
256            }),
257        Err(_) => false,
258    }
259}
260
261/// Scans a directory of `.rs` files, finds the entry point (file containing
262/// `fn main()`), and returns `(entry_file, all_rs_files)`.
263fn find_entry_file(dir: &Path) -> Result<(PathBuf, Vec<PathBuf>), String> {
264    let entries: Vec<PathBuf> = fs::read_dir(dir)
265        .map_err(|e| format!("Failed to read directory '{}': {e}", dir.display()))?
266        .filter_map(|e| e.ok())
267        .map(|e| e.path())
268        .filter(|p| p.extension().and_then(|ext| ext.to_str()) == Some("rs"))
269        .collect();
270
271    if entries.is_empty() {
272        return Err(format!("No .rs files found in '{}'", dir.display()));
273    }
274
275    let mut main_files = Vec::new();
276    for path in &entries {
277        if let Ok(content) = fs::read_to_string(path) {
278            // Look for `fn main()` — a simple heuristic
279            if content.contains("fn main()") {
280                main_files.push(path.clone());
281            }
282        }
283    }
284
285    match main_files.len() {
286        0 => Err(format!(
287            "No entry point found: none of the .rs files in '{}' contain `fn main()`",
288            dir.display()
289        )),
290        1 => Ok((main_files.into_iter().next().unwrap(), entries)),
291        _ => {
292            let names: Vec<String> = main_files
293                .iter()
294                .filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(String::from))
295                .collect();
296            Err(format!(
297                "Multiple entry points found in '{}': {}. \
298                 Please pass the specific .rs file to run instead.",
299                dir.display(),
300                names.join(", ")
301            ))
302        }
303    }
304}
305
306/// Collects any embedded manifest (`//! [dependencies]`) from any `.rs` file
307/// in the directory. Merges all `[dependencies]` into a single manifest.
308fn collect_embedded_manifests(rs_files: &[PathBuf]) -> Option<EmbeddedManifest> {
309    let mut all_deps_lines = Vec::new();
310    let mut found_any = false;
311
312    for path in rs_files {
313        if let Ok(content) = fs::read_to_string(path) {
314            if let Some(manifest) = parse_embedded_manifest(&content) {
315                found_any = true;
316                // Collect lines that are within [dependencies] sections
317                let mut in_deps = false;
318                for line in manifest.toml_content.lines() {
319                    let trimmed = line.trim();
320                    if trimmed.starts_with('[') {
321                        in_deps = trimmed == "[dependencies]";
322                        if !in_deps {
323                            // Preserve other sections as-is
324                            all_deps_lines.push(line.to_string());
325                        }
326                        continue;
327                    }
328                    if in_deps && !trimmed.is_empty() {
329                        // Avoid duplicate dependency lines
330                        if !all_deps_lines.contains(&line.to_string()) {
331                            all_deps_lines.push(line.to_string());
332                        }
333                    } else if !in_deps {
334                        all_deps_lines.push(line.to_string());
335                    }
336                }
337            }
338        }
339    }
340
341    if !found_any {
342        return None;
343    }
344
345    let mut toml_content = String::from("[dependencies]\n");
346    toml_content.push_str(&all_deps_lines.join("\n"));
347
348    Some(EmbeddedManifest { toml_content })
349}
350
351/// Runs a directory of loose `.rs` files by generating a temporary Cargo project.
352///
353/// 1. Finds the entry file (containing `fn main()`)
354/// 2. Creates a cached Cargo project directory
355/// 3. Copies the entry file as `src/main.rs` and all other files as `src/<name>.rs`
356/// 4. Collects any embedded dependency manifests from all files
357/// 5. Invokes `cargo run --quiet`
358pub fn run_rust_dir(dir: &Path, extra_args: &[String]) -> Result<(), String> {
359    let (entry_file, all_files) = find_entry_file(dir)?;
360
361    let dir_name = dir
362        .file_name()
363        .and_then(|s| s.to_str())
364        .unwrap_or("rust-project");
365
366    // Use the directory path for deterministic caching
367    let project_dir = project_cache_dir(Some(dir))?;
368    let src_dir = project_dir.join("src");
369    fs::create_dir_all(&src_dir).map_err(|e| format!("Failed to create src directory: {e}"))?;
370
371    // Collect any embedded manifests for Cargo.toml generation
372    let manifest = collect_embedded_manifests(&all_files);
373    let cargo_toml = if let Some(ref m) = manifest {
374        generate_cargo_toml(m, Some(dir))
375    } else {
376        format!(
377            "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
378            dir_name
379                .to_lowercase()
380                .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
381        )
382    };
383    fs::write(project_dir.join("Cargo.toml"), &cargo_toml)
384        .map_err(|e| format!("Failed to write Cargo.toml: {e}"))?;
385
386    // Copy entry file as src/main.rs (strip manifest comments if any)
387    let entry_source = fs::read_to_string(&entry_file)
388        .map_err(|e| format!("Failed to read '{}': {e}", entry_file.display()))?;
389    let clean_entry = strip_manifest_comments(&entry_source);
390    fs::write(src_dir.join("main.rs"), &clean_entry)
391        .map_err(|e| format!("Failed to write main.rs: {e}"))?;
392
393    // Copy all other .rs files as modules in src/
394    for file in &all_files {
395        if file == &entry_file {
396            continue;
397        }
398        let file_name = file
399            .file_name()
400            .ok_or_else(|| format!("Invalid file path: {}", file.display()))?;
401        let source = fs::read_to_string(file)
402            .map_err(|e| format!("Failed to read '{}': {e}", file.display()))?;
403        let clean = strip_manifest_comments(&source);
404        fs::write(src_dir.join(file_name), &clean)
405            .map_err(|e| format!("Failed to write '{}': {e}", file_name.to_string_lossy()))?;
406    }
407
408    eprintln!(
409        "\x1b[1;32m   Compiling\x1b[0m {} (rust source directory, {} files)",
410        dir_name,
411        all_files.len()
412    );
413
414    let mut cmd = Command::new("cargo");
415    cmd.args(["run", "--quiet"]).current_dir(&project_dir);
416
417    // Pass extra arguments directly to cargo
418    if !extra_args.is_empty() {
419        cmd.args(extra_args);
420    }
421
422    let status = cmd
423        .status()
424        .map_err(|e| format!("Failed to invoke cargo: {e}. Is cargo installed?"))?;
425
426    if !status.success() {
427        return Err(format!(
428            "Compilation failed (exit code: {})",
429            status.code().unwrap_or(-1)
430        ));
431    }
432
433    Ok(())
434}
435
436/// Runs an existing Cargo project directory with `cargo run`.
437///
438/// If the project has no dependencies (empty `[dependencies]` or none at all),
439/// the interpreter *could* be used, but we always delegate to cargo for
440/// full compatibility with the project's build configuration, build scripts,
441/// proc macros, multiple source files, modules, etc.
442pub fn run_cargo_project(project_dir: &Path, extra_args: &[String]) -> Result<(), String> {
443    let cargo_toml = project_dir.join("Cargo.toml");
444    if !cargo_toml.exists() {
445        return Err(format!(
446            "No Cargo.toml found in '{}'",
447            project_dir.display()
448        ));
449    }
450
451    // Read project name from Cargo.toml for display
452    let display_name = read_project_name(&cargo_toml)
453        .unwrap_or_else(|| project_dir.file_name()
454            .and_then(|s| s.to_str())
455            .unwrap_or("project")
456            .to_string());
457
458    eprintln!(
459        "\x1b[1;32m   Compiling\x1b[0m {} (cargo project)",
460        display_name
461    );
462
463    let mut cmd = Command::new("cargo");
464    cmd.arg("run").arg("--quiet").current_dir(project_dir);
465
466    // Pass extra arguments directly to cargo (e.g. --bin <name>, --release, -- <program args>)
467    if !extra_args.is_empty() {
468        cmd.args(extra_args);
469    }
470
471    let status = cmd
472        .status()
473        .map_err(|e| format!("Failed to invoke cargo: {e}. Is cargo installed?"))?;
474
475    if !status.success() {
476        return Err(format!(
477            "cargo run failed (exit code: {})",
478            status.code().unwrap_or(-1)
479        ));
480    }
481
482    Ok(())
483}
484
485/// Reads the `name` field from a Cargo.toml (best-effort, no TOML parser).
486fn read_project_name(cargo_toml: &Path) -> Option<String> {
487    let content = fs::read_to_string(cargo_toml).ok()?;
488    for line in content.lines() {
489        let trimmed = line.trim();
490        if let Some(rest) = trimmed.strip_prefix("name") {
491            let rest = rest.trim();
492            if let Some(rest) = rest.strip_prefix('=') {
493                let rest = rest.trim().trim_matches('"');
494                return Some(rest.to_string());
495            }
496        }
497    }
498    None
499}
500
501// ── Tests ────────────────────────────────────────────────────────────
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn parse_manifest_with_dependencies() {
509        let source = r#"//! [dependencies]
510//! rand = "0.8"
511//! serde = { version = "1.0", features = ["derive"] }
512
513use rand::Rng;
514
515fn main() {
516    println!("hello");
517}
518"#;
519        let manifest = parse_embedded_manifest(source).unwrap();
520        assert!(manifest.toml_content.contains("[dependencies]"));
521        assert!(manifest.toml_content.contains("rand = \"0.8\""));
522        assert!(manifest.toml_content.contains("serde"));
523    }
524
525    #[test]
526    fn parse_manifest_none_without_deps() {
527        let source = r#"fn main() {
528    println!("hello");
529}
530"#;
531        assert!(parse_embedded_manifest(source).is_none());
532    }
533
534    #[test]
535    fn strip_manifest_preserves_code() {
536        let source = r#"//! [dependencies]
537//! rand = "0.8"
538
539use rand::Rng;
540
541fn main() {}
542"#;
543        let cleaned = strip_manifest_comments(source);
544        assert!(!cleaned.contains("//!"));
545        assert!(cleaned.contains("use rand::Rng;"));
546        assert!(cleaned.contains("fn main()"));
547    }
548
549    #[test]
550    fn has_dependencies_detection() {
551        assert!(has_dependencies(
552            "//! [dependencies]\n//! x = \"1\"\nfn main() {}"
553        ));
554        assert!(!has_dependencies("fn main() { println!(\"hi\"); }"));
555    }
556
557    #[test]
558    fn generate_toml_includes_package() {
559        let manifest = EmbeddedManifest {
560            toml_content: "[dependencies]\nrand = \"0.8\"".to_string(),
561        };
562        let toml = generate_cargo_toml(&manifest, None);
563        assert!(toml.contains("[package]"));
564        assert!(toml.contains("edition = \"2021\""));
565        assert!(toml.contains("[dependencies]"));
566        assert!(toml.contains("rand = \"0.8\""));
567    }
568
569    #[test]
570    fn generate_toml_respects_existing_package() {
571        let manifest = EmbeddedManifest {
572            toml_content: "[package]\nname = \"my-script\"\nedition = \"2021\"\n\n[dependencies]\nrand = \"0.8\"".to_string(),
573        };
574        let toml = generate_cargo_toml(&manifest, None);
575        // Should NOT duplicate [package]
576        assert_eq!(toml.matches("[package]").count(), 1);
577        assert!(toml.contains("my-script"));
578    }
579
580    #[test]
581    fn is_cargo_project_detection() {
582        // The virtual-rust project itself is a cargo project
583        let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
584        assert!(is_cargo_project(project_root));
585
586        // A non-existent path is not
587        assert!(!is_cargo_project(Path::new("/nonexistent/fake/path")));
588
589        // A file is not a directory
590        let cargo_toml = project_root.join("Cargo.toml");
591        assert!(!is_cargo_project(&cargo_toml));
592    }
593
594    #[test]
595    fn read_project_name_works() {
596        let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
597        let name = read_project_name(&project_root.join("Cargo.toml"));
598        assert_eq!(name, Some("virtual-rust".to_string()));
599    }
600
601    #[test]
602    fn is_rust_source_dir_detection() {
603        let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
604
605        // A directory with .rs files and no Cargo.toml
606        let loose_dir = project_root.join("examples").join("loose_modules");
607        assert!(is_rust_source_dir(&loose_dir));
608
609        // A Cargo project directory is NOT a rust source dir
610        assert!(!is_rust_source_dir(project_root));
611
612        // A non-existent path is not
613        assert!(!is_rust_source_dir(Path::new("/nonexistent/fake/path")));
614
615        // A file is not a directory
616        assert!(!is_rust_source_dir(&project_root.join("Cargo.toml")));
617    }
618
619    #[test]
620    fn find_entry_file_works() {
621        let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
622        let loose_dir = project_root.join("examples").join("loose_modules");
623
624        let (entry, all_files) = find_entry_file(&loose_dir).unwrap();
625        assert_eq!(entry.file_name().unwrap(), "main.rs");
626        assert_eq!(all_files.len(), 3); // main.rs, math.rs, greeting.rs
627    }
628
629    #[test]
630    fn find_entry_file_no_main() {
631        // Create a temp dir with a .rs file that has no main
632        let tmp = std::env::temp_dir().join("virtual-rust-test-no-main");
633        let _ = fs::create_dir_all(&tmp);
634        fs::write(tmp.join("lib.rs"), "pub fn foo() {}").unwrap();
635        let result = find_entry_file(&tmp);
636        assert!(result.is_err());
637        assert!(result.unwrap_err().contains("No entry point found"));
638        let _ = fs::remove_dir_all(&tmp);
639    }
640}