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") {
return PathBuf::from(d);
}
#[cfg(windows)]
{
if let Ok(local) = std::env::var("LOCALAPPDATA") {
return PathBuf::from(local).join("deno");
}
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "~".to_string());
PathBuf::from(home).join(".cache").join("deno")
}
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());
}
}