@ape-egg/vibe-compiler 4.3.25 → 4.3.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "4.3.25"
3
+ version = "4.3.27"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -1,7 +1,6 @@
1
1
  use std::fs;
2
2
  use std::path::{Path, PathBuf};
3
- use std::process::Command;
4
- use std::time::Instant;
3
+ use std::time::{Duration, Instant, SystemTime};
5
4
  use std::collections::{BTreeMap, HashSet, HashMap};
6
5
  use thiserror::Error;
7
6
  use colored::Colorize;
@@ -39,17 +38,39 @@ fn manifest_url_path(relative_path: &str, root: Option<&str>) -> String {
39
38
  .join("/")
40
39
  }
41
40
 
42
- /// Write via a same-directory temp file + rename, so no reader — the dev
43
- /// server, the browser, another compiler process — can ever observe a
44
- /// partially-written file. Rename is atomic on POSIX; the temp name carries the
45
- /// pid so two processes writing the same target never share a temp file.
46
- pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
41
+ /// Overwrite the output file in place. A temp file + rename would keep a torn
42
+ /// file from ever being read, but the temp file itself vanishes mid-build, and
43
+ /// a dev server that lists the output and then stats each entry (wrangler dev)
44
+ /// dies on the one that is gone. A torn read is fixed by the next reload.
45
+ ///
46
+ /// A file that shares its bytes with another path — a hard link into a package
47
+ /// manager's cache, as bun installs, or a symlink — is replaced through a
48
+ /// rename instead, since writing through it would rewrite that other path too.
49
+ pub(crate) fn write_in_place(path: &Path, contents: impl AsRef<[u8]>) -> std::io::Result<()> {
50
+ if !shares_bytes(path) {
51
+ return fs::write(path, contents);
52
+ }
47
53
  let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
48
54
  let tmp = path.with_file_name(format!(".{}.{}.vibe-tmp", file_name, std::process::id()));
49
55
  fs::write(&tmp, contents)?;
50
56
  fs::rename(&tmp, path)
51
57
  }
52
58
 
59
+ fn copy_in_place(src: &Path, dest: &Path) -> std::io::Result<()> {
60
+ write_in_place(dest, fs::read(src)?)
61
+ }
62
+
63
+ #[cfg(unix)]
64
+ fn shares_bytes(path: &Path) -> bool {
65
+ use std::os::unix::fs::MetadataExt;
66
+ fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink() || meta.nlink() > 1)
67
+ }
68
+
69
+ #[cfg(not(unix))]
70
+ fn shares_bytes(path: &Path) -> bool {
71
+ fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink())
72
+ }
73
+
53
74
  // =============================================================================
54
75
  // MIRROR_MODE: Copy asset files from source to output as-is, preserving
55
76
  // directory structure. HTML files are compiled separately.
@@ -326,10 +347,8 @@ pub enum CompileError {
326
347
  },
327
348
  #[error("Parse error: {0}")]
