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    #[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
53/// # Errors
54///
55/// Returns `ProjectError` if no project root can be found or if manifest parsing fails.
56pub 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
75/// # Errors
76///
77/// Returns `ProjectError::DirectoryCreate` if directory creation fails.
78pub 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}