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    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
52/// # Errors
53///
54/// Returns `ProjectError` if no project root can be found or if manifest parsing fails.
55pub 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
70/// # Errors
71///
72/// Returns `ProjectError::DirectoryCreate` if directory creation fails.
73pub 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        // Fallback to full path if strip_prefix fails (shouldn't happen in practice)
283        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            dependencies: None,
314            build_dependencies: None,
315        };
316        assert_eq!(
317            determine_project_kind(&manifest),
318            ProjectKind::VirtualWorkspace
319        );
320    }
321
322    #[test]
323    fn determine_project_kind_workspace_with_root() {
324        let manifest = CargoManifest {
325            package: Some(crate::manifest::Package {
326                name: "test".to_string(),
327                version: Some(VersionField::Literal("1.0.0".to_string())),
328                metadata: None,
329            }),
330            workspace: Some(crate::manifest::WorkspaceSection {
331                members: Some(vec!["crates/*".to_string()]),
332                exclude: None,
333                package: None,
334                metadata: None,
335            }),
336            dependencies: None,
337            build_dependencies: None,
338        };
339        assert_eq!(
340            determine_project_kind(&manifest),
341            ProjectKind::WorkspaceWithRoot
342        );
343    }
344
345    #[test]
346    fn determine_project_kind_single_package() {
347        let manifest = CargoManifest {
348            package: Some(crate::manifest::Package {
349                name: "test".to_string(),
350                version: Some(VersionField::Literal("1.0.0".to_string())),
351                metadata: None,
352            }),
353            workspace: None,
354            dependencies: None,
355            build_dependencies: None,
356        };
357        assert_eq!(
358            determine_project_kind(&manifest),
359            ProjectKind::SinglePackage
360        );
361    }
362}