Skip to main content

jhol_core/
utils.rs

1use std::collections::HashMap;
2use std::env;
3use std::fs::{self, File, OpenOptions};
4use std::io::{Read, Result, Write};
5use std::path::{Path, PathBuf};
6use std::process::{Command, Output, Stdio};
7use std::thread;
8use std::time::Duration;
9use chrono::Local;
10use sha2::{Sha256, Digest};
11
12pub const LOG_FILE: &str = "logs.txt";
13pub const NPM_SHOW_TIMEOUT_SECS: u64 = 15;
14pub const NPM_INSTALL_TIMEOUT_SECS: u64 = 120;
15pub const CACHE_MANIFEST_NAME: &str = "manifest.json";
16pub const CACHE_MANIFEST_SIG: &str = "manifest.sig";
17
18/// Returns the path to the cache directory. Uses JHOL_CACHE_DIR if set;
19/// otherwise Windows: %USERPROFILE%\.jhol-cache, Unix: $HOME/.jhol-cache
20pub fn get_cache_dir() -> String {
21    if let Ok(dir) = env::var("JHOL_CACHE_DIR") {
22        return dir;
23    }
24    let base = if cfg!(target_os = "windows") {
25        env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string())
26    } else {
27        env::var("HOME").unwrap_or_else(|_| ".".to_string())
28    };
29    let sep = if cfg!(target_os = "windows") { "\\" } else { "/" };
30    format!("{}{}.jhol-cache", base, sep)
31}
32
33pub fn init_cache() -> Result<()> {
34    let cache_dir = get_cache_dir();
35    fs::create_dir_all(&cache_dir)?;
36
37    let log_path = PathBuf::from(format!("{}/{}", cache_dir, LOG_FILE));
38    if !log_path.exists() {
39        File::create(&log_path)?;
40    }
41
42    Ok(())
43}
44
45fn is_quiet() -> bool {
46    if env::var("JHOL_QUIET").map(|v| v == "1" || v == "true").unwrap_or(false) {
47        return true;
48    }
49    env::var("JHOL_LOG")
50        .map(|v| v.to_lowercase() == "quiet" || v.to_lowercase() == "error")
51        .unwrap_or(false)
52}
53
54pub fn log(message: &str) {
55    let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
56    let log_message = format!("[{}] {}", timestamp, message);
57
58    // When -q/--quiet or JHOL_LOG=quiet, never print logs to stdout (only to file)
59    if !is_quiet() {
60        println!("{}", log_message);
61    }
62
63    let log_path = format!("{}/{}", get_cache_dir(), LOG_FILE);
64
65    let mut should_write = true;
66    if let Ok(contents) = fs::read_to_string(&log_path) {
67        if let Some(last_line) = contents.lines().last() {
68            if last_line == log_message {
69                should_write = false;
70            }
71        }
72    }
73
74    if should_write {
75        if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
76            let _ = writeln!(file, "{}", log_message);
77        }
78    }
79}
80
81pub fn log_error(message: &str) {
82    eprintln!("{}", message);
83    log(message);
84}
85
86pub fn format_cache_name(package: &str) -> String {
87    package.replace('@', "-")
88}
89
90/// Convert stem like "lodash-4.17.21" or "@scope/pkg-1.0.0" to spec "lodash@4.17.21" / "@scope/pkg@1.0.0".
91fn stem_to_spec(stem: &str) -> String {
92    if let Some(pos) = stem.rfind('-') {
93        let suffix = &stem[pos + 1..];
94        if suffix.chars().any(|c| c.is_ascii_digit()) {
95            return format!("{}@{}", &stem[..pos], suffix);
96        }
97    }
98    stem.replace('-', "@")
99}
100
101fn cache_dir_path() -> PathBuf {
102    PathBuf::from(get_cache_dir())
103}
104
105/// Content-addressable store dir: cache_dir/store/
106fn store_dir() -> PathBuf {
107    cache_dir_path().join("store")
108}
109
110/// Unpacked store dir: cache_dir/store_unpacked/<hash>/ (one dir per tarball hash).
111pub fn store_unpacked_dir() -> PathBuf {
112    cache_dir_path().join("store_unpacked")
113}
114
115/// Whether to use symlinks/junctions when installing from store (JHOL_LINK=0 to disable).
116fn use_link() -> bool {
117    env::var("JHOL_LINK")
118        .map(|v| v != "0" && !v.is_empty())
119        .unwrap_or(true)
120}
121
122/// Create node_modules/<package_name> as a symlink (Unix) or directory junction (Windows) to store path.
123/// For scoped packages, creates node_modules/@scope/ and links pkg there.
124/// Returns Err if JHOL_LINK=0 or link creation fails (caller should fall back to copy).
125pub fn link_package_from_store(
126    store_unpacked_path: &Path,
127    node_modules: &Path,
128    package_name: &str,
129) -> std::result::Result<(), String> {
130    if !use_link() {
131        return Err("JHOL_LINK=0".to_string());
132    }
133    let link_path = if package_name.starts_with('@') {
134        // node_modules/@scope/pkg
135        let scope_and_name = package_name.splitn(2, '/').collect::<Vec<_>>();
136        if scope_and_name.len() != 2 {
137            return Err(format!("invalid scoped package name: {}", package_name));
138        }
139        node_modules.join(scope_and_name[0]).join(scope_and_name[1])
140    } else {
141        node_modules.join(package_name)
142    };
143    if link_path.exists() {
144        fs::remove_dir_all(&link_path).or_else(|_| fs::remove_file(&link_path)).ok();
145    }
146    if let Some(parent) = link_path.parent() {
147        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
148    }
149    #[cfg(unix)]
150    {
151        std::os::unix::fs::symlink(store_unpacked_path, &link_path).map_err(|e| e.to_string())?;
152    }
153    #[cfg(windows)]
154    {
155        std::os::windows::fs::symlink_dir(store_unpacked_path, &link_path).map_err(|e| e.to_string())?;
156    }
157    Ok(())
158}
159
160/// Index path: cache_dir/store_index.json (pkg@version -> hash)
161fn store_index_path() -> PathBuf {
162    cache_dir_path().join("store_index.json")
163}
164
165pub fn read_store_index() -> HashMap<String, String> {
166    let path = store_index_path();
167    if !path.exists() {
168        return HashMap::new();
169    }
170    let s = match fs::read_to_string(&path) {
171        Ok(x) => x,
172        Err(_) => return HashMap::new(),
173    };
174    serde_json::from_str(&s).unwrap_or_default()
175}
176
177pub fn write_store_index(map: &HashMap<String, String>) -> Result<()> {
178    let path = store_index_path();
179    let s = serde_json::to_string_pretty(map).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
180    fs::write(path, s)?;
181    Ok(())
182}
183
184pub fn manifest_signature(manifest_json: &str) -> Result<String> {
185    let key = env::var("JHOL_CACHE_SIGNING_KEY").map_err(|_| {
186        std::io::Error::new(std::io::ErrorKind::Other, "JHOL_CACHE_SIGNING_KEY not set")
187    })?;
188    let mut hasher = Sha256::new();
189    hasher.update(key.as_bytes());
190    hasher.update(manifest_json.as_bytes());
191    Ok(format!("{:x}", hasher.finalize()))
192}
193
194pub fn verify_manifest_signature(manifest_json: &str, signature: &str) -> Result<bool> {
195    let expected = manifest_signature(manifest_json)?;
196    Ok(expected == signature.trim())
197}
198
199/// Verify file against SRI string (e.g. "sha512-<base64>"). Returns true if match.
200pub fn verify_sri(path: &Path, sri: &str) -> bool {
201    let sri = sri.trim();
202    let Some((algo, rest)) = sri.split_once('-') else { return false };
203    let digest_b64 = rest.split_once('?').map(|(d, _)| d).unwrap_or(rest);
204    use base64::Engine;
205    let expected = match base64::engine::general_purpose::STANDARD.decode(digest_b64.as_bytes()) {
206        Ok(b) => b,
207        Err(_) => return false,
208    };
209    let mut f = match File::open(path) {
210        Ok(x) => x,
211        Err(_) => return false,
212    };
213    let mut buf = [0u8; 8192];
214    match algo.to_lowercase().as_str() {
215        "sha512" => {
216            use sha2::{Digest, Sha512};
217            let mut hasher = Sha512::new();
218            loop {
219                let n = match f.read(&mut buf) {
220                    Ok(0) => break,
221                    Ok(n) => n,
222                    Err(_) => return false,
223                };
224                hasher.update(&buf[..n]);
225            }
226            let got = hasher.finalize();
227            got.as_slice() == expected.as_slice()
228        }
229        "sha384" => {
230            use sha2::{Digest, Sha384};
231            let mut hasher = Sha384::new();
232            loop {
233                let n = match f.read(&mut buf) {
234                    Ok(0) => break,
235                    Ok(n) => n,
236                    Err(_) => return false,
237                };
238                hasher.update(&buf[..n]);
239            }
240            let got = hasher.finalize();
241            got.as_slice() == expected.as_slice()
242        }
243        _ => false,
244    }
245}
246
247/// SHA256 hash of file (hex)
248pub fn content_hash(path: &Path) -> Result<String> {
249    let mut f = File::open(path)?;
250    let mut hasher = Sha256::new();
251    let mut buf = [0u8; 8192];
252    loop {
253        let n = f.read(&mut buf)?;
254        if n == 0 {
255            break;
256        }
257        hasher.update(&buf[..n]);
258    }
259    Ok(format!("{:x}", hasher.finalize()))
260}
261
262/// SHA256 hash of lockfile content for CI cache key. Prefers bun.lock then package-lock.json.
263pub fn lockfile_content_hash(dir: &Path) -> Option<String> {
264    let bun = dir.join("bun.lock");
265    let npm = dir.join("package-lock.json");
266    let path = if bun.exists() {
267        bun
268    } else if npm.exists() {
269        npm
270    } else {
271        return None;
272    };
273    content_hash(&path).ok()
274}
275
276/// Returns the path to a cached tarball if present.
277/// 1) Content-addressable store (store_index.json + store/<hash>.tgz)
278/// 2) Legacy: pkg-version.tgz in cache root
279pub fn get_cached_tarball(package: &str) -> Option<PathBuf> {
280    let cache_dir = cache_dir_path();
281    if !cache_dir.exists() {
282        return None;
283    }
284    let base_name = package.split('@').next().unwrap_or(package);
285    let versioned_key = format_cache_name(package);
286
287    // 1) Content-addressable store
288    let index = read_store_index();
289    let key = if package.contains('@') {
290        package.to_string()
291    } else {
292        // Try to find any version for this package in index
293        for (k, hash) in &index {
294            if k.starts_with(&format!("{}@", base_name)) {
295                let store_file = store_dir().join(format!("{}.tgz", hash));
296                if store_file.exists() {
297                    return Some(store_file);
298                }
299            }
300        }
301        String::new()
302    };
303    if !key.is_empty() {
304        if let Some(hash) = index.get(&key) {
305            let store_file = store_dir().join(format!("{}.tgz", hash));
306            if store_file.exists() {
307                return Some(store_file);
308            }
309        }
310    }
311
312    // 2) Legacy: exact version in cache root
313    let exact = cache_dir.join(format!("{}.tgz", versioned_key));
314    if exact.exists() {
315        return Some(exact);
316    }
317
318    // 3) Legacy: no version - any pkg-*.tgz
319    if !package.contains('@') {
320        if let Ok(entries) = fs::read_dir(&cache_dir) {
321            for e in entries.flatten() {
322                let name = e.file_name().to_string_lossy().into_owned();
323                if name.starts_with(&format!("{}-", base_name)) && name.ends_with(".tgz") && !name.contains("store") {
324                    return Some(e.path());
325                }
326            }
327        }
328    }
329
330    None
331}
332
333#[allow(dead_code)]
334pub fn is_package_cached(package: &str) -> bool {
335    get_cached_tarball(package).is_some()
336}
337
338/// Store a package in the cache (content-addressable + legacy path).
339/// Prefer `registry::fill_store_from_registry` (no npm subprocess). Kept for legacy callers.
340#[deprecated(
341    since = "0.1.0",
342    note = "Use registry::fill_store_from_registry to populate store without npm pack"
343)]
344pub fn cache_package_tarball(base_name: &str, version: &str) -> Result<PathBuf> {
345    let cache_dir = cache_dir_path();
346    fs::create_dir_all(&cache_dir)?;
347    fs::create_dir_all(store_dir())?;
348
349    let output = run_command_timeout(
350        "npm",
351        &["pack", &format!("{}@{}", base_name, version), "--silent"],
352        NPM_SHOW_TIMEOUT_SECS,
353    )?;
354
355    if !output.status.success() {
356        let stderr = String::from_utf8_lossy(&output.stderr);
357        return Err(std::io::Error::new(
358            std::io::ErrorKind::Other,
359            format!("npm pack failed: {}", stderr),
360        ));
361    }
362
363    let tgz_name = format!("{}-{}.tgz", base_name, version);
364    let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
365    let from = cwd.join(&tgz_name);
366
367    if !from.exists() {
368        return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "npm pack did not create tarball"));
369    }
370
371    // Content-addressable: hash and store in store/<hash>.tgz
372    let hash = content_hash(&from)?;
373    let store_file = store_dir().join(format!("{}.tgz", hash));
374    fs::copy(&from, &store_file).map(|_| ()).or_else(|_| fs::rename(&from, &store_file))?;
375    let _ = fs::remove_file(&from);
376
377    let pkg_key = format!("{}@{}", base_name, version);
378    let mut index = read_store_index();
379    index.insert(pkg_key, hash.clone());
380    write_store_index(&index)?;
381
382    // Legacy path for backward compatibility
383    let key = format!("{}-{}", base_name, version.replace('@', "-"));
384    let dest = cache_dir.join(format!("{}.tgz", key));
385    let _ = fs::hard_link(&store_file, &dest).or_else(|_| fs::copy(&store_file, &dest).map(|_| ()));
386
387    log(&format!("Cached package: {}@{}", base_name, version));
388    Ok(store_file)
389}
390
391/// List all cached package tarballs (pkg@version or legacy base names)
392pub fn list_cached_packages() -> Result<Vec<String>> {
393    let mut names: Vec<String> = read_store_index().into_keys().collect();
394    let cache_dir = cache_dir_path();
395    if cache_dir.exists() {
396        for e in fs::read_dir(&cache_dir)? {
397            let e = e?;
398            let name = e.file_name().to_string_lossy().into_owned();
399            if name.ends_with(".tgz") && name != "store_index.json" {
400                let base = name.trim_end_matches(".tgz");
401                if !names.contains(&base.to_string()) && !base.contains("/") {
402                    names.push(base.to_string());
403                }
404            }
405        }
406    }
407    names.sort();
408    Ok(names)
409}
410
411/// Remove all cached tarballs (store + legacy). Keeps logs.
412pub fn cache_clean() -> Result<usize> {
413    let cache_dir = cache_dir_path();
414    if !cache_dir.exists() {
415        return Ok(0);
416    }
417    let mut removed = 0;
418    for e in fs::read_dir(&cache_dir)? {
419        let e = e?;
420        let name = e.file_name().to_string_lossy().into_owned();
421        if name.ends_with(".tgz") {
422            if fs::remove_file(e.path()).is_ok() {
423                removed += 1;
424            }
425        }
426    }
427    let store = store_dir();
428    if store.exists() {
429        for e in fs::read_dir(&store)? {
430            let e = e?;
431            if e.path().extension().map(|x| x == "tgz").unwrap_or(false) && fs::remove_file(e.path()).is_ok() {
432                removed += 1;
433            }
434        }
435    }
436    let _ = fs::remove_file(store_index_path());
437    Ok(removed)
438}
439
440/// Return (total size in bytes, count of tarballs) for the cache (store + legacy .tgz in root).
441pub fn cache_size_bytes() -> Result<(u64, usize)> {
442    let cache_dir = cache_dir_path();
443    let mut total: u64 = 0;
444    let mut count = 0;
445    if cache_dir.exists() {
446        for e in fs::read_dir(&cache_dir)? {
447            let e = e?;
448            let path = e.path();
449            if path.extension().map(|x| x == "tgz").unwrap_or(false) {
450                if let Ok(m) = fs::metadata(&path) {
451                    total += m.len();
452                    count += 1;
453                }
454            }
455        }
456    }
457    let store = store_dir();
458    if store.exists() {
459        for e in fs::read_dir(&store)? {
460            let e = e?;
461            let path = e.path();
462            if path.extension().map(|x| x == "tgz").unwrap_or(false) {
463                if let Ok(m) = fs::metadata(&path) {
464                    total += m.len();
465                    count += 1;
466                }
467            }
468        }
469    }
470    Ok((total, count))
471}
472
473/// Prune cache: remove store tarballs not referenced by index. If keep_recent is Some(N), keep only N most recently used (by mtime).
474pub fn cache_prune(keep_recent: Option<usize>) -> Result<usize> {
475    let mut index = read_store_index();
476    let store = store_dir();
477    if !store.exists() {
478        return Ok(0);
479    }
480    let mut removed = 0;
481    let mut entries: Vec<(PathBuf, std::time::SystemTime, String)> = Vec::new();
482    for e in fs::read_dir(&store)? {
483        let e = e?;
484        let path = e.path();
485        if path.extension().map(|x| x == "tgz").unwrap_or(false) {
486            let hash = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
487            let in_index = index.values().any(|v| v == &hash);
488            if !in_index {
489                if fs::remove_file(&path).is_ok() {
490                    removed += 1;
491                }
492            } else if let Ok(meta) = fs::metadata(&path) {
493                if let Ok(mtime) = meta.modified() {
494                    entries.push((path, mtime, hash));
495                }
496            }
497        }
498    }
499    if let Some(n) = keep_recent {
500        if entries.len() > n {
501            entries.sort_by(|a, b| b.1.cmp(&a.1));
502            let to_remove = entries.split_off(n);
503            let keep_hashes: std::collections::HashSet<String> = entries.into_iter().map(|(_, _, h)| h).collect();
504            index.retain(|_, hash| keep_hashes.contains(hash));
505            write_store_index(&index)?;
506            for (path, _, _) in to_remove {
507                if fs::remove_file(&path).is_ok() {
508                    removed += 1;
509                }
510            }
511        }
512    }
513    Ok(removed)
514}
515
516/// Export cache entries needed for current project (package.json + lockfile) into dir. Writes manifest.json for import. Returns number of files copied.
517pub fn cache_export(dir: &Path) -> Result<usize> {
518    let pj = Path::new("package.json");
519    if !pj.exists() {
520        return Err(std::io::Error::new(
521            std::io::ErrorKind::NotFound,
522            "No package.json in current directory",
523        ));
524    }
525    let deps = crate::lockfile::read_package_json_deps(pj).ok_or_else(|| {
526        std::io::Error::new(std::io::ErrorKind::InvalidData, "Could not read package.json deps")
527    })?;
528    if deps.is_empty() {
529        return Ok(0);
530    }
531    let specs = crate::lockfile::read_all_resolved_specs_from_dir(Path::new("."))
532        .unwrap_or_else(|| crate::lockfile::resolve_deps_for_install(&deps, None));
533    fs::create_dir_all(dir)?;
534    let mut count = 0;
535    let mut manifest: Vec<(String, String)> = Vec::new();
536    for spec in specs {
537        if let Some(path) = get_cached_tarball(&spec) {
538            let name = format!("{}.tgz", format_cache_name(&spec));
539            let dest = dir.join(&name);
540            fs::copy(&path, &dest)?;
541            manifest.push((spec, name));
542            count += 1;
543        }
544    }
545    let manifest_json: Vec<serde_json::Value> = manifest
546        .iter()
547        .map(|(spec, file)| {
548            serde_json::json!({ "spec": spec, "file": file })
549        })
550        .collect();
551    let manifest_path = dir.join(CACHE_MANIFEST_NAME);
552    let s = serde_json::to_string_pretty(&manifest_json)
553        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
554    fs::write(manifest_path, s)?;
555    if let Ok(sig) = manifest_signature(&serde_json::to_string(&manifest_json).unwrap_or_default()) {
556        let sig_path = dir.join(CACHE_MANIFEST_SIG);
557        fs::write(sig_path, sig)?;
558    }
559    Ok(count)
560}
561
562/// Import tarballs from dir into cache. Expects manifest.json (from cache export) or .tgz files named pkg-version.tgz.
563/// Returns number of packages imported.
564pub fn cache_import(dir: &Path) -> Result<usize> {
565    if !dir.exists() {
566        return Err(std::io::Error::new(
567            std::io::ErrorKind::NotFound,
568            "Export directory does not exist",
569        ));
570    }
571    fs::create_dir_all(store_dir())?;
572    let mut index = read_store_index();
573    let mut count = 0;
574    let manifest_path = dir.join(CACHE_MANIFEST_NAME);
575    if manifest_path.exists() {
576        let s = fs::read_to_string(&manifest_path)?;
577        if let Ok(sig) = fs::read_to_string(dir.join(CACHE_MANIFEST_SIG)) {
578            if !verify_manifest_signature(&s, &sig).unwrap_or(false) {
579                return Err(std::io::Error::new(
580                    std::io::ErrorKind::Other,
581                    "Manifest signature verification failed",
582                ));
583            }
584        } else if env::var("JHOL_CACHE_SIGNING_KEY").is_ok() {
585            return Err(std::io::Error::new(
586                std::io::ErrorKind::Other,
587                "Manifest signature missing",
588            ));
589        }
590        let entries: Vec<serde_json::Value> = serde_json::from_str(&s).unwrap_or_default();
591        for entry in entries {
592            let spec = entry.get("spec").and_then(|v| v.as_str()).unwrap_or("");
593            let file = entry.get("file").and_then(|v| v.as_str()).unwrap_or("");
594            if spec.is_empty() || file.is_empty() {
595                continue;
596            }
597            let path = dir.join(file);
598            if !path.exists() {
599                continue;
600            }
601            let hash = content_hash(&path)?;
602            let store_file = store_dir().join(format!("{}.tgz", hash));
603            if !store_file.exists() {
604                fs::copy(&path, &store_file)?;
605            }
606            // Verify hash matches content.
607            let content_hash = content_hash(&store_file)?;
608            if content_hash != hash {
609                return Err(std::io::Error::new(
610                    std::io::ErrorKind::Other,
611                    format!("Cache import hash mismatch for {}", spec),
612                ));
613            }
614            index.insert(spec.to_string(), hash);
615            count += 1;
616        }
617    } else {
618        for e in fs::read_dir(dir)? {
619            let e = e?;
620            let path = e.path();
621            if path.extension().map(|x| x == "tgz").unwrap_or(false) {
622                let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
623                if stem.is_empty() {
624                    continue;
625                }
626                let hash = content_hash(&path)?;
627                let store_file = store_dir().join(format!("{}.tgz", hash));
628                if !store_file.exists() {
629                    fs::copy(&path, &store_file)?;
630                }
631                let pkg_key = stem_to_spec(stem);
632                index.insert(pkg_key, hash);
633                count += 1;
634            }
635        }
636    }
637    write_store_index(&index)?;
638    Ok(count)
639}
640
641/// Run a command with a timeout. On timeout the process is killed and an error is returned.
642pub fn run_command_timeout(program: &str, args: &[&str], timeout_secs: u64) -> Result<Output> {
643    let child = Command::new(program)
644        .args(args)
645        .stdout(Stdio::piped())
646        .stderr(Stdio::piped())
647        .spawn()?;
648
649    let pid = child.id();
650    let kill_handle = thread::spawn(move || {
651        thread::sleep(Duration::from_secs(timeout_secs));
652        #[cfg(unix)]
653        {
654            let _ = Command::new("kill").arg("-9").arg(pid.to_string()).output();
655        }
656        #[cfg(windows)]
657        {
658            let _ = Command::new("taskkill").args(["/F", "/PID", &pid.to_string()]).output();
659        }
660    });
661
662    let out = child.wait_with_output();
663    let _ = kill_handle.join();
664    out
665}
666
667/// Run npm install with timeout
668pub fn npm_install_timeout(args: &[&str], timeout_secs: u64) -> Result<Output> {
669    let mut a = vec!["install"];
670    a.extend(args);
671    run_command_timeout("npm", &a, timeout_secs)
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn test_format_cache_name() {
680        assert_eq!(format_cache_name("lodash"), "lodash");
681        assert_eq!(format_cache_name("lodash@4.17.21"), "lodash-4.17.21");
682        assert_eq!(format_cache_name("@scope/pkg@1.0.0"), "-scope/pkg-1.0.0");
683    }
684
685    #[test]
686    fn test_get_cache_dir_non_empty() {
687        let dir = get_cache_dir();
688        assert!(!dir.is_empty());
689        assert!(dir.contains("jhol-cache") || dir.contains(".jhol-cache"));
690    }
691
692    #[test]
693    fn test_is_package_cached_no_dir() {
694        // With a non-existent path in cache dir, should be false
695        assert!(!is_package_cached("nonexistent-package-xyz-123"));
696    }
697}