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