Skip to main content

changeset_project/
project.rs

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
26/// # Errors
27///
28/// Returns `ProjectError` if no project root can be found or if manifest parsing fails.
29pub 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
48/// # Errors
49///
50/// Returns `ProjectError::DirectoryCreate` if directory creation fails.
51pub 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        // Fallback to full path if strip_prefix fails (shouldn't happen in practice)
261        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}