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::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}