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::new(root, ProjectKind::VirtualWorkspace, packages)
148 }
149
150 #[test]
151 fn maps_file_to_correct_package() {
152 let root = PathBuf::from("/workspace");
153 let pkg_a = make_package("crate-a", root.join("crates/crate-a"));
154 let pkg_b = make_package("crate-b", root.join("crates/crate-b"));
155 let project = make_project(root.clone(), vec![pkg_a.clone(), pkg_b.clone()]);
156
157 let changed_files = vec![PathBuf::from("crates/crate-a/src/lib.rs")];
158 let root_config = RootChangesetConfig::default();
159 let package_configs = HashMap::new();
160
161 let mapping =
162 map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
163
164 let files_a = mapping
165 .package_files
166 .iter()
167 .find(|pf| pf.package.name == "crate-a");
168 assert!(files_a.is_some());
169 assert_eq!(files_a.expect("crate-a should exist").files.len(), 1);
170
171 let files_b = mapping
172 .package_files
173 .iter()
174 .find(|pf| pf.package.name == "crate-b");
175 assert!(files_b.is_some());
176 assert!(files_b.expect("crate-b should exist").files.is_empty());
177 }
178
179 #[test]
180 fn nested_package_takes_precedence() {
181 let root = PathBuf::from("/workspace");
182 let parent = make_package("parent", root.join("crates/parent"));
183 let nested = make_package("nested", root.join("crates/parent/nested"));
184 let project = make_project(root.clone(), vec![parent.clone(), nested.clone()]);
185
186 let changed_files = vec![PathBuf::from("crates/parent/nested/src/lib.rs")];
187 let root_config = RootChangesetConfig::default();
188 let package_configs = HashMap::new();
189
190 let mapping =
191 map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
192
193 let nested = mapping
194 .package_files
195 .iter()
196 .find(|pf| pf.package.name == "nested");
197 assert!(nested.is_some());
198 assert_eq!(nested.expect("nested package should exist").files.len(), 1);
199
200 let parent = mapping
201 .package_files
202 .iter()
203 .find(|pf| pf.package.name == "parent");
204 assert!(parent.is_some());
205 assert!(
206 parent
207 .expect("parent package should exist")
208 .files
209 .is_empty()
210 );
211 }
212
213 #[test]
214 fn project_level_files_collected_separately() {
215 let root = PathBuf::from("/workspace");
216 let pkg = make_package("my-crate", root.join("crates/my-crate"));
217 let project = make_project(root.clone(), vec![pkg]);
218
219 let changed_files = vec![
220 PathBuf::from("Cargo.toml"),
221 PathBuf::from(".github/workflows/ci.yml"),
222 ];
223 let root_config = RootChangesetConfig::default();
224 let package_configs = HashMap::new();
225
226 let mapping =
227 map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
228
229 assert_eq!(mapping.project_files.len(), 2);
230 assert!(mapping.project_files.contains(&PathBuf::from("Cargo.toml")));
231 }
232
233 #[test]
234 fn affected_packages_returns_only_packages_with_changes() {
235 let root = PathBuf::from("/workspace");
236 let pkg_a = make_package("crate-a", root.join("crates/crate-a"));
237 let pkg_b = make_package("crate-b", root.join("crates/crate-b"));
238 let project = make_project(root.clone(), vec![pkg_a, pkg_b]);
239
240 let changed_files = vec![PathBuf::from("crates/crate-a/src/lib.rs")];
241 let root_config = RootChangesetConfig::default();
242 let package_configs = HashMap::new();
243
244 let mapping =
245 map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
246 let affected = mapping.affected_packages();
247
248 assert_eq!(affected.len(), 1);
249 assert_eq!(affected[0].name, "crate-a");
250 }
251
252 #[test]
253 fn empty_project_all_files_are_project_level() {
254 let root = PathBuf::from("/workspace");
255 let project = make_project(root.clone(), vec![]);
256
257 let changed_files = vec![PathBuf::from("src/lib.rs")];
258 let root_config = RootChangesetConfig::default();
259 let package_configs = HashMap::new();
260
261 let mapping =
262 map_files_to_packages(&project, &changed_files, &root_config, &package_configs);
263
264 assert!(mapping.package_files.is_empty());
265 assert_eq!(mapping.project_files.len(), 1);
266 }
267}