Skip to main content

cuenv_workspaces/discovery/
mod.rs

1//! Workspace discovery implementations for various package managers.
2//!
3//! This module provides implementations of the [`WorkspaceDiscovery`] trait for
4//! discovering workspace configurations from:
5//! - `package.json` (npm, Bun, Yarn)
6//! - `pnpm-workspace.yaml` (pnpm)
7//! - `Cargo.toml` (Rust/Cargo)
8//!
9//! # Usage
10//!
11//! ```rust,ignore
12//! use cuenv_workspaces::discovery::{PackageJsonDiscovery, CargoTomlDiscovery};
13//! use cuenv_workspaces::WorkspaceDiscovery;
14//! use std::path::Path;
15//!
16//! let root = Path::new(".");
17//!
18//! // Try npm/yarn/bun workspace
19//! if let Ok(workspace) = PackageJsonDiscovery.discover(root) {
20//!     println!("Found {} npm members", workspace.member_count());
21//! }
22//!
23//! // Try cargo workspace
24//! if let Ok(workspace) = CargoTomlDiscovery.discover(root) {
25//!     println!("Found {} crate members", workspace.member_count());
26//! }
27//! ```
28
29use crate::error::{Error, Result};
30use glob::Pattern;
31use serde::de::DeserializeOwned;
32use std::collections::HashSet;
33use std::fs;
34use std::path::{Path, PathBuf};
35use walkdir::WalkDir;
36
37#[cfg(feature = "discovery-cargo")]
38pub mod cargo_toml;
39
40#[cfg(feature = "discovery-package-json")]
41pub mod package_json;
42
43#[cfg(feature = "discovery-pnpm")]
44pub mod pnpm_workspace;
45
46#[cfg(feature = "discovery-cargo")]
47pub use cargo_toml::CargoTomlDiscovery;
48
49#[cfg(feature = "discovery-package-json")]
50pub use package_json::PackageJsonDiscovery;
51
52#[cfg(feature = "discovery-pnpm")]
53pub use pnpm_workspace::PnpmWorkspaceDiscovery;
54
55/// Resolves glob patterns to find directories, handling exclusions.
56///
57/// # Arguments
58///
59/// * `root` - The root directory to resolve patterns from.
60/// * `patterns` - List of glob patterns to match (e.g., "packages/*").
61/// * `exclusions` - List of glob patterns to exclude (e.g., "packages/excluded").
62///   Note: Patterns starting with "!" in the `patterns` list are also treated as exclusions.
63///
64/// # Returns
65///
66/// A sorted list of unique, absolute paths (rooted under `root`) that match the patterns and are not excluded.
67///
68/// # Errors
69///
70/// Returns an error if any glob pattern is invalid or if the filesystem cannot
71/// be read while resolving glob matches.
72///
73/// # Implementation Notes
74///
75/// This implementation uses `walkdir` for efficient traversal and prunes common
76/// heavy directories (`node_modules`, `.git`, `target`, `dist`) to improve performance
77pub fn resolve_glob_patterns(
78    root: &Path,
79    patterns: &[String],
80    exclusions: &[String],
81) -> Result<Vec<PathBuf>> {
82    let mut matched_paths = HashSet::new();
83
84    // Compile patterns
85    let mut inclusion_patterns = Vec::new();
86    let mut exclusion_patterns = Vec::new();
87
88    // Pre-compile default exclusions to avoid traversing heavy directories
89    let default_ignores = [
90        "**/node_modules/**",
91        "**/.git/**",
92        "**/target/**",
93        "**/dist/**",
94    ];
95    for ignore in default_ignores {
96        if let Ok(pat) = Pattern::new(ignore) {
97            exclusion_patterns.push(pat);
98        }
99    }
100
101    for p in exclusions {
102        if let Ok(pat) = Pattern::new(p) {
103            exclusion_patterns.push(pat);
104        }
105    }
106
107    for p in patterns {
108        if let Some(stripped) = p.strip_prefix('!') {
109            if let Ok(pat) = Pattern::new(stripped) {
110                exclusion_patterns.push(pat);
111            }
112        } else if let Ok(pat) = Pattern::new(p) {
113            inclusion_patterns.push(pat);
114        }
115    }
116
117    // Walk the directory tree
118    let walker = WalkDir::new(root).follow_links(false);
119
120    for entry in walker
121        .into_iter()
122        .filter_entry(|e| {
123            let name = e.file_name().to_str().unwrap_or("");
124            // Standard directory ignores to prune search tree
125            if name == "node_modules" || name == ".git" || name == "target" || name == "dist" {
126                return false;
127            }
128            true
129        })
130        .filter_map(std::result::Result::ok)
131    {
132        if !entry.file_type().is_dir() {
133            continue;
134        }
135
136        let path = entry.path();
137        // Skip root itself
138        if path == root {
139            continue;
140        }
141
142        // Relativize path for matching
143        let Ok(rel_path) = path.strip_prefix(root) else {
144            continue;
145        };
146
147        // Check exclusions
148        let is_excluded = exclusion_patterns.iter().any(|p| p.matches_path(rel_path));
149        if is_excluded {
150            continue;
151        }
152
153        // Check inclusions
154        let is_included = inclusion_patterns.iter().any(|p| p.matches_path(rel_path));
155        if is_included {
156            matched_paths.insert(path.to_path_buf());
157        }
158    }
159
160    let mut result: Vec<PathBuf> = matched_paths.into_iter().collect();
161    result.sort();
162    Ok(result)
163}
164
165/// Reads and parses a JSON file.
166///
167/// # Errors
168///
169/// Returns an error if the file cannot be read or parsed as valid JSON.
170pub fn read_json_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
171    let content = fs::read_to_string(path).map_err(|e| Error::Io {
172        source: e,
173        path: Some(path.to_path_buf()),
174        operation: "reading json file".to_string(),
175    })?;
176
177    serde_json::from_str(&content).map_err(|e| Error::Json {
178        source: e,
179        path: Some(path.to_path_buf()),
180    })
181}
182
183/// Reads and parses a YAML file.
184///
185/// # Errors
186///
187/// Returns an error if the file cannot be read or parsed as valid YAML.
188#[cfg(feature = "serde_yaml")]
189pub fn read_yaml_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
190    let content = fs::read_to_string(path).map_err(|e| Error::Io {
191        source: e,
192        path: Some(path.to_path_buf()),
193        operation: "reading yaml file".to_string(),
194    })?;
195
196    serde_yaml::from_str(&content).map_err(|e| Error::Yaml {
197        source: e,
198        path: Some(path.to_path_buf()),
199    })
200}
201
202/// Reads and parses a TOML file.
203///
204/// # Errors
205///
206/// Returns an error if the file cannot be read or parsed as valid TOML.
207#[cfg(feature = "toml")]
208pub fn read_toml_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
209    let content = fs::read_to_string(path).map_err(|e| Error::Io {
210        source: e,
211        path: Some(path.to_path_buf()),
212        operation: "reading toml file".to_string(),
213    })?;
214
215    toml::from_str(&content).map_err(|e| Error::Toml {
216        source: e,
217        path: Some(path.to_path_buf()),
218    })
219}