use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use super::types::{CrawledPackage, CrawlerOptions};
pub struct DenoCrawler;
impl DenoCrawler {
pub fn new() -> Self {
Self
}
pub async fn get_jsr_cache_paths(
&self,
options: &CrawlerOptions,
) -> Result<Vec<PathBuf>, std::io::Error> {
if options.global || options.global_prefix.is_some() {
if let Some(ref custom) = options.global_prefix {
return Ok(vec![custom.clone()]);
}
let cache = deno_dir().join("npm").join("jsr.io");
if is_dir(&cache).await {
return Ok(vec![cache]);
}
return Ok(Vec::new());
}
if !is_deno_project(&options.cwd).await {
return Ok(Vec::new());
}
let cache = deno_dir().join("npm").join("jsr.io");
if is_dir(&cache).await {
Ok(vec![cache])
} else {
Ok(Vec::new())
}
}
pub async fn crawl_all(&self, options: &CrawlerOptions) -> Vec<CrawledPackage> {
let mut packages = Vec::new();
let mut seen = HashSet::new();
let cache_paths = self.get_jsr_cache_paths(options).await.unwrap_or_default();
for cache_path in &cache_paths {
scan_jsr_cache(cache_path, &mut seen, &mut packages).await;
}
packages
}
pub async fn find_by_purls(
&self,
jsr_cache_path: &Path,
purls: &[String],
) -> Result<HashMap<String, CrawledPackage>, std::io::Error> {
let mut result: HashMap<String, CrawledPackage> = HashMap::new();
for purl in purls {
let Some(((scope, name), version)) = crate::utils::purl::parse_jsr_purl(purl) else {
continue;
};
let pkg_dir = jsr_cache_path.join(scope).join(name).join(version);
if !is_dir(&pkg_dir).await {
continue;
}
result.insert(
purl.clone(),
CrawledPackage {
name: name.to_string(),
version: version.to_string(),
namespace: Some(scope.to_string()),
purl: purl.clone(),
path: pkg_dir,
},
);
}
Ok(result)
}
}
impl Default for DenoCrawler {
fn default() -> Self {
Self::new()
}
}
async fn scan_jsr_cache(root: &Path, seen: &mut HashSet<String>, out: &mut Vec<CrawledPackage>) {
for scope_entry in crate::utils::fs::list_dir_entries(root).await {
if !crate::utils::fs::entry_is_dir(&scope_entry).await {
continue;
}
let scope_name = scope_entry.file_name();
let scope_str = scope_name.to_string_lossy().to_string();
if !scope_str.starts_with('@') {
continue;
}
let scope_path = root.join(&scope_str);
for name_entry in crate::utils::fs::list_dir_entries(&scope_path).await {
if !crate::utils::fs::entry_is_dir(&name_entry).await {
continue;
}
let name_str = name_entry.file_name().to_string_lossy().to_string();
let name_path = scope_path.join(&name_str);
for ver_entry in crate::utils::fs::list_dir_entries(&name_path).await {
if !crate::utils::fs::entry_is_dir(&ver_entry).await {
continue;
}
let ver_str = ver_entry.file_name().to_string_lossy().to_string();
let pkg_path = name_path.join(&ver_str);
let purl = crate::utils::purl::build_jsr_purl(&scope_str, &name_str, &ver_str);
if seen.insert(purl.clone()) {
out.push(CrawledPackage {
name: name_str.clone(),
version: ver_str,
namespace: Some(scope_str.clone()),
purl,
path: pkg_path,
});
}
}
}
}
}
async fn is_deno_project(cwd: &Path) -> bool {
let markers = ["deno.json", "deno.jsonc", "deno.lock"];
for m in &markers {
if tokio::fs::metadata(cwd.join(m)).await.is_ok() {
return true;
}
}
false
}
fn deno_dir() -> PathBuf {
if let Ok(d) = std::env::var("DENO_DIR") {
if !d.is_empty() {
return PathBuf::from(d);
}
}
default_cache_root().join("deno")
}
#[cfg(target_os = "macos")]
fn default_cache_root() -> PathBuf {
home_dir().join("Library").join("Caches")
}
#[cfg(windows)]
fn default_cache_root() -> PathBuf {
if let Ok(local) = std::env::var("LOCALAPPDATA") {
if !local.is_empty() {
return PathBuf::from(local);
}
}
home_dir().join(".cache")
}
#[cfg(all(not(target_os = "macos"), not(windows)))]
fn default_cache_root() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg);
}
}
home_dir().join(".cache")
}
fn home_dir() -> PathBuf {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "~".to_string());
PathBuf::from(home)
}
async fn is_dir(path: &Path) -> bool {
tokio::fs::metadata(path)
.await
.map(|m| m.is_dir())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn is_deno_project_detects_deno_json() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("deno.json"), b"{}")
.await
.unwrap();
assert!(is_deno_project(tmp.path()).await);
}
#[tokio::test]
async fn is_deno_project_detects_deno_jsonc() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("deno.jsonc"), b"{}")
.await
.unwrap();
assert!(is_deno_project(tmp.path()).await);
}
#[tokio::test]
async fn is_deno_project_detects_deno_lock() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("deno.lock"), b"{}")
.await
.unwrap();
assert!(is_deno_project(tmp.path()).await);
}
#[tokio::test]
async fn is_deno_project_rejects_unrelated_dir() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("package.json"), b"{}")
.await
.unwrap();
assert!(!is_deno_project(tmp.path()).await);
}
#[tokio::test]
async fn deno_crawler_default_and_new_construct_cleanly() {
let _a = DenoCrawler::default();
let _b = DenoCrawler::new();
}
#[tokio::test]
async fn crawl_all_empty_cache_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("npm").join("jsr.io");
tokio::fs::create_dir_all(&cache).await.unwrap();
let crawler = DenoCrawler;
let opts = CrawlerOptions {
cwd: tmp.path().to_path_buf(),
global: true,
global_prefix: Some(cache),
batch_size: 100,
};
assert!(crawler.crawl_all(&opts).await.is_empty());
}
async fn stage(root: &Path, scope: &str, name: &str, version: &str) {
let pkg = root.join(scope).join(name).join(version);
tokio::fs::create_dir_all(&pkg).await.unwrap();
tokio::fs::write(pkg.join("mod.ts"), b"export default 1;")
.await
.unwrap();
}
#[tokio::test]
async fn scan_emits_every_version_of_a_package() {
let tmp = tempfile::tempdir().unwrap();
stage(tmp.path(), "@std", "path", "0.220.0").await;
stage(tmp.path(), "@std", "path", "0.221.0").await;
let mut seen = HashSet::new();
let mut out = Vec::new();
scan_jsr_cache(tmp.path(), &mut seen, &mut out).await;
let mut versions: Vec<&str> = out.iter().map(|p| p.version.as_str()).collect();
versions.sort();
assert_eq!(versions, vec!["0.220.0", "0.221.0"]);
assert!(out.iter().all(|p| p.namespace.as_deref() == Some("@std")));
}
#[tokio::test]
async fn scan_dedups_across_repeated_roots() {
let tmp = tempfile::tempdir().unwrap();
stage(tmp.path(), "@std", "path", "0.220.0").await;
let mut seen = HashSet::new();
let mut out = Vec::new();
scan_jsr_cache(tmp.path(), &mut seen, &mut out).await;
scan_jsr_cache(tmp.path(), &mut seen, &mut out).await;
assert_eq!(out.len(), 1);
}
#[tokio::test]
async fn scan_skips_files_at_scope_and_version_layers() {
let tmp = tempfile::tempdir().unwrap();
stage(tmp.path(), "@std", "path", "0.220.0").await;
tokio::fs::write(tmp.path().join("@loose-file"), b"x")
.await
.unwrap();
let fs_dir = tmp.path().join("@std").join("fs");
tokio::fs::create_dir_all(&fs_dir).await.unwrap();
tokio::fs::write(fs_dir.join("readme.txt"), b"x")
.await
.unwrap();
let mut seen = HashSet::new();
let mut out = Vec::new();
scan_jsr_cache(tmp.path(), &mut seen, &mut out).await;
assert_eq!(out.len(), 1);
assert_eq!(out[0].purl, "pkg:jsr/@std/path@0.220.0");
}
#[tokio::test]
async fn find_by_purls_resolves_qualified_purl_and_keys_by_input() {
let tmp = tempfile::tempdir().unwrap();
stage(tmp.path(), "@std", "path", "0.220.0").await;
let qualified = "pkg:jsr/@std/path@0.220.0?repository_url=https://jsr.io";
let crawler = DenoCrawler;
let result = crawler
.find_by_purls(tmp.path(), &[qualified.to_string()])
.await
.unwrap();
let entry = result.get(qualified).unwrap();
assert_eq!(entry.name, "path");
assert_eq!(entry.version, "0.220.0");
assert_eq!(entry.namespace.as_deref(), Some("@std"));
}
#[tokio::test]
async fn find_by_purls_skips_absent_version_keeps_present() {
let tmp = tempfile::tempdir().unwrap();
stage(tmp.path(), "@std", "path", "0.220.0").await;
let crawler = DenoCrawler;
let result = crawler
.find_by_purls(
tmp.path(),
&[
"pkg:jsr/@std/path@0.220.0".to_string(),
"pkg:jsr/@std/path@9.9.9".to_string(),
],
)
.await
.unwrap();
assert_eq!(result.len(), 1);
assert!(result.contains_key("pkg:jsr/@std/path@0.220.0"));
}
struct EnvGuard {
key: &'static str,
prev: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let prev = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, prev }
}
fn unset(key: &'static str) -> Self {
let prev = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, prev }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prev {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
#[test]
#[serial_test::serial]
fn deno_dir_honors_explicit_env() {
let _g = EnvGuard::set("DENO_DIR", "/tmp/custom-deno");
assert_eq!(deno_dir(), PathBuf::from("/tmp/custom-deno"));
}
#[test]
#[serial_test::serial]
fn deno_dir_treats_empty_env_as_unset() {
let _g = EnvGuard::set("DENO_DIR", "");
let dir = deno_dir();
assert_ne!(dir, PathBuf::from(""));
assert!(dir.ends_with("deno"), "got {dir:?}");
}
#[cfg(target_os = "macos")]
#[test]
#[serial_test::serial]
fn deno_dir_uses_library_caches_on_macos() {
let _g = EnvGuard::unset("DENO_DIR");
let dir = deno_dir();
assert!(
dir.ends_with("Library/Caches/deno"),
"macOS default should live under Library/Caches, got {dir:?}"
);
assert!(!dir.to_string_lossy().contains("/.cache/"));
}
#[cfg(all(not(target_os = "macos"), not(windows)))]
#[test]
#[serial_test::serial]
fn deno_dir_honors_xdg_cache_home_on_linux() {
let _d = EnvGuard::unset("DENO_DIR");
let _x = EnvGuard::set("XDG_CACHE_HOME", "/tmp/xdg-cache");
assert_eq!(deno_dir(), PathBuf::from("/tmp/xdg-cache").join("deno"));
}
#[cfg(all(not(target_os = "macos"), not(windows)))]
#[test]
#[serial_test::serial]
fn deno_dir_falls_back_to_dot_cache_on_linux() {
let _d = EnvGuard::unset("DENO_DIR");
let _x = EnvGuard::unset("XDG_CACHE_HOME");
let dir = deno_dir();
assert!(dir.ends_with(".cache/deno"), "got {dir:?}");
}
}