Skip to main content

changeset_project/
mapping.rs

1use std::collections::HashMap;
2use std::hash::BuildHasher;
3use std::path::{Path, PathBuf};
4
5use changeset_core::PackageInfo;
6use gset::Getset;
7
8use crate::config::{PackageChangesetConfig, RootChangesetConfig};
9use crate::project::CargoProject;
10
11#[derive(Debug, Getset)]
12pub struct PackageFiles {
13    #[getset(get, vis = "pub")]
14    package: PackageInfo,
15    #[getset(get, vis = "pub")]
16    files: Vec<PathBuf>,
17}
18
19impl PackageFiles {
20    pub(crate) fn new(package: PackageInfo, files: Vec<PathBuf>) -> Self {
21        Self { package, files }
22    }
23}
24
25#[derive(Debug, Default, Getset)]
26pub struct FileMapping {
27    #[getset(get, vis = "pub")]
28    packages: Vec<PackageFiles>,
29    #[getset(get, vis = "pub")]
30    project: Vec<PathBuf>,
31    #[getset(get, vis = "pub")]
32    ignored: Vec<PathBuf>,
33}
34
35impl FileMapping {
36    pub(crate) fn new(
37        packages: Vec<PackageFiles>,
38        project: Vec<PathBuf>,
39        ignored: Vec<PathBuf>,
40    ) -> Self {
41        Self {
42            packages,
43            project,
44            ignored,
45        }
46    }
47
48    #[must_use]
49    pub fn affected_packages(&self) -> Vec<&PackageInfo> {
50        self.packages
51            .iter()
52            .filter(|pf| !pf.files.is_empty())
53            .map(|pf| &pf.package)
54            .collect()
55    }
56}
57
58#[must_use]
59pub fn map_files_to_packages<S: BuildHasher>(
60    project: &CargoProject,
61    changed_files: &[PathBuf],
62    root_config: &RootChangesetConfig,
63    package_configs: &HashMap<String, PackageChangesetConfig, S>,
64) -> FileMapping {
65    let mut packages_with_depth: Vec<PackageWithDepth> = project
66        .packages()
67        .iter()
68        .map(|p| {
69            let relative_path = p.path().strip_prefix(project.root()).unwrap_or(p.path());
70            PackageWithDepth {
71                package: p.clone(),
72                depth: calculate_path_depth(relative_path),
73            }
74        })
75        .collect();
76
77    packages_with_depth.sort_by(|a, b| b.depth.cmp(&a.depth));
78
79    let mut package_files_map: HashMap<String, Vec<PathBuf>> = HashMap::new();
80    let mut project_files = Vec::new();
81    let mut ignored_files = Vec::new();
82
83    for file in changed_files {
84        if root_config.is_ignored(file) {
85            ignored_files.push(file.clone());
86            continue;
87        }
88
89        let abs_file = if file.is_absolute() {
90            file.clone()
91        } else {
92            project.root().join(file)
93        };
94
95        let mut matched = false;
96        for pwd in &packages_with_depth {
97            if abs_file.starts_with(pwd.package.path()) {
98                if let Some(pkg_config) = package_configs.get(pwd.package.name()) {
99                    let relative_to_pkg = abs_file
100                        .strip_prefix(pwd.package.path())
101                        .unwrap_or(&abs_file);
102                    if pkg_config.is_ignored(relative_to_pkg) {
103                        ignored_files.push(file.clone());
104                        matched = true;
105                        break;
106                    }
107                }
108
109                package_files_map
110                    .entry(pwd.package.name().clone())
111                    .or_default()
112                    .push(file.clone());
113                matched = true;
114                break;
115            }
116        }
117
118        if !matched {
119            project_files.push(file.clone());
120        }
121    }
122
123    let package_files: Vec<PackageFiles> = project
124        .packages()
125        .iter()
126        .map(|p| {
127            PackageFiles::new(
128                p.clone(),
129                package_files_map.remove(p.name()).unwrap_or_default(),
130            )
131        })
132        .collect();
133
134    FileMapping::new(package_files, project_files, ignored_files)
135}
136
137struct PackageWithDepth {
138    package: PackageInfo,
139    depth: usize,
140}
141
142fn calculate_path_depth(path: &Path) -> usize {
143    path.components().count()
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::ProjectKind;
150    use semver::Version;
151
152    fn make_package(name: &str, path: PathBuf) -> PackageInfo {
153        PackageInfo::new(name.to_string(), Version::new(0, 1, 0), path)
154    }
155
156    fn make_project(root: PathBuf, packages: Vec<PackageInfo>) -> CargoProject {
157        CargoProject::new(root, ProjectKind::VirtualWorkspace, packages)
158    }
159
160    #[test]
161    fn maps_file_to_correct_package() {
162        let root = PathBuf::from("/workspace");
163        let pkg_a = make_package("crate-a", root.join("crates/crate-a"));
164        let pkg_b = make_package("crate-b", root.join("crates/crate-b"));
165        let project = make_project(root.clone(), vec![pkg_a.clone(), pkg_b.clone()]);
166
167        let changed_files = vec![PathBuf::from("crates/crate-a/src/lib.rs")];
168        let root_config = RootChangesetConfig::default();
169        let package_configs = HashMap::new();
170
171        let mapping =
172            map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
173
174        let files_a = mapping
175            .packages()
176            .iter()
177            .find(|pf| pf.package().name() == "crate-a");
178        assert!(files_a.is_some());
179        assert_eq!(files_a.expect("crate-a should exist").files().len(), 1);
180
181        let files_b = mapping
182            .packages()
183            .iter()
184            .find(|pf| pf.package().name() == "crate-b");
185        assert!(files_b.is_some());
186        assert!(files_b.expect("crate-b should exist").files().is_empty());
187    }
188
189    #[test]
190    fn nested_package_takes_precedence() {
191        let root = PathBuf::from("/workspace");
192        let parent = make_package("parent", root.join("crates/parent"));
193        let nested = make_package("nested", root.join("crates/parent/nested"));
194        let project = make_project(root.clone(), vec![parent.clone(), nested.clone()]);
195
196        let changed_files = vec![PathBuf::from("crates/parent/nested/src/lib.rs")];
197        let root_config = RootChangesetConfig::default();
198        let package_configs = HashMap::new();
199
200        let mapping =
201            map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
202
203        let nested = mapping
204            .packages()
205            .iter()
206            .find(|pf| pf.package().name() == "nested");
207        assert!(nested.is_some());
208        assert_eq!(
209            nested.expect("nested package should exist").files().len(),
210            1
211        );
212
213        let parent = mapping
214            .packages()
215            .iter()
216            .find(|pf| pf.package().name() == "parent");
217        assert!(parent.is_some());
218        assert!(
219            parent
220                .expect("parent package should exist")
221                .files()
222                .is_empty()
223        );
224    }
225
226    #[test]
227    fn project_level_files_collected_separately() {
228        let root = PathBuf::from("/workspace");
229        let pkg = make_package("my-crate", root.join("crates/my-crate"));
230        let project = make_project(root.clone(), vec![pkg]);
231
232        let changed_files = vec![
233            PathBuf::from("Cargo.toml"),
234            PathBuf::from(".github/workflows/ci.yml"),
235        ];
236        let root_config = RootChangesetConfig::default();
237        let package_configs = HashMap::new();
238
239        let mapping =
240            map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
241
242        assert_eq!(mapping.project().len(), 2);
243        assert!(mapping.project().contains(&PathBuf::from("Cargo.toml")));
244    }
245
246    #[test]
247    fn affected_packages_returns_only_packages_with_changes() {
248        let root = PathBuf::from("/workspace");
249        let pkg_a = make_package("crate-a", root.join("crates/crate-a"));
250        let pkg_b = make_package("crate-b", root.join("crates/crate-b"));
251        let project = make_project(root.clone(), vec![pkg_a, pkg_b]);
252
253        let changed_files = vec![PathBuf::from("crates/crate-a/src/lib.rs")];
254        let root_config = RootChangesetConfig::default();
255        let package_configs = HashMap::new();
256
257        let mapping =
258            map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
259        let affected = mapping.affected_packages();
260
261        assert_eq!(affected.len(), 1);
262        assert_eq!(affected[0].name(), "crate-a");
263    }
264
265    #[test]
266    fn empty_project_all_files_are_project_level() {
267        let root = PathBuf::from("/workspace");
268        let project = make_project(root.clone(), vec![]);
269
270        let changed_files = vec![PathBuf::from("src/lib.rs")];
271        let root_config = RootChangesetConfig::default();
272        let package_configs = HashMap::new();
273
274        let mapping =
275            map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
276
277        assert!(mapping.packages().is_empty());
278        assert_eq!(mapping.project().len(), 1);
279    }
280}