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 let pkg_path = dir.join("package.json");
134 if pkg_path.is_file() {
135 let pkg = PackageJson::load(&pkg_path)?;
136 DenoJson::load_from_dir(dir).map_err(|(_path, error)| error)?;
137 let deps = pkg.all_dependency_names();
138 let name = pkg.name.clone().unwrap_or_else(|| dir_name_fallback(dir));
139 return Ok(Some((name, pkg, deps)));
140 }
141
142 match DenoJson::load_from_dir(dir).map_err(|(_path, error)| error)? {
143 Some((_path, deno)) => {
144 let pkg = deno.to_package_json();
145 let name = pkg.name.clone().unwrap_or_else(|| dir_name_fallback(dir));
146 Ok(Some((name, pkg, Vec::new())))
147 }
148 None => Ok(None),
149 }
150}
151
152#[must_use]
155pub fn load_dir_package_json(dir: &Path) -> Option<PackageJson> {
156 load_member_package_manifest(dir)
157 .ok()
158 .flatten()
159 .map(|(_name, pkg, _deps)| pkg)
160}
161
162#[expect(
169 clippy::type_complexity,
170 reason = "tuple projection keeps the full Deno config model private"
171)]
172pub fn load_deno_import_map(
173 dir: &Path,
174) -> Result<Option<(PathBuf, Vec<(String, String)>)>, (PathBuf, String)> {
175 Ok(DenoJson::load_from_dir(dir)?.map(|(path, deno)| (path, deno.import_map_entries())))
176}
177
178#[expect(
185 clippy::type_complexity,
186 reason = "tuple projection keeps the full Deno config model private"
187)]
188pub fn load_root_deno_workspace_patterns(
189 root: &Path,
190) -> Result<Option<(PathBuf, Vec<String>)>, (PathBuf, String)> {
191 Ok(DenoJson::load_from_dir(root)?.map(|(path, deno)| (path, deno.workspace_patterns())))
192}
193
194#[must_use]
197pub fn is_deno_without_node_modules(root: &Path) -> bool {
198 dir_has_deno_json(root) && !root.join("package.json").is_file()
199}
200
201fn dir_name_fallback(dir: &Path) -> String {
202 dir.file_name().map_or_else(
203 || dir.to_string_lossy().into_owned(),
204 |name| name.to_string_lossy().into_owned(),
205 )
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn parses_workspace_and_imports() {
214 let dir = tempfile::tempdir().unwrap();
215 std::fs::write(
216 dir.path().join("deno.json"),
217 r#"{
218 "workspace": ["./apps/*", "./packages/*"],
219 "imports": {
220 "@std/assert": "jsr:@std/assert@1",
221 "@std/": "jsr:@std/"
222 }
223 }"#,
224 )
225 .unwrap();
226
227 let (path, deno) = DenoJson::load_from_dir(dir.path()).unwrap().unwrap();
228 assert!(path.ends_with("deno.json"));
229 assert_eq!(
230 deno.workspace_patterns(),
231 vec!["./apps/*".to_string(), "./packages/*".to_string()]
232 );
233 let map = deno.import_map_entries();
234 assert!(
235 map.iter()
236 .any(|(k, v)| k == "@std/assert" && v == "jsr:@std/assert@1")
237 );
238 }
239
240 #[test]
241 fn parses_workspace_object_members_form() {
242 let dir = tempfile::tempdir().unwrap();
243 std::fs::write(
244 dir.path().join("deno.json"),
245 r#"{"workspace":{"members":["./packages/*"]}}"#,
246 )
247 .unwrap();
248
249 let deno = DenoJson::load_from_dir(dir.path()).unwrap().unwrap().1;
250 assert_eq!(deno.workspace_patterns(), vec!["./packages/*".to_string()]);
251 }
252
253 #[test]
254 fn rejects_invalid_workspace_value() {
255 let dir = tempfile::tempdir().unwrap();
256 std::fs::write(dir.path().join("deno.json"), r#"{"workspace":5}"#).unwrap();
257
258 assert!(DenoJson::load_from_dir(dir.path()).is_err());
259 }
260
261 #[test]
262 fn parses_member_name_and_exports() {
263 let dir = tempfile::tempdir().unwrap();
264 std::fs::write(
265 dir.path().join("deno.json"),
266 r#"{
267 "name": "@fallow/core",
268 "exports": {
269 ".": "./mod.ts",
270 "./result": "./result.ts"
271 }
272 }"#,
273 )
274 .unwrap();
275
276 let (_name, pkg, deps) = load_member_package_manifest(dir.path()).unwrap().unwrap();
277 assert_eq!(pkg.name.as_deref(), Some("@fallow/core"));
278 assert!(pkg.exports.is_some());
279 assert!(deps.is_empty());
280 }
281
282 #[test]
283 fn prefers_package_json_over_deno_json() {
284 let dir = tempfile::tempdir().unwrap();
285 std::fs::write(dir.path().join("package.json"), r#"{"name":"from-npm"}"#).unwrap();
286 std::fs::write(dir.path().join("deno.json"), r#"{"name":"from-deno"}"#).unwrap();
287
288 let (name, _, _) = load_member_package_manifest(dir.path()).unwrap().unwrap();
289 assert_eq!(name, "from-npm");
290 }
291
292 #[test]
293 fn import_map_loader_preserves_path_and_sort_order() {
294 let dir = tempfile::tempdir().unwrap();
295 std::fs::write(
296 dir.path().join("deno.json"),
297 r#"{"imports":{"z":"./z.ts","a":"./a.ts"}}"#,
298 )
299 .unwrap();
300
301 let (path, entries) = load_deno_import_map(dir.path()).unwrap().unwrap();
302 assert!(path.ends_with("deno.json"));
303 assert_eq!(
304 entries,
305 vec![
306 ("a".to_string(), "./a.ts".to_string()),
307 ("z".to_string(), "./z.ts".to_string())
308 ]
309 );
310 }
311
312 #[test]
313 fn deno_without_node_modules_requires_no_package_json() {
314 let dir = tempfile::tempdir().unwrap();
315 std::fs::write(dir.path().join("deno.json"), "{}").unwrap();
316 assert!(is_deno_without_node_modules(dir.path()));
317
318 std::fs::write(dir.path().join("package.json"), "{}").unwrap();
319 assert!(!is_deno_without_node_modules(dir.path()));
320 }
321
322 #[test]
323 fn accepts_jsonc_comments() {
324 let dir = tempfile::tempdir().unwrap();
325 std::fs::write(
326 dir.path().join("deno.jsonc"),
327 r#"{
328 // root workspace
329 "workspace": ["./packages/*"],
330 }"#,
331 )
332 .unwrap();
333 let deno = DenoJson::load_from_dir(dir.path()).unwrap().unwrap().1;
334 assert_eq!(deno.workspace_patterns(), vec!["./packages/*".to_string()]);
335 }
336}