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 #[cfg(any(test, feature = "testing"))]
28 #[must_use]
29 pub fn new(root: PathBuf, kind: ProjectKind, packages: Vec<PackageInfo>) -> Self {
30 Self {
31 root,
32 kind,
33 packages,
34 }
35 }
36
37 #[must_use]
38 pub fn root(&self) -> &Path {
39 &self.root
40 }
41
42 #[must_use]
43 pub fn kind(&self) -> &ProjectKind {
44 &self.kind
45 }
46
47 #[must_use]
48 pub fn packages(&self) -> &[PackageInfo] {
49 &self.packages
50 }
51}
52
53pub fn discover_project(start_dir: &Path) -> Result<CargoProject, ProjectError> {
57 let start_dir = start_dir
58 .canonicalize()
59 .map_err(|source| ProjectError::ManifestRead {
60 path: start_dir.to_path_buf(),
61 source,
62 })?;
63
64 let (root, manifest) = find_project_root(&start_dir)?;
65 let kind = determine_project_kind(&manifest);
66 let packages = collect_packages(&root, &manifest, &kind)?;
67
68 Ok(CargoProject {
69 root,
70 kind,
71 packages,
72 })
73}
74
75pub fn ensure_changeset_dir(
79 project: &CargoProject,
80 config: &RootChangesetConfig,
81) -> Result<PathBuf, ProjectError> {
82 let changeset_dir = project.root().join(config.changeset_dir());
83 let changesets_subdir = changeset_dir.join(CHANGESETS_SUBDIR);
84 if !changesets_subdir.exists() {
85 std::fs::create_dir_all(&changesets_subdir).map_err(|source| {
86 ProjectError::DirectoryCreate {
87 path: changesets_subdir,
88 source,
89 }
90 })?;
91 }
92 Ok(changeset_dir)
93}
94
95fn find_project_root(start_dir: &Path) -> Result<(PathBuf, CargoManifest), ProjectError> {
96 let mut current = start_dir.to_path_buf();
97 let mut fallback_single_package: Option<(PathBuf, CargoManifest)> = None;
98
99 loop {
100 let manifest_path = current.join("Cargo.toml");
101
102 if manifest_path.exists() {
103 let manifest = read_manifest(&manifest_path)?;
104
105 if manifest.workspace.is_some() {
106 return Ok((current, manifest));
107 }
108
109 if manifest.package.is_some() && fallback_single_package.is_none() {
110 fallback_single_package = Some((current.clone(), manifest));
111 }
112 }
113
114 match current.parent() {
115 Some(parent) => current = parent.to_path_buf(),
116 None => {
117 return fallback_single_package.ok_or_else(|| ProjectError::NotFound {
118 start_dir: start_dir.to_path_buf(),
119 });
120 }
121 }
122 }
123}
124
125fn determine_project_kind(manifest: &CargoManifest) -> ProjectKind {
126 match (&manifest.workspace, &manifest.package) {
127 (Some(_), Some(_)) => ProjectKind::WorkspaceWithRoot,
128 (None, Some(_)) => ProjectKind::SinglePackage,
129 (Some(_) | None, None) => ProjectKind::VirtualWorkspace,
130 }
131}
132
133fn collect_packages(
134 root: &Path,
135 manifest: &CargoManifest,
136 kind: &ProjectKind,
137) -> Result<Vec<PackageInfo>, ProjectError> {
138 let workspace_version = manifest
139 .workspace
140 .as_ref()
141 .and_then(|ws| ws.package.as_ref())
142 .and_then(|pkg| pkg.version.as_ref());
143
144 let mut packages = Vec::new();
145
146 if *kind == ProjectKind::WorkspaceWithRoot {
147 if let Some(pkg) = &manifest.package {
148 let version = resolve_version(
149 pkg.version.as_ref(),
150 workspace_version,
151 &root.join("Cargo.toml"),
152 )?;
153 packages.push(PackageInfo::new(
154 pkg.name.clone(),
155 version,
156 root.to_path_buf(),
157 ));
158 }
159 }
160
161 if *kind == ProjectKind::SinglePackage {
162 if let Some(pkg) = &manifest.package {
163 let version = resolve_version(
164 pkg.version.as_ref(),
165 workspace_version,
166 &root.join("Cargo.toml"),
167 )?;
168 return Ok(vec![PackageInfo::new(
169 pkg.name.clone(),
170 version,
171 root.to_path_buf(),
172 )]);
173 }
174 }
175
176 if let Some(workspace) = &manifest.workspace {
177 let members = workspace.members.as_deref().unwrap_or(&[]);
178 let excludes = workspace.exclude.as_deref().unwrap_or(&[]);
179
180 for pattern in members {
181 let member_dirs = expand_glob_pattern(root, pattern, excludes)?;
182
183 for member_dir in member_dirs {
184 let member_manifest_path = member_dir.join("Cargo.toml");
185 if !member_manifest_path.exists() {
186 continue;
187 }
188
189 let member_manifest = read_manifest(&member_manifest_path)?;
190 if let Some(pkg) = member_manifest.package {
191 let version = resolve_version(
192 pkg.version.as_ref(),
193 workspace_version,
194 &member_manifest_path,
195 )?;
196 packages.push(PackageInfo::new(pkg.name, version, member_dir));
197 }
198 }
199 }
200 }
201
202 Ok(packages)
203}
204
205fn resolve_version(
206 version_field: Option<&VersionField>,
207 workspace_version: Option<&String>,
208 manifest_path: &Path,
209) -> Result<Version, ProjectError> {
210 let version_str = match version_field {
211 Some(VersionField::Literal(v)) => v.clone(),
212 Some(VersionField::Inherited(inherited)) if inherited.workspace => workspace_version
213 .ok_or_else(|| ProjectError::MissingField {
214 path: manifest_path.to_path_buf(),
215 field: "workspace.package.version",
216 })?
217 .clone(),
218 Some(VersionField::Inherited(_)) | None => {
219 return Err(ProjectError::MissingField {
220 path: manifest_path.to_path_buf(),
221 field: "package.version",
222 });
223 }
224 };
225
226 version_str
227 .parse()
228 .map_err(|source| ProjectError::InvalidVersion {
229 path: manifest_path.to_path_buf(),
230 version: version_str,
231 source,
232 })
233}
234
235fn path_to_str(path: &Path) -> Result<&str, ProjectError> {
236 path.to_str().ok_or(ProjectError::NonUtf8Path {
237 path: path.to_path_buf(),
238 })
239}
240
241fn expand_glob_pattern(
242 root: &Path,
243 pattern: &str,
244 excludes: &[String],
245) -> Result<Vec<PathBuf>, ProjectError> {
246 let absolute_pattern = root.join(pattern);
247 let pattern_str = path_to_str(&absolute_pattern)?;
248
249 let paths = glob::glob(pattern_str).map_err(|source| ProjectError::GlobPatternParse {
250 pattern: pattern.to_string(),
251 source,
252 })?;
253
254 let exclude_matchers: Vec<globset::GlobMatcher> = excludes
255 .iter()
256 .map(|ex| {
257 GlobBuilder::new(ex)
258 .literal_separator(true)
259 .build()
260 .map(|g| g.compile_matcher())
261 .map_err(|source| ProjectError::GlobPattern {
262 pattern: ex.clone(),
263 source,
264 })
265 })
266 .collect::<Result<Vec<_>, _>>()?;
267
268 let mut dirs = Vec::new();
269 for entry in paths {
270 let path = entry?;
271
272 if !path.is_dir() {
273 continue;
274 }
275
276 let relative = path.strip_prefix(root).unwrap_or(&path);
277
278 if exclude_matchers.iter().any(|ex| ex.is_match(relative)) {
279 continue;
280 }
281
282 dirs.push(path);
283 }
284
285 Ok(dirs)
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn determine_project_kind_virtual() {
294 let manifest = CargoManifest {
295 package: None,
296 workspace: Some(crate::manifest::WorkspaceSection {
297 members: Some(vec!["crates/*".to_string()]),
298 exclude: None,
299 package: None,
300 metadata: None,
301 }),
302 dependencies: None,
303 build_dependencies: None,
304 };
305 assert_eq!(
306 determine_project_kind(&manifest),
307 ProjectKind::VirtualWorkspace
308 );
309 }
310
311 #[test]
312 fn determine_project_kind_workspace_with_root() {
313 let manifest = CargoManifest {
314 package: Some(crate::manifest::Package {
315 name: "test".to_string(),
316 version: Some(VersionField::Literal("1.0.0".to_string())),
317 metadata: None,
318 }),
319 workspace: Some(crate::manifest::WorkspaceSection {
320 members: Some(vec!["crates/*".to_string()]),
321 exclude: None,
322 package: None,
323 metadata: None,
324 }),
325 dependencies: None,
326 build_dependencies: None,
327 };
328 assert_eq!(
329 determine_project_kind(&manifest),
330 ProjectKind::WorkspaceWithRoot
331 );
332 }
333
334 #[test]
335 fn determine_project_kind_single_package() {
336 let manifest = CargoManifest {
337 package: Some(crate::manifest::Package {
338 name: "test".to_string(),
339 version: Some(VersionField::Literal("1.0.0".to_string())),
340 metadata: None,
341 }),
342 workspace: None,
343 dependencies: None,
344 build_dependencies: None,
345 };
346 assert_eq!(
347 determine_project_kind(&manifest),
348 ProjectKind::SinglePackage
349 );
350 }
351
352 #[test]
353 fn expand_glob_returns_only_directories() -> Result<(), ProjectError> {
354 let temp = tempfile::tempdir().expect("create temp dir");
355 let root = temp.path();
356
357 std::fs::create_dir_all(root.join("crates/foo")).expect("create dir");
358 std::fs::write(root.join("crates/bar.txt"), "file").expect("create file");
359
360 let result = expand_glob_pattern(root, "crates/*", &[])?;
361
362 assert_eq!(result.len(), 1);
363 assert!(result[0].ends_with("crates/foo"));
364 Ok(())
365 }
366
367 #[test]
368 fn expand_glob_no_matches_returns_empty() -> Result<(), ProjectError> {
369 let temp = tempfile::tempdir().expect("create temp dir");
370 let root = temp.path();
371
372 std::fs::create_dir_all(root.join("other")).expect("create dir");
373
374 let result = expand_glob_pattern(root, "crates/*", &[])?;
375
376 assert!(result.is_empty());
377 Ok(())
378 }
379
380 #[test]
381 fn expand_glob_excludes_matching_dirs() -> Result<(), ProjectError> {
382 let temp = tempfile::tempdir().expect("create temp dir");
383 let root = temp.path();
384
385 std::fs::create_dir_all(root.join("crates/included")).expect("create dir");
386 std::fs::create_dir_all(root.join("crates/excluded")).expect("create dir");
387
388 let excludes = vec!["crates/excluded".to_string()];
389 let result = expand_glob_pattern(root, "crates/*", &excludes)?;
390
391 assert_eq!(result.len(), 1);
392 assert!(result[0].ends_with("crates/included"));
393 Ok(())
394 }
395
396 #[test]
397 fn expand_glob_literal_pattern() -> Result<(), ProjectError> {
398 let temp = tempfile::tempdir().expect("create temp dir");
399 let root = temp.path();
400
401 std::fs::create_dir_all(root.join("specific-crate")).expect("create dir");
402
403 let result = expand_glob_pattern(root, "specific-crate", &[])?;
404
405 assert_eq!(result.len(), 1);
406 assert!(result[0].ends_with("specific-crate"));
407 Ok(())
408 }
409
410 #[test]
411 fn expand_glob_star_does_not_match_nested() -> Result<(), ProjectError> {
412 let temp = tempfile::tempdir().expect("create temp dir");
413 let root = temp.path();
414
415 std::fs::create_dir_all(root.join("crates/foo")).expect("create dir");
416 std::fs::create_dir_all(root.join("crates/foo/nested")).expect("create dir");
417
418 let result = expand_glob_pattern(root, "crates/*", &[])?;
419
420 assert_eq!(result.len(), 1);
421 assert!(result[0].ends_with("crates/foo"));
422 Ok(())
423 }
424
425 #[test]
426 fn expand_glob_invalid_pattern_returns_error() {
427 let temp = tempfile::tempdir().expect("create temp dir");
428 let result = expand_glob_pattern(temp.path(), "[invalid", &[]);
429
430 assert!(matches!(
431 result,
432 Err(ProjectError::GlobPatternParse { pattern, .. }) if pattern == "[invalid"
433 ));
434 }
435
436 #[test]
437 fn expand_glob_double_star_matches_nested() -> Result<(), ProjectError> {
438 let temp = tempfile::tempdir().expect("create temp dir");
439 let root = temp.path();
440
441 std::fs::create_dir_all(root.join("packages/foo")).expect("create dir");
442 std::fs::create_dir_all(root.join("packages/bar/nested")).expect("create dir");
443
444 let result = expand_glob_pattern(root, "packages/**", &[])?;
445
446 let relative_paths: Vec<_> = result
447 .iter()
448 .map(|p| p.strip_prefix(root).expect("strip prefix"))
449 .collect();
450
451 assert!(relative_paths.iter().any(|p| p.ends_with("foo")));
452 assert!(relative_paths.iter().any(|p| p.ends_with("nested")));
453 Ok(())
454 }
455
456 #[test]
457 fn expand_glob_invalid_exclude_pattern_returns_error() {
458 let temp = tempfile::tempdir().expect("create temp dir");
459 let root = temp.path();
460
461 std::fs::create_dir_all(root.join("crates/foo")).expect("create dir");
462
463 let excludes = vec!["[invalid".to_string()];
464 let result = expand_glob_pattern(root, "crates/*", &excludes);
465
466 assert!(matches!(
467 result,
468 Err(ProjectError::GlobPattern { pattern, .. }) if pattern == "[invalid"
469 ));
470 }
471}