1use crate::catalog::{resolve_catalog_deps, CatalogConfig};
2use crate::errors::{DnxError, Result};
3use crate::package_json::PackageJson;
4use std::collections::{HashMap, VecDeque};
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
9pub struct WorkspaceMember {
10 pub path: PathBuf,
12 pub package_json: PackageJson,
14 pub name: String,
16 pub version: String,
18}
19
20#[derive(Debug)]
22pub struct Workspace {
23 pub root: PathBuf,
25 pub root_package_json: PackageJson,
27 pub patterns: Vec<String>,
29 pub members: HashMap<String, WorkspaceMember>,
31 pub topo_order: Vec<String>,
33}
34
35#[derive(Debug, serde::Deserialize)]
38struct WorkspaceToml {
39 packages: Vec<String>,
40}
41
42impl Workspace {
43 pub fn discover(root: &Path) -> Result<Option<Self>> {
47 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
48
49 let pkg_path = root.join("package.json");
50 if !pkg_path.exists() {
51 return Ok(None);
52 }
53
54 let root_pkg = PackageJson::read(&pkg_path)?;
55
56 let config = crate::config::DnxConfig::load(&root);
58 let patterns = if let Some(ref ws_patterns) = config.workspace_patterns {
59 if !ws_patterns.is_empty() {
60 ws_patterns.clone()
61 } else if let Some(ref ws) = root_pkg.workspaces {
62 ws.clone()
63 } else {
64 return Ok(None);
65 }
66 } else if let Some(ref ws) = root_pkg.workspaces {
67 ws.clone()
68 } else {
69 let toml_path = root.join("dnx-workspace.toml");
71 if toml_path.exists() {
72 let content = std::fs::read_to_string(&toml_path).map_err(|e| {
73 DnxError::Workspace(format!("Failed to read {}: {}", toml_path.display(), e))
74 })?;
75 let ws_toml: WorkspaceToml = toml::from_str(&content).map_err(|e| {
76 DnxError::Workspace(format!("Failed to parse {}: {}", toml_path.display(), e))
77 })?;
78 ws_toml.packages
79 } else {
80 return Ok(None);
82 }
83 };
84
85 if patterns.is_empty() {
86 return Ok(None);
87 }
88
89 let mut members = HashMap::new();
91
92 for pattern in &patterns {
93 let abs_pattern = root.join(pattern).to_string_lossy().to_string();
95 let abs_pattern = abs_pattern.replace('\\', "/");
97
98 let entries = glob::glob(&abs_pattern).map_err(|e| {
99 DnxError::Workspace(format!("Invalid glob pattern '{}': {}", pattern, e))
100 })?;
101
102 for entry in entries {
103 let member_dir =
104 entry.map_err(|e| DnxError::Workspace(format!("Glob error: {}", e)))?;
105
106 if !member_dir.is_dir() {
107 continue;
108 }
109
110 let member_pkg_path = member_dir.join("package.json");
111 if !member_pkg_path.exists() {
112 continue;
113 }
114
115 let member_pkg = PackageJson::read(&member_pkg_path)?;
116 let name = member_pkg.name.clone().ok_or_else(|| {
117 DnxError::Workspace(format!(
118 "Workspace member at {} has no 'name' field in package.json",
119 member_dir.display()
120 ))
121 })?;
122
123 let version = member_pkg
124 .version
125 .clone()
126 .unwrap_or_else(|| "0.0.0".to_string());
127
128 if members.contains_key(&name) {
130 return Err(DnxError::Workspace(format!(
131 "Duplicate workspace member name '{}' at {}",
132 name,
133 member_dir.display()
134 )));
135 }
136
137 let abs_member_dir = member_dir
138 .canonicalize()
139 .unwrap_or_else(|_| member_dir.clone());
140
141 members.insert(
142 name.clone(),
143 WorkspaceMember {
144 path: abs_member_dir,
145 package_json: member_pkg,
146 name: name.clone(),
147 version,
148 },
149 );
150 }
151 }
152
153 let topo_order = compute_topo_order(&members)?;
155
156 Ok(Some(Workspace {
157 root,
158 root_package_json: root_pkg,
159 patterns,
160 members,
161 topo_order,
162 }))
163 }
164
165 pub fn all_external_deps(&self) -> HashMap<String, String> {
169 let mut deps = HashMap::new();
170 for member in self.members.values() {
171 let all = member.package_json.all_dependencies();
172 for (name, spec) in all {
173 if spec.starts_with("workspace:") || spec.starts_with("catalog:") {
174 continue;
175 }
176 if self.members.contains_key(&name) {
178 continue;
179 }
180 deps.entry(name).or_insert(spec);
181 }
182 }
183 let root_deps = self.root_package_json.all_dependencies();
185 for (name, spec) in root_deps {
186 if spec.starts_with("workspace:") || spec.starts_with("catalog:") {
187 continue;
188 }
189 if self.members.contains_key(&name) {
190 continue;
191 }
192 deps.entry(name).or_insert(spec);
193 }
194 deps
195 }
196
197 pub fn all_external_deps_with_catalog(
200 &self,
201 catalog: Option<&CatalogConfig>,
202 ) -> Result<HashMap<String, String>> {
203 let mut deps = HashMap::new();
204
205 for member in self.members.values() {
206 let all = member.package_json.all_dependencies();
207 let resolved = if let Some(cat) = catalog {
208 resolve_catalog_deps(&all, cat)?
209 } else {
210 all
211 };
212
213 for (name, spec) in resolved {
214 if spec.starts_with("workspace:") {
215 continue;
216 }
217 if self.members.contains_key(&name) {
218 continue;
219 }
220 deps.entry(name).or_insert(spec);
221 }
222 }
223
224 let root_deps = self.root_package_json.all_dependencies();
226 let resolved_root = if let Some(cat) = catalog {
227 resolve_catalog_deps(&root_deps, cat)?
228 } else {
229 root_deps
230 };
231 for (name, spec) in resolved_root {
232 if spec.starts_with("workspace:") {
233 continue;
234 }
235 if self.members.contains_key(&name) {
236 continue;
237 }
238 deps.entry(name).or_insert(spec);
239 }
240
241 Ok(deps)
242 }
243
244 pub fn internal_dep_graph(&self) -> HashMap<String, Vec<String>> {
247 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
248 for (name, member) in &self.members {
249 let mut internal_deps = Vec::new();
250 let all = member.package_json.all_dependencies();
251 for (dep_name, spec) in &all {
252 if spec.starts_with("workspace:") || self.members.contains_key(dep_name) {
253 internal_deps.push(dep_name.clone());
254 }
255 }
256 graph.insert(name.clone(), internal_deps);
257 }
258 graph
259 }
260
261 pub fn filter(&self, pattern: &str) -> Result<Vec<String>> {
264 let glob_pattern = glob::Pattern::new(pattern).map_err(|e| {
265 DnxError::Workspace(format!("Invalid filter pattern '{}': {}", pattern, e))
266 })?;
267
268 let mut matched = Vec::new();
269 for (name, member) in &self.members {
270 let relative = member.path.strip_prefix(&self.root).unwrap_or(&member.path);
271 let relative_str = relative.to_string_lossy().replace('\\', "/");
272 if glob_pattern.matches(&relative_str) || glob_pattern.matches(name) {
273 matched.push(name.clone());
274 }
275 }
276 Ok(matched)
277 }
278
279 pub fn member_external_deps(
283 &self,
284 member_name: &str,
285 catalog: Option<&CatalogConfig>,
286 ) -> Result<HashMap<String, String>> {
287 let member = self
288 .members
289 .get(member_name)
290 .ok_or_else(|| DnxError::Workspace(format!("Member '{}' not found", member_name)))?;
291
292 let all = member.package_json.all_dependencies();
293 let resolved = if let Some(cat) = catalog {
294 resolve_catalog_deps(&all, cat)?
295 } else {
296 all
297 };
298
299 let mut external = HashMap::new();
300 for (name, spec) in resolved {
301 if spec.starts_with("workspace:") {
302 continue;
303 }
304 if self.members.contains_key(&name) {
305 continue;
306 }
307 external.insert(name, spec);
308 }
309 Ok(external)
310 }
311
312 pub fn workspace_packages_map(&self) -> HashMap<String, (String, PathBuf)> {
314 self.members
315 .iter()
316 .map(|(name, m)| (name.clone(), (m.version.clone(), m.path.clone())))
317 .collect()
318 }
319}
320
321fn compute_topo_order(members: &HashMap<String, WorkspaceMember>) -> Result<Vec<String>> {
324 let mut in_degree: HashMap<String, usize> = HashMap::new();
326 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
327
328 for name in members.keys() {
329 in_degree.insert(name.clone(), 0);
330 dependents.entry(name.clone()).or_default();
331 }
332
333 for (name, member) in members {
334 let all = member.package_json.all_dependencies();
335 for (dep_name, spec) in &all {
336 let is_workspace_dep = spec.starts_with("workspace:") || members.contains_key(dep_name);
337 if is_workspace_dep && members.contains_key(dep_name) {
338 *in_degree.entry(name.clone()).or_insert(0) += 1;
339 dependents
340 .entry(dep_name.clone())
341 .or_default()
342 .push(name.clone());
343 }
344 }
345 }
346
347 let mut queue: VecDeque<String> = VecDeque::new();
348 for (name, °ree) in &in_degree {
349 if degree == 0 {
350 queue.push_back(name.clone());
351 }
352 }
353
354 let mut order = Vec::with_capacity(members.len());
355
356 while let Some(name) = queue.pop_front() {
357 let deps_to_process = dependents.get(&name).cloned().unwrap_or_default();
358 order.push(name);
359 for dep in deps_to_process {
360 let degree = in_degree.get_mut(&dep).unwrap();
361 *degree -= 1;
362 if *degree == 0 {
363 queue.push_back(dep);
364 }
365 }
366 }
367
368 if order.len() != members.len() {
369 let remaining: Vec<_> = members
370 .keys()
371 .filter(|n| !order.contains(n))
372 .cloned()
373 .collect();
374 return Err(DnxError::Workspace(format!(
375 "Circular dependency detected among workspace members: {}",
376 remaining.join(", ")
377 )));
378 }
379
380 Ok(order)
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::collections::HashMap;
387
388 fn make_member(name: &str, version: &str, deps: HashMap<String, String>) -> WorkspaceMember {
389 WorkspaceMember {
390 path: PathBuf::from(format!("/workspace/packages/{}", name)),
391 package_json: PackageJson {
392 name: Some(name.to_string()),
393 version: Some(version.to_string()),
394 description: None,
395 main: None,
396 license: None,
397 dependencies: if deps.is_empty() { None } else { Some(deps) },
398 dev_dependencies: None,
399 peer_dependencies: None,
400 optional_dependencies: None,
401 scripts: None,
402 bin: None,
403 engines: None,
404 private: None,
405 overrides: None,
406 resolutions: None,
407 workspaces: None,
408 },
409 name: name.to_string(),
410 version: version.to_string(),
411 }
412 }
413
414 #[test]
415 fn test_topo_order_simple() {
416 let mut members = HashMap::new();
417
418 let mut b_deps = HashMap::new();
419 b_deps.insert("a".to_string(), "workspace:*".to_string());
420
421 members.insert("a".to_string(), make_member("a", "1.0.0", HashMap::new()));
422 members.insert("b".to_string(), make_member("b", "1.0.0", b_deps));
423
424 let order = compute_topo_order(&members).unwrap();
425 let a_idx = order.iter().position(|n| n == "a").unwrap();
426 let b_idx = order.iter().position(|n| n == "b").unwrap();
427 assert!(a_idx < b_idx, "a should come before b");
428 }
429
430 #[test]
431 fn test_topo_order_cycle() {
432 let mut members = HashMap::new();
433
434 let mut a_deps = HashMap::new();
435 a_deps.insert("b".to_string(), "workspace:*".to_string());
436 let mut b_deps = HashMap::new();
437 b_deps.insert("a".to_string(), "workspace:*".to_string());
438
439 members.insert("a".to_string(), make_member("a", "1.0.0", a_deps));
440 members.insert("b".to_string(), make_member("b", "1.0.0", b_deps));
441
442 let result = compute_topo_order(&members);
443 assert!(result.is_err());
444 let err = result.unwrap_err().to_string();
445 assert!(err.contains("Circular dependency"));
446 }
447
448 #[test]
449 fn test_topo_order_diamond() {
450 let mut members = HashMap::new();
452
453 let mut b_deps = HashMap::new();
454 b_deps.insert("a".to_string(), "workspace:*".to_string());
455 let mut c_deps = HashMap::new();
456 c_deps.insert("a".to_string(), "workspace:*".to_string());
457 let mut d_deps = HashMap::new();
458 d_deps.insert("b".to_string(), "workspace:*".to_string());
459 d_deps.insert("c".to_string(), "workspace:*".to_string());
460
461 members.insert("a".to_string(), make_member("a", "1.0.0", HashMap::new()));
462 members.insert("b".to_string(), make_member("b", "1.0.0", b_deps));
463 members.insert("c".to_string(), make_member("c", "1.0.0", c_deps));
464 members.insert("d".to_string(), make_member("d", "1.0.0", d_deps));
465
466 let order = compute_topo_order(&members).unwrap();
467 assert_eq!(order.len(), 4);
468 let a_idx = order.iter().position(|n| n == "a").unwrap();
469 let b_idx = order.iter().position(|n| n == "b").unwrap();
470 let c_idx = order.iter().position(|n| n == "c").unwrap();
471 let d_idx = order.iter().position(|n| n == "d").unwrap();
472 assert!(a_idx < b_idx);
473 assert!(a_idx < c_idx);
474 assert!(b_idx < d_idx);
475 assert!(c_idx < d_idx);
476 }
477
478 #[test]
479 fn test_all_external_deps() {
480 let mut members = HashMap::new();
481
482 let mut a_deps = HashMap::new();
483 a_deps.insert("react".to_string(), "^18.0.0".to_string());
484 a_deps.insert("b".to_string(), "workspace:*".to_string());
485
486 let mut b_deps = HashMap::new();
487 b_deps.insert("lodash".to_string(), "^4.17.21".to_string());
488 b_deps.insert("react".to_string(), "^18.0.0".to_string());
489
490 members.insert("a".to_string(), make_member("a", "1.0.0", a_deps));
491 members.insert("b".to_string(), make_member("b", "1.0.0", b_deps));
492
493 let ws = Workspace {
494 root: PathBuf::from("/workspace"),
495 root_package_json: PackageJson {
496 name: Some("root".to_string()),
497 version: Some("1.0.0".to_string()),
498 description: None,
499 main: None,
500 license: None,
501 dependencies: None,
502 dev_dependencies: None,
503 peer_dependencies: None,
504 optional_dependencies: None,
505 scripts: None,
506 bin: None,
507 engines: None,
508 private: None,
509 overrides: None,
510 resolutions: None,
511 workspaces: Some(vec!["packages/*".to_string()]),
512 },
513 patterns: vec!["packages/*".to_string()],
514 members,
515 topo_order: vec!["b".to_string(), "a".to_string()],
516 };
517
518 let external = ws.all_external_deps();
519 assert!(external.contains_key("react"));
520 assert!(external.contains_key("lodash"));
521 assert!(!external.contains_key("a"));
522 assert!(!external.contains_key("b"));
523 }
524
525 #[test]
526 fn test_internal_dep_graph() {
527 let mut members = HashMap::new();
528
529 let mut a_deps = HashMap::new();
530 a_deps.insert("b".to_string(), "workspace:*".to_string());
531
532 members.insert("a".to_string(), make_member("a", "1.0.0", a_deps));
533 members.insert("b".to_string(), make_member("b", "1.0.0", HashMap::new()));
534
535 let ws = Workspace {
536 root: PathBuf::from("/workspace"),
537 root_package_json: PackageJson {
538 name: Some("root".to_string()),
539 version: Some("1.0.0".to_string()),
540 description: None,
541 main: None,
542 license: None,
543 dependencies: None,
544 dev_dependencies: None,
545 peer_dependencies: None,
546 optional_dependencies: None,
547 scripts: None,
548 bin: None,
549 engines: None,
550 private: None,
551 overrides: None,
552 resolutions: None,
553 workspaces: Some(vec!["packages/*".to_string()]),
554 },
555 patterns: vec!["packages/*".to_string()],
556 members,
557 topo_order: vec!["b".to_string(), "a".to_string()],
558 };
559
560 let graph = ws.internal_dep_graph();
561 assert_eq!(graph["a"], vec!["b".to_string()]);
562 assert!(graph["b"].is_empty());
563 }
564
565 #[test]
566 fn test_filter_by_path() {
567 let mut members = HashMap::new();
568
569 members.insert(
570 "app".to_string(),
571 WorkspaceMember {
572 path: PathBuf::from("/workspace/apps/app"),
573 package_json: PackageJson {
574 name: Some("app".to_string()),
575 version: Some("1.0.0".to_string()),
576 description: None,
577 main: None,
578 license: None,
579 dependencies: None,
580 dev_dependencies: None,
581 peer_dependencies: None,
582 optional_dependencies: None,
583 scripts: None,
584 bin: None,
585 engines: None,
586 private: None,
587 overrides: None,
588 resolutions: None,
589 workspaces: None,
590 },
591 name: "app".to_string(),
592 version: "1.0.0".to_string(),
593 },
594 );
595 members.insert(
596 "lib".to_string(),
597 WorkspaceMember {
598 path: PathBuf::from("/workspace/packages/lib"),
599 package_json: PackageJson {
600 name: Some("lib".to_string()),
601 version: Some("1.0.0".to_string()),
602 description: None,
603 main: None,
604 license: None,
605 dependencies: None,
606 dev_dependencies: None,
607 peer_dependencies: None,
608 optional_dependencies: None,
609 scripts: None,
610 bin: None,
611 engines: None,
612 private: None,
613 overrides: None,
614 resolutions: None,
615 workspaces: None,
616 },
617 name: "lib".to_string(),
618 version: "1.0.0".to_string(),
619 },
620 );
621
622 let ws = Workspace {
623 root: PathBuf::from("/workspace"),
624 root_package_json: PackageJson {
625 name: Some("root".to_string()),
626 version: Some("1.0.0".to_string()),
627 description: None,
628 main: None,
629 license: None,
630 dependencies: None,
631 dev_dependencies: None,
632 peer_dependencies: None,
633 optional_dependencies: None,
634 scripts: None,
635 bin: None,
636 engines: None,
637 private: None,
638 overrides: None,
639 resolutions: None,
640 workspaces: None,
641 },
642 patterns: vec![],
643 members,
644 topo_order: vec!["lib".to_string(), "app".to_string()],
645 };
646
647 let matched = ws.filter("apps/*").unwrap();
648 assert_eq!(matched, vec!["app".to_string()]);
649
650 let matched = ws.filter("packages/*").unwrap();
651 assert_eq!(matched, vec!["lib".to_string()]);
652 }
653}