328
349
  ParseError(#[from] crate::parser::html::ParseError),
329
- #[error("Failed to execute command: {0}")]
330
- CommandError(String),
331
- #[error("Package manager not found")]
332
- PackageManagerNotFound,
350
+ #[error("Failed to copy packages: {0}")]
351
+ ModulesError(String),
333
352
  #[error("Component name '{component_name}' in '{file_path}' conflicts with reserved element. Component filenames cannot match standard HTML elements (case-insensitive). Use PascalCase (e.g., 'Nav.html' instead of 'nav.html') to avoid conflicts.")]
334
353
  ReservedComponentName {
335
354
  component_name: String,
@@ -351,7 +370,6 @@ pub struct CompileStats {
351
370
  pub components_time_ms: f64,
352
371
  pub validation_time_ms: Option<f64>,
353
372
  pub node_modules_time_ms: Option<f64>,
354
- pub package_manager: Option<String>,
355
373
  pub node_modules_copied_as_is: bool,
356
374
  pub components_as_is: bool,
357
375
  }
@@ -630,6 +648,8 @@ pub struct Compiler {
630
648
  /// disk read-back — so a concurrent writer rewriting the output directory
631
649
  /// (e.g. a second compiler) can't feed a torn read into a manifest.
632
650
  compiled_html: HashMap<PathBuf, String>,
651
+ /// When the last full compile began; output older than it is stale.
652
+ build_start: SystemTime,
633
653
  }
634
654
 
635
655
  impl Compiler {
@@ -642,6 +662,7 @@ impl Compiler {
642
662
  unique_components: HashSet::new(),
643
663
  component_cache: HashMap::new(),
644
664
  compiled_html: HashMap::new(),
665
+ build_start: SystemTime::now(),
645
666
  }
646
667
  }
647
668
 
@@ -683,7 +704,6 @@ impl Compiler {
683
704
  components_time_ms: 0.0,
684
705
  validation_time_ms: None,
685
706
  node_modules_time_ms: None,
686
- package_manager: None,
687
707
  node_modules_copied_as_is: false,
688
708
  components_as_is: self.config.components_as_is,
689
709
  };
@@ -727,18 +747,10 @@ impl Compiler {
727
747
  println!(" HTML validation completed successfully");
728
748
  }
729
749
 
730
- // Clean output directory if not in no-clean mode
731
- if !self.config.no_clean && self.config.output.exists() {
732
- if self.verbose {
733
- println!(" Cleaning output directory: {}", self.config.output.display());
734
- }
735
- fs::remove_dir_all(&self.config.output).map_err(|e| {
736
- CompileError::WriteError {
737
- path: self.config.output.display().to_string(),
738
- source: e,
739
- }
740
- })?;
741
- }
750
+ // Output is overwritten in place and stale files are removed only at
751
+ // the end (remove_stale_output), so a dev server reading the output
752
+ // never sees it emptied.
753
+ self.build_start = SystemTime::now();
742
754
 
743
755
  // Create output directory
744
756
  if !self.config.output.exists() {
@@ -814,31 +826,16 @@ impl Compiler {
814
826
  }
815
827
  }
816
828
 
817
- // Handle node_modules after successful compilation
829
+ // Packages after successful compilation
818
830
  if self.config.working_dir.join("node_modules").exists() {
831
+ let nm_start = Instant::now();
819
832
  if self.config.node_modules_as_is {
820
- // Copy node_modules as-is (no install, just copy)
821
- let nm_start = Instant::now();
822
- self.copy_node_modules_as_is()?;
823
- stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
824
- stats.node_modules_copied_as_is = true;
825
-
826
- if self.verbose {
827
- println!("\n Copied node_modules as-is");
828
- }
833
+ copy_dir_recursive(&self.config.working_dir.join("node_modules"), &self.config.modules_path())?;
829
834
  } else {
830
- // Use production-only strategy (runs install commands)
831
- let pkg_manager = detect_package_manager(&self.config.working_dir).ok();
832
- stats.package_manager = pkg_manager.clone();
833
-
834
- let nm_start = Instant::now();
835
- self.copy_production_node_modules()?;
836
- stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
837
-
838
- if self.verbose {
839
- println!(" Copied production dependencies");
840
- }
835
+ self.copy_production_modules()?;
841
836
  }
837
+ stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
838
+ stats.node_modules_copied_as_is = self.config.node_modules_as_is;
842
839
  }
843
840
 
844
841
  Ok(stats)
@@ -862,7 +859,6 @@ impl Compiler {
862
859
  components_time_ms: 0.0,
863
860
  validation_time_ms: None,
864
861
  node_modules_time_ms: None,
865
- package_manager: None,
866
862
  node_modules_copied_as_is: false,
867
863
  components_as_is: self.config.components_as_is,
868
864
  };
@@ -945,7 +941,6 @@ impl Compiler {
945
941
  components_time_ms: 0.0,
946
942
  validation_time_ms: None,
947
943
  node_modules_time_ms: None,
948
- package_manager: None,
949
944
  node_modules_copied_as_is: false,
950
945
  components_as_is: self.config.components_as_is,
951
946
  };
@@ -1118,7 +1113,7 @@ impl Compiler {
1118
1113
  manifest_json
1119
1114
  );
1120
1115
 
1121
- atomic_write(&manifest_path, &manifest_js)
1116
+ write_in_place(&manifest_path, &manifest_js)
1122
1117
  .map_err(|e| format!("Failed to write manifest: {}", e))?;
1123
1118
 
1124
1119
  // Stamp the compiled HTML with initial state values for FOUC prevention:
@@ -1143,7 +1138,7 @@ impl Compiler {
1143
1138
  let stamped = stamper.stamp_html(html.to_string())
1144
1139
  .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
1145
1140
 
1146
- atomic_write(html_path, &stamped)
1141
+ write_in_place(html_path, &stamped)
1147
1142
  .map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
1148
1143
  }
1149
1144
 
@@ -1512,7 +1507,7 @@ impl Compiler {
1512
1507
 
1513
1508
  // Write to output
1514
1509
  let output_path = self.get_output_path(path, relative_path)?;
1515
- atomic_write(&output_path, &output).map_err(|e| CompileError::WriteError {
1510
+ write_in_place(&output_path, &output).map_err(|e| CompileError::WriteError {
1516
1511
  path: output_path.display().to_string(),
1517
1512
  source: e,
1518
1513
  })?;
@@ -1693,7 +1688,7 @@ impl Compiler {
1693
1688
  CompileError::CreateDirError(parent.display().to_string())
1694
1689
  })?;
1695
1690
  }
1696
- atomic_write(&fragment_path, &page.fragment).map_err(|e| CompileError::WriteError {
1691
+ write_in_place(&fragment_path, &page.fragment).map_err(|e| CompileError::WriteError {
1697
1692
  path: fragment_path.display().to_string(),
1698
1693
  source: e,
1699
1694
  })?;
@@ -1798,7 +1793,7 @@ impl Compiler {
1798
1793
  };
1799
1794
  let shell_html = inject_vibe_css(shell_html, self.config.disable_vibe_css);
1800
1795
  let shell_path = self.config.output.join("index.html");
1801
- atomic_write(&shell_path, &shell_html).map_err(|e| CompileError::WriteError {
1796
+ write_in_place(&shell_path, &shell_html).map_err(|e| CompileError::WriteError {
1802
1797
  path: shell_path.display().to_string(),
1803
1798
  source: e,
1804
1799
  })?;
@@ -1913,7 +1908,7 @@ impl Compiler {
1913
1908
  fn copy_file(&self, path: &Path, relative_path: &str) -> Result<(), CompileError> {
1914
1909
  let output_path = self.get_output_path(path, relative_path)?;
1915
1910
 
1916
- fs::copy(path, &output_path).map_err(|e| CompileError::WriteError {
1911
+ copy_in_place(path, &output_path).map_err(|e| CompileError::WriteError {
1917
1912
  path: output_path.display().to_string(),
1918
1913
  source: e,
1919
1914
  })?;
@@ -1928,9 +1923,7 @@ impl Compiler {
1928
1923
  /// components_as_is — are served from that mirror at request time. Watch
1929
1924
  /// mode calls this for every changed component so the mirror tracks edits
1930
1925
  /// and deletions even when no page inlines the component (zero graph
1931
- /// dependents). Writes are atomic (same tmp+rename as compiled pages) so a
1932
- /// runtime fetch mid-copy never sees a torn file. Returns how many files
1933
- /// were copied.
1926
+ /// dependents). Returns how many files were copied.
1934
1927
  pub fn mirror_component_files(&self, files: &[PathBuf]) -> Result<usize, CompileError> {
1935
1928
  let mut copied = 0;
1936
1929
  for path in files {
@@ -1947,7 +1940,7 @@ impl Compiler {
1947
1940
  path: path.display().to_string(),
1948
1941
  source: e,
1949
1942
  })?;
1950
- atomic_write(&output_path, &contents).map_err(|e| CompileError::WriteError {
1943
+ write_in_place(&output_path, &contents).map_err(|e| CompileError::WriteError {
1951
1944
  path: output_path.display().to_string(),
1952
1945
  source: e,
1953
1946
  })?;
@@ -2494,120 +2487,96 @@ impl Compiler {
2494
2487
  Ok(())
2495
2488
  }
2496
2489
 
2497
- /// Copy node_modules as-is to output
2498
- fn copy_node_modules_as_is(&self) -> Result<(), CompileError> {
2499
- let src = self.config.working_dir.join("node_modules");
2500
- let dest = self.config.output.join("node_modules");
2501
-
2502
- // Remove existing node_modules in output if it exists
2503
- if dest.exists() {
2504
- fs::remove_dir_all(&dest).map_err(|e| CompileError::WriteError {
2505
- path: dest.display().to_string(),
2506
- source: e,
2507
- })?;
2508
- }
2509
-
2510
- // Copy recursively
2511
- copy_dir_recursive(&src, &dest)?;
2512
-
2513
- Ok(())
2514
- }
2515
-
2516
- /// Install production dependencies directly into output directory
2517
- fn copy_production_node_modules(&self) -> Result<(), CompileError> {
2518
- let pkg_manager = detect_package_manager(&self.config.working_dir)?;
2519
-
2520
- if self.verbose {
2521
- println!("\n Detected package manager: {}", pkg_manager);
2522
- println!(" Installing production dependencies to output...");
2523
- }
2524
-
2525
- // 1. Copy package.json to output directory
2526
- let package_json_src = self.config.working_dir.join("package.json");
2527
- let package_json_dest = self.config.output.join("package.json");
2528
-
2529
- fs::copy(&package_json_src, &package_json_dest).map_err(|e| CompileError::WriteError {
2530
- path: package_json_dest.display().to_string(),
2531
- source: e,
2532
- })?;
2533
-
2534
- // 2. Copy lockfile if it exists (needed for reproducible installs)
2535
- let lockfile_name = match pkg_manager.as_str() {
2536
- "bun" if self.config.working_dir.join("bun.lock").exists() => "bun.lock",
2537
- "bun" => "bun.lockb",
2538
- "pnpm" => "pnpm-lock.yaml",
2539
- "yarn" => "yarn.lock",
2540
- "npm" => "package-lock.json",
2541
- _ => "",
2542
- };
2543
-
2544
- if !lockfile_name.is_empty() {
2545
- let lockfile_src = self.config.working_dir.join(lockfile_name);
2546
- let lockfile_dest = self.config.output.join(lockfile_name);
2547
- if lockfile_src.exists() {
2548
- fs::copy(&lockfile_src, &lockfile_dest).ok();
2490
+ /// Remove whatever the last full compile did not write, then the folders it
2491
+ /// left empty. Run once the build is done — manifests included — so a
2492
+ /// file disappears only when it is stale. File times come from the
2493
+ /// kernel's coarse clock, which trails SystemTime::now(), hence the margin.
2494
+ pub fn remove_stale_output(&self) -> Result<usize, CompileError> {
2495
+ if self.config.no_clean {
2496
+ return Ok(0);
2497
+ }
2498
+ remove_older_than(&self.config.output, self.build_start - Duration::from_secs(1))
2499
+ }
2500
+
2501
+ /// Copy every production dependency, and theirs, from the project's own
2502
+ /// node_modules into the modules folder of the output. Written over what is
2503
+ /// there, never through a temp tree, so the output never loses a file.
2504
+ /// A `modules` folder other than node_modules holds each package under its
2505
+ /// bare name (`modules/vibe`, not `modules/@ape-egg/vibe`): some static
2506
+ /// hosts (Cloudflare) redirect any path holding `@`.
2507
+ fn copy_production_modules(&self) -> Result<(), CompileError> {
2508
+ let installed = self.config.working_dir.join("node_modules");
2509
+ let bare = self.config.modules != "node_modules";
2510
+ let mut pending = dependency_names(&self.config.working_dir.join("package.json"))?;
2511
+ let mut copied: HashMap<String, String> = HashMap::new();
2512
+ while let Some(name) = pending.pop() {
2513
+ let src = installed.join(&name);
2514
+ // A dependency missing at the top level is nested inside its dependent, and copied with it
2515
+ if copied.contains_key(&name) || !src.exists() {
2516
+ continue;
2549
2517
  }
2518
+ let folder = if bare { name.rsplit('/').next().unwrap_or(&name).to_string() } else { name.clone() };
2519
+ if let Some((other, _)) = copied.iter().find(|(_, f)| **f == folder) {
2520
+ return Err(CompileError::ModulesError(format!(
2521
+ "\"{}\" and \"{}\" both land in {}/{} — bare package names must be unique in \"modules\"",
2522
+ other, name, self.config.modules, folder
2523
+ )));
2524
+ }
2525
+ copy_dir_recursive(&src, &self.config.modules_path().join(&folder))?;
2526
+ pending.extend(dependency_names(&src.join("package.json"))?);
2527
+ copied.insert(name, folder);
2550
2528
  }
2551
-
2552
- // 3. Install production dependencies directly in output directory
2553
- run_command(&pkg_manager, &["install", "--production"], &self.config.output)?;
2554
-
2555
- // 4. Cleanup: remove package.json and lockfile from output
2556
- fs::remove_file(&package_json_dest).ok();
2557
- if !lockfile_name.is_empty() {
2558
- let lockfile_dest = self.config.output.join(lockfile_name);
2559
- fs::remove_file(&lockfile_dest).ok();
2560
- }
2561
-
2562
- if self.verbose {
2563
- println!(" Installed production dependencies to output");
2564
- }
2565
-
2566
2529
  Ok(())
2567
2530
  }
2568
2531
  }
2569
2532
 
2570
- /// Detect package manager from lockfiles
2571
- fn detect_package_manager(dir: &Path) -> Result<String, CompileError> {
2572
- if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
2573
- return Ok("bun".to_string());
2574
- }
2575
- if dir.join("pnpm-lock.yaml").exists() {
2576
- return Ok("pnpm".to_string());
2577
- }
2578
- if dir.join("yarn.lock").exists() {
2579
- return Ok("yarn".to_string());
2580
- }
2581
- if dir.join("package-lock.json").exists() {
2582
- return Ok("npm".to_string());
2533
+ /// Remove every file under `dir` last written before `cutoff`, and every
2534
+ /// folder that leaves empty. Returns how many files went.
2535
+ fn remove_older_than(dir: &Path, cutoff: SystemTime) -> Result<usize, CompileError> {
2536
+ let read_error = |e| CompileError::ReadError { path: dir.display().to_string(), source: e };
2537
+ let write_error = |path: &Path, e| CompileError::WriteError { path: path.display().to_string(), source: e };
2538
+ let mut removed = 0;
2539
+ for entry in fs::read_dir(dir).map_err(read_error)? {
2540
+ let entry = entry.map_err(read_error)?;
2541
+ let path = entry.path();
2542
+ let meta = entry.metadata().map_err(read_error)?;
2543
+ if meta.is_dir() {
2544
+ removed += remove_older_than(&path, cutoff)?;
2545
+ if fs::read_dir(&path).map_err(read_error)?.next().is_none() {
2546
+ fs::remove_dir(&path).map_err(|e| write_error(&path, e))?;
2547
+ }
2548
+ } else if meta.modified().map_err(read_error)? < cutoff {
2549
+ fs::remove_file(&path).map_err(|e| write_error(&path, e))?;
2550
+ removed += 1;
2551
+ }
2583
2552
  }
2584
-
2585
- Err(CompileError::PackageManagerNotFound)
2553
+ Ok(removed)
2586
2554
  }
2587
2555
 
2588
- /// Run a package manager command
2589
- fn run_command(pkg_manager: &str, args: &[&str], cwd: &Path) -> Result<(), CompileError> {
2590
- let output = Command::new(pkg_manager)
2591
- .args(args)
2592
- .current_dir(cwd)
2593
- .output()
2594
- .map_err(|e| CompileError::CommandError(format!("{} not found: {}", pkg_manager, e)))?;
2595
-
2596
- if !output.status.success() {
2597
- let stderr = String::from_utf8_lossy(&output.stderr);
2598
- return Err(CompileError::CommandError(format!(
2599
- "{} {} failed: {}",
2600
- pkg_manager,
2601
- args.join(" "),
2602
- stderr
2603
- )));
2604
- }
2605
-
2606
- Ok(())
2556
+ /// Names under `dependencies`, `optionalDependencies` and `peerDependencies`
2557
+ fn dependency_names(package_json: &Path) -> Result<Vec<String>, CompileError> {
2558
+ let content = fs::read_to_string(package_json).map_err(|e| CompileError::ReadError {
2559
+ path: package_json.display().to_string(),
2560
+ source: e,
2561
+ })?;
2562
+ let package: Value = serde_json::from_str(&content)
2563
+ .map_err(|e| CompileError::ModulesError(format!("{}: {}", package_json.display(), e)))?;
2564
+ Ok(["dependencies", "optionalDependencies", "peerDependencies"]
2565
+ .iter()
2566
+ .filter_map(|key| package.get(key).and_then(Value::as_object))
2567
+ .flat_map(|deps| deps.keys().cloned())
2568
+ .collect())
2607
2569
  }
2608
2570
 
2609
2571
  /// Recursively copy a directory
2610
2572
  fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2573
+ // Copying into a symlinked folder would write into the folder it points at
2574
+ if fs::symlink_metadata(dest).is_ok_and(|meta| meta.file_type().is_symlink()) {
2575
+ fs::remove_file(dest).map_err(|e| CompileError::WriteError {
2576
+ path: dest.display().to_string(),
2577
+ source: e,
2578
+ })?;
2579
+ }
2611
2580
  if !dest.exists() {
2612
2581
  fs::create_dir_all(dest).map_err(|_| CompileError::CreateDirError(dest.display().to_string()))?;
2613
2582
  }
@@ -2635,7 +2604,7 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2635
2604
  }
2636
2605
  copy_dir_recursive(&path, &dest_path)?;
2637
2606
  } else {
2638
- fs::copy(&path, &dest_path).map_err(|e| CompileError::WriteError {
2607
+ copy_in_place(&path, &dest_path).map_err(|e| CompileError::WriteError {
2639
2608
  path: dest_path.display().to_string(),
2640
2609
  source: e,
2641
2610
  })?;
@@ -2790,17 +2759,16 @@ mod tests {
2790
2759
  }
2791
2760
 
2792
2761
  #[test]
2793
- fn atomic_write_replaces_content_without_leaving_tmp_files() {
2794
- let dir = std::env::temp_dir().join("vibe_atomic_write_test");
2762
+ fn write_in_place_replaces_content_without_leaving_tmp_files() {
2763
+ let dir = std::env::temp_dir().join("vibe_write_in_place_test");
2795
2764
  let _ = fs::remove_dir_all(&dir);
2796
2765
  fs::create_dir_all(&dir).unwrap();
2797
2766
  let target = dir.join("page.html");
2798
2767
 
2799
- atomic_write(&target, "first").unwrap();
2768
+ write_in_place(&target, "first").unwrap();
2800
2769
  assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2801
2770
 
2802
- // Overwriting an existing file goes through the same tmp+rename path.
2803
- atomic_write(&target, "second, longer content").unwrap();
2771
+ write_in_place(&target, "second, longer content").unwrap();
2804
2772
  assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
2805
2773
 
2806
2774
  let leftovers: Vec<String> = fs::read_dir(&dir)
@@ -2812,6 +2780,30 @@ mod tests {
2812
2780
  assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
2813
2781
  }
2814
2782
 
2783
+ // bun installs packages as hard links into its cache, and an output built
2784
+ // by an install holds more of them. Copying a package over such an output
2785
+ // once truncated the cached package, and every project linked to it.
2786
+ #[test]
2787
+ fn copy_in_place_never_writes_through_a_hard_link() {
2788
+ let dir = std::env::temp_dir().join("vibe_copy_in_place_test");
2789
+ let _ = fs::remove_dir_all(&dir);
2790
+ fs::create_dir_all(&dir).unwrap();
2791
+ let (cache, installed, output) = (dir.join("cache.js"), dir.join("installed.js"), dir.join("output.js"));
2792
+ fs::write(&cache, "cached").unwrap();
2793
+ fs::hard_link(&cache, &installed).unwrap();
2794
+ fs::hard_link(&cache, &output).unwrap();
2795
+
2796
+ copy_in_place(&installed, &output).unwrap();
2797
+ assert_eq!(fs::read_to_string(&cache).unwrap(), "cached");
2798
+ assert_eq!(fs::read_to_string(&output).unwrap(), "cached");
2799
+
2800
+ write_in_place(&installed, "newer").unwrap();
2801
+ assert_eq!(fs::read_to_string(&cache).unwrap(), "cached");
2802
+ assert_eq!(fs::read_to_string(&installed).unwrap(), "newer");
2803
+
2804
+ let _ = fs::remove_dir_all(&dir);
2805
+ }
2806
+
2815
2807
  // A minimal on-disk project: source with one page carrying a distinctive
2816
2808
  // binding, empty components dir, output dir sibling. Returns (config, page
2817
2809
  // source path, compiled page output path, manifest path).
@@ -2881,6 +2873,7 @@ mod tests {
2881
2873
  reserved_elements: Vec::new(),
2882
2874
  skip_files: Vec::new(),
2883
2875
  node_modules_as_is: false,
2876
+ modules: "node_modules".to_string(),
2884
2877
  components_as_is: true,
2885
2878
  runtime_as_is: false,
2886
2879
  iterations_as_is: false,
@@ -2970,6 +2963,7 @@ mod tests {
2970
2963
  reserved_elements: Vec::new(),
2971
2964
  skip_files: Vec::new(),
2972
2965
  node_modules_as_is: false,
2966
+ modules: "node_modules".to_string(),
2973
2967
  components_as_is: false,
2974
2968
  runtime_as_is: false,
2975
2969
  iterations_as_is: false,
@@ -3083,6 +3077,7 @@ mod tests {
3083
3077
  reserved_elements: Vec::new(),
3084
3078
  skip_files: Vec::new(),
3085
3079
  node_modules_as_is: false,
3080
+ modules: "node_modules".to_string(),
3086
3081
  components_as_is: false,
3087
3082
  runtime_as_is: false,
3088
3083
  iterations_as_is: false,
@@ -3155,6 +3150,7 @@ mod tests {
3155
3150
  reserved_elements: Vec::new(),
3156
3151
  skip_files: Vec::new(),
3157
3152
  node_modules_as_is: false,
3153
+ modules: "node_modules".to_string(),
3158
3154
  components_as_is: false,
3159
3155
  runtime_as_is: false,
3160
3156
  iterations_as_is: false,