changeset_project/
mapping.rs1use 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}