Skip to main content

cuenv_workspaces/materializer/
node_modules.rs

1//! Materializer for Node.js dependencies (`node_modules`).
2
3use super::Materializer;
4use crate::core::types::{LockfileEntry, PackageManager, Workspace};
5use crate::error::{Error, Result};
6use std::path::{Path, PathBuf};
7
8#[cfg(unix)]
9use std::os::unix::fs::symlink;
10#[cfg(windows)]
11use std::os::windows::fs::symlink_dir as symlink;
12
13/// Materializer for Node.js projects.
14pub struct NodeModulesMaterializer;
15
16impl Materializer for NodeModulesMaterializer {
17    fn materialize(
18        &self,
19        workspace: &Workspace,
20        _entries: &[LockfileEntry],
21        target_dir: &Path,
22    ) -> Result<()> {
23        if !matches!(
24            workspace.manager,
25            PackageManager::Npm
26                | PackageManager::Bun
27                | PackageManager::Pnpm
28                | PackageManager::YarnClassic
29                | PackageManager::YarnModern
30        ) {
31            return Ok(());
32        }
33
34        // Strategy:
35        // 1. Symlink the entire node_modules from workspace root if it exists.
36        //    This allows leveraging the existing installed dependencies.
37        // 2. (Future) Link individual packages from global cache (pnpm store, etc.)
38        //    for granular, hermetic population.
39
40        let workspace_nm = workspace.root.join("node_modules");
41        let target_nm = target_dir.join("node_modules");
42
43        if workspace_nm.exists() {
44            if !target_nm.exists() {
45                symlink(&workspace_nm, &target_nm).map_err(|e| Error::Io {
46                    source: e,
47                    path: Some(target_nm.clone()),
48                    operation: "symlink node_modules".to_string(),
49                })?;
50            }
51        } else {
52            // Warn or log that dependencies might be missing?
53            // For now, we assume if node_modules is missing, the user might run install
54            // or we are in a state where it's not needed yet.
55        }
56
57        Ok(())
58    }
59}
60
61impl NodeModulesMaterializer {
62    /// Detects the global cache directory for the package manager.
63    ///
64    /// This can be used in the future to populate dependencies directly from cache.
65    pub fn detect_cache_dir(manager: PackageManager) -> Option<PathBuf> {
66        let home = std::env::var_os("HOME")
67            .or_else(|| std::env::var_os("USERPROFILE"))
68            .map(PathBuf::from)?;
69
70        match manager {
71            PackageManager::Npm => Some(home.join(".npm")),
72            PackageManager::Pnpm => {
73                // pnpm store path can vary (v3, etc), checking common default
74                Some(home.join(".local/share/pnpm/store/v3"))
75            }
76            PackageManager::Bun => Some(home.join(".bun/install/cache")),
77            PackageManager::YarnClassic => Some(home.join(".yarn/cache")),
78            PackageManager::YarnModern => Some(home.join(".yarn/berry/cache")),
79            PackageManager::Deno => Some(home.join(".cache/deno")),
80            PackageManager::Cargo => None,
81        }
82    }
83}
84
85#[cfg(test)]
86#[allow(unsafe_code)]
87mod tests {
88    use super::*;
89    use std::fs;
90    use tempfile::TempDir;
91
92    fn make_workspace(root: &Path, manager: PackageManager) -> Workspace {
93        Workspace::new(root.to_path_buf(), manager)
94    }
95
96    // ==========================================================================
97    // NodeModulesMaterializer::materialize tests
98    // ==========================================================================
99
100    #[test]
101    fn test_node_modules_materializer_skips_cargo() {
102        let temp_dir = TempDir::new().unwrap();
103        let workspace = make_workspace(temp_dir.path(), PackageManager::Cargo);
104        let target_dir = temp_dir.path().join("hermetic");
105        fs::create_dir_all(&target_dir).unwrap();
106
107        let materializer = NodeModulesMaterializer;
108        let result = materializer.materialize(&workspace, &[], &target_dir);
109
110        assert!(result.is_ok());
111        // No symlink should be created for Cargo projects
112        assert!(!target_dir.join("node_modules").exists());
113    }
114
115    #[test]
116    fn test_node_modules_materializer_handles_npm() {
117        let temp_dir = TempDir::new().unwrap();
118        let workspace = make_workspace(temp_dir.path(), PackageManager::Npm);
119        let target_dir = temp_dir.path().join("hermetic");
120        fs::create_dir_all(&target_dir).unwrap();
121
122        // Create source node_modules
123        let workspace_nm = temp_dir.path().join("node_modules");
124        fs::create_dir_all(&workspace_nm).unwrap();
125        fs::write(workspace_nm.join("marker"), "npm").unwrap();
126
127        let materializer = NodeModulesMaterializer;
128        let result = materializer.materialize(&workspace, &[], &target_dir);
129
130        assert!(result.is_ok());
131        // Symlink should be created
132        assert!(target_dir.join("node_modules").exists());
133    }
134
135    #[test]
136    fn test_node_modules_materializer_handles_bun() {
137        let temp_dir = TempDir::new().unwrap();
138        let workspace = make_workspace(temp_dir.path(), PackageManager::Bun);
139        let target_dir = temp_dir.path().join("hermetic");
140        fs::create_dir_all(&target_dir).unwrap();
141
142        // Create source node_modules
143        let workspace_nm = temp_dir.path().join("node_modules");
144        fs::create_dir_all(&workspace_nm).unwrap();
145
146        let materializer = NodeModulesMaterializer;
147        let result = materializer.materialize(&workspace, &[], &target_dir);
148
149        assert!(result.is_ok());
150        assert!(target_dir.join("node_modules").exists());
151    }
152
153    #[test]
154    fn test_node_modules_materializer_handles_pnpm() {
155        let temp_dir = TempDir::new().unwrap();
156        let workspace = make_workspace(temp_dir.path(), PackageManager::Pnpm);
157        let target_dir = temp_dir.path().join("hermetic");
158        fs::create_dir_all(&target_dir).unwrap();
159
160        // Create source node_modules
161        let workspace_nm = temp_dir.path().join("node_modules");
162        fs::create_dir_all(&workspace_nm).unwrap();
163
164        let materializer = NodeModulesMaterializer;
165        let result = materializer.materialize(&workspace, &[], &target_dir);
166
167        assert!(result.is_ok());
168        assert!(target_dir.join("node_modules").exists());
169    }
170
171    #[test]
172    fn test_node_modules_materializer_handles_yarn_classic() {
173        let temp_dir = TempDir::new().unwrap();
174        let workspace = make_workspace(temp_dir.path(), PackageManager::YarnClassic);
175        let target_dir = temp_dir.path().join("hermetic");
176        fs::create_dir_all(&target_dir).unwrap();
177
178        // Create source node_modules
179        let workspace_nm = temp_dir.path().join("node_modules");
180        fs::create_dir_all(&workspace_nm).unwrap();
181
182        let materializer = NodeModulesMaterializer;
183        let result = materializer.materialize(&workspace, &[], &target_dir);
184
185        assert!(result.is_ok());
186        assert!(target_dir.join("node_modules").exists());
187    }
188
189    #[test]
190    fn test_node_modules_materializer_handles_yarn_modern() {
191        let temp_dir = TempDir::new().unwrap();
192        let workspace = make_workspace(temp_dir.path(), PackageManager::YarnModern);
193        let target_dir = temp_dir.path().join("hermetic");
194        fs::create_dir_all(&target_dir).unwrap();
195
196        // Create source node_modules
197        let workspace_nm = temp_dir.path().join("node_modules");
198        fs::create_dir_all(&workspace_nm).unwrap();
199
200        let materializer = NodeModulesMaterializer;
201        let result = materializer.materialize(&workspace, &[], &target_dir);
202
203        assert!(result.is_ok());
204        assert!(target_dir.join("node_modules").exists());
205    }
206
207    #[test]
208    fn test_node_modules_materializer_no_source_node_modules() {
209        let temp_dir = TempDir::new().unwrap();
210        let workspace = make_workspace(temp_dir.path(), PackageManager::Npm);
211        let target_dir = temp_dir.path().join("hermetic");
212        fs::create_dir_all(&target_dir).unwrap();
213
214        // Don't create source node_modules
215
216        let materializer = NodeModulesMaterializer;
217        let result = materializer.materialize(&workspace, &[], &target_dir);
218
219        // Should succeed but not create symlink
220        assert!(result.is_ok());
221        assert!(!target_dir.join("node_modules").exists());
222    }
223
224    #[test]
225    fn test_node_modules_materializer_skips_if_target_exists() {
226        let temp_dir = TempDir::new().unwrap();
227        let workspace = make_workspace(temp_dir.path(), PackageManager::Npm);
228        let target_dir = temp_dir.path().join("hermetic");
229        fs::create_dir_all(&target_dir).unwrap();
230
231        // Create source node_modules
232        let workspace_nm = temp_dir.path().join("node_modules");
233        fs::create_dir_all(&workspace_nm).unwrap();
234        fs::write(workspace_nm.join("marker"), "source").unwrap();
235
236        // Pre-create target node_modules (simulating previous symlink)
237        let target_nm = target_dir.join("node_modules");
238        fs::create_dir_all(&target_nm).unwrap();
239        fs::write(target_nm.join("existing"), "keep").unwrap();
240
241        let materializer = NodeModulesMaterializer;
242        let result = materializer.materialize(&workspace, &[], &target_dir);
243
244        assert!(result.is_ok());
245        // Existing directory should be preserved (code skips if target exists)
246        assert!(target_nm.join("existing").exists());
247    }
248
249    // ==========================================================================
250    // NodeModulesMaterializer::detect_cache_dir tests
251    // ==========================================================================
252
253    #[test]
254    fn test_detect_cache_dir_npm() {
255        // Set HOME for test
256        // SAFETY: Test runs in isolation, setting env var is acceptable
257        unsafe { std::env::set_var("HOME", "/home/test") };
258        let cache = NodeModulesMaterializer::detect_cache_dir(PackageManager::Npm);
259        assert!(cache.is_some());
260        assert!(cache.unwrap().ends_with(".npm"));
261    }
262
263    #[test]
264    fn test_detect_cache_dir_pnpm() {
265        // SAFETY: Test runs in isolation, setting env var is acceptable
266        unsafe { std::env::set_var("HOME", "/home/test") };
267        let cache = NodeModulesMaterializer::detect_cache_dir(PackageManager::Pnpm);
268        assert!(cache.is_some());
269        let cache_path = cache.unwrap();
270        assert!(cache_path.to_string_lossy().contains("pnpm"));
271    }
272
273    #[test]
274    fn test_detect_cache_dir_bun() {
275        // SAFETY: Test runs in isolation, setting env var is acceptable
276        unsafe { std::env::set_var("HOME", "/home/test") };
277        let cache = NodeModulesMaterializer::detect_cache_dir(PackageManager::Bun);
278        assert!(cache.is_some());
279        let cache_path = cache.unwrap();
280        assert!(cache_path.to_string_lossy().contains("bun"));
281    }
282
283    #[test]
284    fn test_detect_cache_dir_yarn_classic() {
285        // SAFETY: Test runs in isolation, setting env var is acceptable
286        unsafe { std::env::set_var("HOME", "/home/test") };
287        let cache = NodeModulesMaterializer::detect_cache_dir(PackageManager::YarnClassic);
288        assert!(cache.is_some());
289        let cache_path = cache.unwrap();
290        assert!(cache_path.to_string_lossy().contains("yarn"));
291    }
292
293    #[test]
294    fn test_detect_cache_dir_yarn_modern() {
295        // SAFETY: Test runs in isolation, setting env var is acceptable
296        unsafe { std::env::set_var("HOME", "/home/test") };
297        let cache = NodeModulesMaterializer::detect_cache_dir(PackageManager::YarnModern);
298        assert!(cache.is_some());
299        let cache_path = cache.unwrap();
300        assert!(cache_path.to_string_lossy().contains("berry"));
301    }
302
303    #[test]
304    fn test_detect_cache_dir_deno() {
305        // SAFETY: Test runs in isolation, setting env var is acceptable
306        unsafe { std::env::set_var("HOME", "/home/test") };
307        let cache = NodeModulesMaterializer::detect_cache_dir(PackageManager::Deno);
308        assert!(cache.is_some());
309        let cache_path = cache.unwrap();
310        assert!(cache_path.to_string_lossy().contains("deno"));
311    }
312
313    #[test]
314    fn test_detect_cache_dir_cargo_returns_none() {
315        // SAFETY: Test runs in isolation, setting env var is acceptable
316        unsafe { std::env::set_var("HOME", "/home/test") };
317        let cache = NodeModulesMaterializer::detect_cache_dir(PackageManager::Cargo);
318        assert!(cache.is_none());
319    }
320}