virtual_rust/
cargo_runner.rs1use std::fs;
46use std::hash::{Hash, Hasher};
47use std::path::{Path, PathBuf};
48use std::process::Command;
49
50pub struct EmbeddedManifest {
54 pub toml_content: String,
56}
57
58pub 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 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 if found_section {
79 toml_lines.push(String::new());
80 }
81 } else {
82 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
96pub fn has_dependencies(source: &str) -> bool {
98 parse_embedded_manifest(source).is_some()
99}
100
101fn 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; }
117 in_header = false;
118 }
119 result_lines.push(line);
120 }
121
122 result_lines.join("\n")
123}
124
125fn 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
150fn 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
174pub 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 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 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 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 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 let mut cmd = Command::new("cargo");
215 cmd.args(["run", "--quiet"]).current_dir(&project_dir);
216
217 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
236pub fn is_cargo_project(path: &Path) -> bool {
241 path.is_dir() && path.join("Cargo.toml").exists()
242}
243
244pub fn is_rust_source_dir(path: &Path) -> bool {
247 if !path.is_dir() || path.join("Cargo.toml").exists() {
248 return false;
249 }
250 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
261fn 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 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
306fn 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 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 all_deps_lines.push(line.to_string());
325 }
326 continue;
327 }
328 if in_deps && !trimmed.is_empty() {
329 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
351pub 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 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 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 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 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 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
436pub 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 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 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
485fn 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#[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 assert_eq!(toml.matches("[package]").count(), 1);
577 assert!(toml.contains("my-script"));
578 }
579
580 #[test]
581 fn is_cargo_project_detection() {
582 let project_root = Path::new(env!("CARGO_MANIFEST_DIR"));
584 assert!(is_cargo_project(project_root));
585
586 assert!(!is_cargo_project(Path::new("/nonexistent/fake/path")));
588
589 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 let loose_dir = project_root.join("examples").join("loose_modules");
607 assert!(is_rust_source_dir(&loose_dir));
608
609 assert!(!is_rust_source_dir(project_root));
611
612 assert!(!is_rust_source_dir(Path::new("/nonexistent/fake/path")));
614
615 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); }
628
629 #[test]
630 fn find_entry_file_no_main() {
631 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}