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;
6
7use crate::config::{PackageChangesetConfig, RootChangesetConfig};
8use crate::project::CargoProject;
9
10/// Mapping of files to a single package.
11///
12/// This is a data transfer object with intentionally public fields for direct access.
13#[derive(Debug)]
14pub struct PackageFiles {
15    pub package: PackageInfo,
16    pub files: Vec<PathBuf>,
17}
18
19/// Result of mapping changed files to packages.
20///
21/// This is a data transfer object with intentionally public fields for direct access.
22#[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            // Fallback to full path if strip_prefix fails (shouldn't happen in practice)
61            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                    // Fallback to full path if strip_prefix fails (shouldn't happen in practice)
92                    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}