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