1use std::path::{Path, PathBuf};
2
3use changeset_core::PackageInfo;
4use globset::GlobBuilder;
5use semver::Version;
6
7use crate::CHANGESETS_SUBDIR;
8use crate::config::RootChangesetConfig;
9use crate::error::ProjectError;
10use crate::manifest::{CargoManifest, VersionField, read_manifest};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ProjectKind {
14 VirtualWorkspace,
15 WorkspaceWithRoot,
16 SinglePackage,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CargoProject {
21 root: PathBuf,
22 kind: ProjectKind,
23 packages: Vec<PackageInfo>,
24}
25
26impl CargoProject {
27 #[must_use]
28 pub fn new(root: PathBuf, kind: ProjectKind, packages: Vec<PackageInfo>) -> Self {
29 Self {
30 root,
31 kind,
32 packages,
33 }
34 }
35
36 #[must_use]
37 pub fn root(&self) -> &Path {
38 &self.root
39 }
40
41 #[must_use]
42 pub fn kind(&self) -> &ProjectKind {
43 &self.kind
44 }
45
46 #[must_use]
47 pub fn packages(&self) -> &[PackageInfo] {
48 &self.packages
49 }
50}
51
52pub fn discover_project(start_dir: &Path) -> Result<CargoProject, ProjectError> {
56 let start_dir = start_dir
57 .canonicalize()
58 .map_err(|source| ProjectError::ManifestRead {
59 path: start_dir.to_path_buf(),
60 source,
61 })?;
62
63 let (root, manifest) = find_project_root(&start_dir)?;
64 let kind = determine_project_kind(&manifest);
65 let packages = collect_packages(&root, &manifest, &kind)?;
66
67 Ok(CargoProject::new(root, kind, packages))
68}
69
70pub fn ensure_changeset_dir(
74 project: &CargoProject,
75 config: &RootChangesetConfig,
76) -> Result<PathBuf, ProjectError> {
77 let changeset_dir = project.root().join(config.changeset_dir());
78 let changesets_subdir = changeset_dir.join(CHANGESETS_SUBDIR);
79 if !changesets_subdir.exists() {
80 std::fs::create_dir_all(&changesets_subdir).map_err(|source| {
81 ProjectError::DirectoryCreate {
82 path: changesets_subdir,
83 source,
84 }
85 })?;
86 }
87 Ok(changeset_dir)
88}
89
90fn find_project_root(start_dir: &Path) -> Result<(PathBuf, CargoManifest), ProjectError> {
91 let mut current = start_dir.to_path_buf();
92 let mut fallback_single_package: Option<(PathBuf, CargoManifest)> = None;
93
94 loop {
95 let manifest_path = current.join("Cargo.toml");
96
97 if manifest_path.exists() {
98 let manifest = read_manifest(&manifest_path)?;
99
100 if manifest.workspace.is_some() {
101 return Ok((current, manifest));
102 }
103
104 if manifest.package.is_some() && fallback_single_package.is_none() {
105 fallback_single_package = Some((current.clone(), manifest));
106 }
107 }
108
109 match current.parent() {
110 Some(parent) => current = parent.to_path_buf(),
111 None => {
112 return fallback_single_package.ok_or_else(|| ProjectError::NotFound {
113 start_dir: start_dir.to_path_buf(),
114 });
115 }
116 }
117 }
118}
119
120fn determine_project_kind(manifest: &CargoManifest) -> ProjectKind {
121 match (&manifest.workspace, &manifest.package) {
122 (Some(_), Some(_)) => ProjectKind::WorkspaceWithRoot,
123 (None, Some(_)) => ProjectKind::SinglePackage,
124 (Some(_) | None, None) => ProjectKind::VirtualWorkspace,
125 }
126}
127
128fn collect_packages(
129 root: &Path,
130 manifest: &CargoManifest,
131 kind: &ProjectKind,
132) -> Result<Vec<PackageInfo>, ProjectError> {
133 let workspace_version = manifest
134 .workspace
135 .as_ref()
136 .and_then(|ws| ws.package.as_ref())
137 .and_then(|pkg| pkg.version.as_ref());
138
139 let mut packages = Vec::new();
140
141 if *kind == ProjectKind::WorkspaceWithRoot {
142 if let Some(pkg) = &manifest.package {
143 let version = resolve_version(
144 pkg.version.as_ref(),
145 workspace_version,
146 &root.join("Cargo.toml"),
147 )?;
148 packages.push(PackageInfo {
149 name: pkg.name.clone(),
150 version,
151 path: root.to_path_buf(),
152 });
153 }
154 }
155
156 if *kind == ProjectKind::SinglePackage {
157 if let Some(pkg) = &manifest.package {
158 let version = resolve_version(
159 pkg.version.as_ref(),
160 workspace_version,
161 &root.join("Cargo.toml"),
162 )?;
163 return Ok(vec![PackageInfo {
164 name: pkg.name.clone(),
165 version,
166 path: root.to_path_buf(),
167 }]);
168 }
169 }
170
171 if let Some(workspace) = &manifest.workspace {
172 let members = workspace.members.as_deref().unwrap_or(&[]);
173 let excludes = workspace.exclude.as_deref().unwrap_or(&[]);
174
175 for pattern in members {
176 let member_dirs = expand_glob_pattern(root, pattern, excludes)?;
177
178 for member_dir in member_dirs {
179 let member_manifest_path = member_dir.join("Cargo.toml");
180 if !member_manifest_path.exists() {
181 continue;
182 }
183
184 let member_manifest = read_manifest(&member_manifest_path)?;
185 if let Some(pkg) = member_manifest.package {
186 let version = resolve_version(
187 pkg.version.as_ref(),
188 workspace_version,
189 &member_manifest_path,
190 )?;
191 packages.push(PackageInfo {
192 name: pkg.name,
193 version,
194 path: member_dir,
195 });
196 }
197 }
198 }
199 }
200
201 Ok(packages)
202}
203
204fn resolve_version(
205 version_field: Option<&VersionField>,
206 workspace_version: Option<&String>,
207 manifest_path: &Path,
208) -> Result<Version, ProjectError> {
209 let version_str = match version_field {
210 Some(VersionField::Literal(v)) => v.clone(),
211 Some(VersionField::Inherited(inherited)) if inherited.workspace => workspace_version
212 .ok_or_else(|| ProjectError::MissingField {
213 path: manifest_path.to_path_buf(),
214 field: "workspace.package.version",
215 })?
216 .clone(),
217 Some(VersionField::Inherited(_)) | None => {
218 return Err(ProjectError::MissingField {
219 path: manifest_path.to_path_buf(),
220 field: "package.version",
221 });
222 }
223 };
224
225 version_str
226 .parse()
227 .map_err(|source| ProjectError::InvalidVersion {
228 path: manifest_path.to_path_buf(),
229 version: version_str,
230 source,
231 })
232}
233
234fn expand_glob_pattern(
235 root: &Path,
236 pattern: &str,
237 excludes: &[String],
238) -> Result<Vec<PathBuf>, ProjectError> {
239 let glob = GlobBuilder::new(pattern)
240 .literal_separator(true)
241 .build()
242 .map_err(|source| ProjectError::GlobPattern {
243 pattern: pattern.to_string(),
244 source,
245 })?
246 .compile_matcher();
247
248 let exclude_matchers: Vec<_> = excludes
249 .iter()
250 .filter_map(|ex| {
251 GlobBuilder::new(ex)
252 .literal_separator(true)
253 .build()
254 .ok()
255 .map(|g| g.compile_matcher())
256 })
257 .collect();
258
259 let mut dirs = Vec::new();
260 collect_matching_dirs(root, root, &glob, &exclude_matchers, &mut dirs)?;
261
262 Ok(dirs)
263}
264
265fn collect_matching_dirs(
266 base: &Path,
267 current: &Path,
268 glob: &globset::GlobMatcher,
269 excludes: &[globset::GlobMatcher],
270 results: &mut Vec<PathBuf>,
271) -> Result<(), ProjectError> {
272 let entries = std::fs::read_dir(current)?;
273
274 for entry in entries {
275 let entry = entry?;
276 let path = entry.path();
277
278 if !path.is_dir() {
279 continue;
280 }
281
282 let relative = path.strip_prefix(base).unwrap_or(&path);
284
285 if excludes.iter().any(|ex| ex.is_match(relative)) {
286 continue;
287 }
288
289 if glob.is_match(relative) {
290 results.push(path.clone());
291 }
292
293 collect_matching_dirs(base, &path, glob, excludes, results)?;
294 }
295
296 Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn determine_project_kind_virtual() {
305 let manifest = CargoManifest {
306 package: None,
307 workspace: Some(crate::manifest::WorkspaceSection {
308 members: Some(vec!["crates/*".to_string()]),
309 exclude: None,
310 package: None,
311 metadata: None,
312 }),
313 };
314 assert_eq!(
315 determine_project_kind(&manifest),
316 ProjectKind::VirtualWorkspace
317 );
318 }
319
320 #[test]
321 fn determine_project_kind_workspace_with_root() {
322 let manifest = CargoManifest {
323 package: Some(crate::manifest::Package {
324 name: "test".to_string(),
325 version: Some(VersionField::Literal("1.0.0".to_string())),
326 metadata: None,
327 }),
328 workspace: Some(crate::manifest::WorkspaceSection {
329 members: Some(vec!["crates/*".to_string()]),
330 exclude: None,
331 package: None,
332 metadata: None,
333 }),
334 };
335 assert_eq!(
336 determine_project_kind(&manifest),
337 ProjectKind::WorkspaceWithRoot
338 );
339 }
340
341 #[test]
342 fn determine_project_kind_single_package() {
343 let manifest = CargoManifest {
344 package: Some(crate::manifest::Package {
345 name: "test".to_string(),
346 version: Some(VersionField::Literal("1.0.0".to_string())),
347 metadata: None,
348 }),
349 workspace: None,
350 };
351 assert_eq!(
352 determine_project_kind(&manifest),
353 ProjectKind::SinglePackage
354 );
355 }
356}