1use std::path::{Path, PathBuf};
9
10use rustc_hash::FxHashMap;
11use serde::Deserialize;
12
13use super::package_json::PackageJson;
14
15const DENO_JSON_NAMES: &[&str] = &["deno.json", "deno.jsonc"];
17
18#[derive(Debug, Clone, Default, Deserialize)]
20struct DenoJson {
21 #[serde(default)]
22 name: Option<String>,
23 #[serde(default)]
24 exports: Option<serde_json::Value>,
25 #[serde(default)]
27 workspace: Option<DenoWorkspace>,
28 #[serde(default)]
30 imports: Option<FxHashMap<String, String>>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
35#[serde(untagged)]
36enum DenoWorkspace {
37 Members(Vec<String>),
38 Detailed {
39 #[serde(default)]
40 members: Vec<String>,
41 },
42}
43
44impl DenoJson {
45 fn load(path: &Path) -> Result<Self, String> {
51 let content = std::fs::read_to_string(path)
52 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
53 let content = content.trim_start_matches('\u{FEFF}');
54 crate::jsonc::parse_to_value(content)
55 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))
56 }
57
58 fn load_from_dir(dir: &Path) -> Result<Option<(PathBuf, Self)>, (PathBuf, String)> {
60 for name in DENO_JSON_NAMES {
61 let path = dir.join(name);
62 if path.is_file() {
63 let deno = Self::load(&path).map_err(|error| (path.clone(), error))?;
64 return Ok(Some((path, deno)));
65 }
66 }
67 Ok(None)
68 }
69
70 #[must_use]
72 fn workspace_patterns(&self) -> Vec<String> {
73 match &self.workspace {
74 Some(DenoWorkspace::Members(members)) | Some(DenoWorkspace::Detailed { members }) => {
75 members.clone()
76 }
77 None => Vec::new(),
78 }
79 }
80
81 #[must_use]
83 fn import_map_entries(&self) -> Vec<(String, String)> {
84 let Some(imports) = &self.imports else {
85 return Vec::new();
86 };
87 let mut entries: Vec<(String, String)> = imports
88 .iter()
89 .map(|(k, v)| (k.clone(), v.clone()))
90 .collect();
91 entries.sort_by(|a, b| a.0.cmp(&b.0));
92 entries
93 }
94
95 #[must_use]
98 fn to_package_json(&self) -> PackageJson {
99 PackageJson {
100 name: self.name.clone(),
101 exports: self.exports.clone(),
102 ..PackageJson::default()
103 }
104 }
105}
106
107#[must_use]
109pub fn dir_has_deno_json(dir: &Path) -> bool {
110 DENO_JSON_NAMES.iter().any(|name| dir.join(name).is_file())
111}
112
113#[must_use]
115pub fn dir_has_package_manifest(dir: &Path) -> bool {
116 dir.join("package.json").is_file() || dir_has_deno_json(dir)
117}
118
119pub fn load_member_package_manifest(
131 dir: &Path,
132) -> Result<Option<(String, PackageJson, Vec<String>)>, String> {
133 manifest_from_probe(dir, DenoJson::load_from_dir(dir))
134}
135
136fn manifest_from_probe(
140 dir: &Path,
141 deno: Result<Option<(PathBuf, DenoJson)>, (PathBuf, String)>,
142) -> Result<Option<(String, PackageJson, Vec<String>)>, String> {
143 let pkg_path = dir.join("package.json");
144 if pkg_path.is_file() {
145 let pkg = PackageJson::load(&pkg_path)?;
146 deno.map_err(|(_path, error)| error)?;
147 let deps = pkg.all_dependency_names();
148 let name = pkg.name.clone().unwrap_or_else(|| dir_name_fallback(dir));
149 return Ok(Some((name, pkg, deps)));
150 }
151
152 match deno.map_err(|(_path, error)| error)? {
153 Some((_path, deno)) => {
154 let pkg = deno.to_package_json();
155 let name = pkg.name.clone().unwrap_or_else(|| dir_name_fallback(dir));
156 Ok(Some((name, pkg, Vec::new())))
157 }
158 None => Ok(None),
159 }
160}
161
162#[derive(Debug)]
171pub struct DirManifestProbe {
172 pub manifest: Result<Option<(String, PackageJson, Vec<String>)>, String>,
174 pub deno_import_map: Option<(PathBuf, Vec<(String, String)>)>,
176}
177
178#[must_use]
181pub fn probe_dir_manifest(dir: &Path) -> DirManifestProbe {
182 let deno = DenoJson::load_from_dir(dir);
183 let deno_import_map = match &deno {
184 Ok(Some((path, config))) => Some((path.clone(), config.import_map_entries())),
185 _ => None,
186 };
187 let manifest = manifest_from_probe(dir, deno);
188 DirManifestProbe {
189 manifest,
190 deno_import_map,
191 }
192}
193
194#[must_use]
197pub fn load_dir_package_json(dir: &Path) -> Option<PackageJson> {
198 load_member_package_manifest(dir)
199 .ok()
200 .flatten()
201 .map(|(_name, pkg, _deps)| pkg)
202}
203
204#[expect(
211 clippy::type_complexity,
212 reason = "tuple projection keeps the full Deno config model private"
213)]
214pub fn load_deno_import_map(
215 dir: &Path,
216) -> Result<Option<(PathBuf, Vec<(String, String)>)>, (PathBuf, String)> {
217 Ok(DenoJson::load_from_dir(dir)?.map(|(path, deno)| (path, deno.import_map_entries())))
218}
219
220#[expect(
227 clippy::type_complexity,
228 reason = "tuple projection keeps the full Deno config model private"
229)]
230pub fn load_root_deno_workspace_patterns(
231 root: &Path,
232) -> Result<Option<(PathBuf, Vec<String>)>, (PathBuf, String)> {
233 Ok(DenoJson::load_from_dir(root)?.map(|(path, deno)| (path, deno.workspace_patterns())))
234}
235
236#[must_use]
239pub fn is_deno_without_node_modules(root: &Path) -> bool {
240 dir_has_deno_json(root) && !root.join("package.json").is_file()
241}
242
243fn dir_name_fallback(dir: &Path) -> String {
244 dir.file_name().map_or_else(
245 || dir.to_string_lossy().into_owned(),
246 |name| name.to_string_lossy().into_owned(),
247 )
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn parses_workspace_and_imports() {
256 let dir = tempfile::tempdir().unwrap();
257 std::fs::write(
258 dir.path().join("deno.json"),
259 r#"{
260 "workspace": ["./apps/*", "./packages/*"],
261 "imports": {
262 "@std/assert": "jsr:@std/assert@1",
263 "@std/": "jsr:@std/"
264 }
265 }"#,
266 )
267 .unwrap();
268
269 let (path, deno) = DenoJson::load_from_dir(dir.path()).unwrap().unwrap();
270 assert!(path.ends_with("deno.json"));
271 assert_eq!(
272 deno.workspace_patterns(),
273 vec!["./apps/*".to_string(), "./packages/*".to_string()]
274 );
275 let map = deno.import_map_entries();
276 assert!(
277 map.iter()
278 .any(|(k, v)| k == "@std/assert" && v == "jsr:@std/assert@1")
279 );
280 }
281
282 #[test]
283 fn parses_workspace_object_members_form() {
284 let dir = tempfile::tempdir().unwrap();
285 std::fs::write(
286 dir.path().join("deno.json"),
287 r#"{"workspace":{"members":["./packages/*"]}}"#,
288 )
289 .unwrap();
290
291 let deno = DenoJson::load_from_dir(dir.path()).unwrap().unwrap().1;
292 assert_eq!(deno.workspace_patterns(), vec!["./packages/*".to_string()]);
293 }
294
295 #[test]
296 fn rejects_invalid_workspace_value() {
297 let dir = tempfile::tempdir().unwrap();
298 std::fs::write(dir.path().join("deno.json"), r#"{"workspace":5}"#).unwrap();
299
300 assert!(DenoJson::load_from_dir(dir.path()).is_err());
301 }
302
303 #[test]
304 fn parses_member_name_and_exports() {
305 let dir = tempfile::tempdir().unwrap();
306 std::fs::write(
307 dir.path().join("deno.json"),
308 r#"{
309 "name": "@fallow/core",
310 "exports": {
311 ".": "./mod.ts",
312 "./result": "./result.ts"
313 }
314 }"#,
315 )
316 .unwrap();
317
318 let (_name, pkg, deps) = load_member_package_manifest(dir.path()).unwrap().unwrap();
319 assert_eq!(pkg.name.as_deref(), Some("@fallow/core"));
320 assert!(pkg.exports.is_some());
321 assert!(deps.is_empty());
322 }
323
324 #[test]
325 fn prefers_package_json_over_deno_json() {
326 let dir = tempfile::tempdir().unwrap();
327 std::fs::write(dir.path().join("package.json"), r#"{"name":"from-npm"}"#).unwrap();
328 std::fs::write(dir.path().join("deno.json"), r#"{"name":"from-deno"}"#).unwrap();
329
330 let (name, _, _) = load_member_package_manifest(dir.path()).unwrap().unwrap();
331 assert_eq!(name, "from-npm");
332 }
333
334 #[test]
335 fn import_map_loader_preserves_path_and_sort_order() {
336 let dir = tempfile::tempdir().unwrap();
337 std::fs::write(
338 dir.path().join("deno.json"),
339 r#"{"imports":{"z":"./z.ts","a":"./a.ts"}}"#,
340 )
341 .unwrap();
342
343 let (path, entries) = load_deno_import_map(dir.path()).unwrap().unwrap();
344 assert!(path.ends_with("deno.json"));
345 assert_eq!(
346 entries,
347 vec![
348 ("a".to_string(), "./a.ts".to_string()),
349 ("z".to_string(), "./z.ts".to_string())
350 ]
351 );
352 }
353
354 #[test]
355 fn deno_without_node_modules_requires_no_package_json() {
356 let dir = tempfile::tempdir().unwrap();
357 std::fs::write(dir.path().join("deno.json"), "{}").unwrap();
358 assert!(is_deno_without_node_modules(dir.path()));
359
360 std::fs::write(dir.path().join("package.json"), "{}").unwrap();
361 assert!(!is_deno_without_node_modules(dir.path()));
362 }
363
364 #[test]
365 fn probe_keeps_import_map_when_package_json_is_malformed() {
366 let dir = tempfile::tempdir().unwrap();
367 std::fs::write(dir.path().join("package.json"), r"{,}").unwrap();
368 std::fs::write(
369 dir.path().join("deno.json"),
370 r#"{"imports":{"@std/assert":"jsr:@std/assert@1"}}"#,
371 )
372 .unwrap();
373
374 let probe = probe_dir_manifest(dir.path());
375 assert!(
376 probe.manifest.is_err(),
377 "malformed package.json must surface as a manifest error"
378 );
379 let (path, entries) = probe
380 .deno_import_map
381 .expect("valid colocated import map survives a broken package.json");
382 assert!(path.ends_with("deno.json"));
383 assert_eq!(
384 entries,
385 vec![("@std/assert".to_string(), "jsr:@std/assert@1".to_string())]
386 );
387 }
388
389 #[test]
390 fn probe_drops_import_map_when_deno_config_is_malformed() {
391 let dir = tempfile::tempdir().unwrap();
392 std::fs::write(dir.path().join("deno.jsonc"), "{ imports: [ }").unwrap();
393
394 let probe = probe_dir_manifest(dir.path());
395 let error = probe
396 .manifest
397 .expect_err("malformed deno.jsonc must surface as a manifest error");
398 assert!(
399 error.contains("deno.jsonc"),
400 "error should name the failing config: {error}"
401 );
402 assert!(probe.deno_import_map.is_none());
403 }
404
405 #[test]
406 fn probe_returns_manifest_and_import_map_when_both_parse() {
407 let dir = tempfile::tempdir().unwrap();
408 std::fs::write(dir.path().join("package.json"), r#"{"name":"from-npm"}"#).unwrap();
409 std::fs::write(
410 dir.path().join("deno.json"),
411 r#"{"imports":{"a":"./a.ts"}}"#,
412 )
413 .unwrap();
414
415 let probe = probe_dir_manifest(dir.path());
416 let (name, _pkg, _deps) = probe
417 .manifest
418 .expect("both configs parse")
419 .expect("manifest present");
420 assert_eq!(
421 name, "from-npm",
422 "package.json identity wins over deno.json"
423 );
424 let (_path, entries) = probe.deno_import_map.expect("import map present");
425 assert_eq!(entries, vec![("a".to_string(), "./a.ts".to_string())]);
426 }
427
428 #[test]
429 fn accepts_jsonc_comments() {
430 let dir = tempfile::tempdir().unwrap();
431 std::fs::write(
432 dir.path().join("deno.jsonc"),
433 r#"{
434 // root workspace
435 "workspace": ["./packages/*"],
436 }"#,
437 )
438 .unwrap();
439 let deno = DenoJson::load_from_dir(dir.path()).unwrap().unwrap().1;
440 assert_eq!(deno.workspace_patterns(), vec!["./packages/*".to_string()]);
441 }
442}