Skip to main content

dnx_core/
workspace.rs

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/// A single workspace member (a package within the monorepo).
8#[derive(Debug, Clone)]
9pub struct WorkspaceMember {
10    /// Absolute path to the member directory.
11    pub path: PathBuf,
12    /// Parsed package.json for this member.
13    pub package_json: PackageJson,
14    /// Package name (from package.json `name` field).
15    pub name: String,
16    /// Package version (from package.json `version` field).
17    pub version: String,
18}
19
20/// Represents a discovered workspace (monorepo).
21#[derive(Debug)]
22pub struct Workspace {
23    /// Absolute path to the workspace root directory.
24    pub root: PathBuf,
25    /// Root-level package.json.
26    pub root_package_json: PackageJson,
27    /// Glob patterns that define workspace members.
28    pub patterns: Vec<String>,
29    /// Discovered members keyed by package name.
30    pub members: HashMap<String, WorkspaceMember>,
31    /// Topologically sorted member names (dependencies before dependents).
32    pub topo_order: Vec<String>,
33}
34
35/// Config loaded from `dnx-workspace.toml` as a fallback when the root
36/// `package.json` does not contain a `workspaces` field.
37#[derive(Debug, serde::Deserialize)]
38struct WorkspaceToml {
39    packages: Vec<String>,
40}
41
42impl Workspace {
43    /// Discover workspace members starting from the given root directory.
44    ///
45    /// Returns `Ok(None)` if this is a plain (non-workspace) project.
46    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        // Determine workspace patterns — priority: dnx.toml > package.json > dnx-workspace.toml
57        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            // Fallback: check dnx-workspace.toml
70            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                // Not a workspace project
81                return Ok(None);
82            }
83        };
84
85        if patterns.is_empty() {
86            return Ok(None);
87        }
88
89        // Expand glob patterns and discover members
90        let mut members = HashMap::new();
91
92        for pattern in &patterns {
93            // Convert the pattern to an absolute glob
94            let abs_pattern = root.join(pattern).to_string_lossy().to_string();
95            // On Windows, glob needs forward slashes
96            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                // Check for duplicate names
129                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        // Compute topological order
154        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    /// Return all external dependencies across all workspace members,
166    /// filtering out `workspace:` and `catalog:` references.
167    /// Deduplicates by keeping the first seen version range for each package.
168    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                // Skip deps that are workspace members
177                if self.members.contains_key(&name) {
178                    continue;
179                }
180                deps.entry(name).or_insert(spec);
181            }
182        }
183        // Also include root-level deps (if any)
184        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    /// Return all external dependencies for all members, resolving
198    /// `catalog:` references using the provided catalog config.
199    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        // Root-level deps
225        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    /// Build the internal dependency graph: adjacency list of
245    /// workspace member name → list of workspace member names it depends on.
246    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    /// Filter workspace members by a glob pattern matched against their
262    /// relative path from the root.
263    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    /// Return resolved external deps for a single member.
280    /// Workspace refs are resolved to the member's version, catalog refs
281    /// are resolved via the catalog config.
282    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    /// Build a map of workspace package name → (version, path) for resolver.
313    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
321/// Compute topological order of workspace members using Kahn's algorithm.
322/// Detects cycles and returns an error if one is found.
323fn compute_topo_order(members: &HashMap<String, WorkspaceMember>) -> Result<Vec<String>> {
324    // Build adjacency list using owned strings to avoid lifetime issues.
325    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, &degree) 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        // d depends on b and c, both depend on a
451        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}