1use crate::types::{Bump, CrateInfo, DependencyUpdate, Workspace};
2use crate::{changeset::ChangesetInfo, config::Config};
3use std::collections::{BTreeMap, BTreeSet};
4
5pub fn format_dependency_updates_message(updates: &[DependencyUpdate]) -> Option<String> {
10 if updates.is_empty() {
11 return None;
12 }
13
14 let dep_list = updates
15 .iter()
16 .map(|dep| format!("{}@{}", dep.name, dep.new_version))
17 .collect::<Vec<_>>()
18 .join(", ");
19
20 Some(format!("Updated dependencies: {}", dep_list))
21}
22
23pub fn build_dependency_updates(updates: &[(String, String)]) -> Vec<DependencyUpdate> {
25 updates
26 .iter()
27 .map(|(name, version)| DependencyUpdate {
28 name: name.clone(),
29 new_version: version.clone(),
30 })
31 .collect()
32}
33
34pub fn create_dependency_update_entry(updates: &[DependencyUpdate]) -> Option<(String, Bump)> {
38 format_dependency_updates_message(updates).map(|msg| (msg, Bump::Patch))
39}
40
41pub fn create_fixed_dependency_policy_entry(bump: Bump) -> (String, Bump) {
45 (
46 "Bumped due to fixed dependency group policy".to_string(),
47 bump,
48 )
49}
50
51pub fn infer_bump_from_versions(old_ver: &str, new_ver: &str) -> Bump {
56 let old_parts: Vec<u32> = old_ver.split('.').filter_map(|s| s.parse().ok()).collect();
57 let new_parts: Vec<u32> = new_ver.split('.').filter_map(|s| s.parse().ok()).collect();
58
59 if old_parts.len() >= 3 && new_parts.len() >= 3 {
60 if new_parts[0] > old_parts[0] {
61 Bump::Major
62 } else if new_parts[1] > old_parts[1] {
63 Bump::Minor
64 } else {
65 Bump::Patch
66 }
67 } else {
68 Bump::Patch
69 }
70}
71
72pub fn detect_all_dependency_explanations(
88 changesets: &[ChangesetInfo],
89 workspace: &Workspace,
90 config: &Config,
91 releases: &BTreeMap<String, (String, String)>,
92) -> BTreeMap<String, Vec<(String, Bump)>> {
93 let mut messages_by_pkg: BTreeMap<String, Vec<(String, Bump)>> = BTreeMap::new();
94
95 let bumped_packages: BTreeSet<String> = releases.keys().cloned().collect();
97 let policy_packages =
98 detect_fixed_dependency_policy_packages(changesets, workspace, config, &bumped_packages);
99
100 for (pkg_name, policy_bump) in policy_packages {
101 let actual_bump = if let Some((old_ver, new_ver)) = releases.get(&pkg_name) {
103 infer_bump_from_versions(old_ver, new_ver)
104 } else {
105 policy_bump
106 };
107
108 let (msg, bump_type) = create_fixed_dependency_policy_entry(actual_bump);
109 messages_by_pkg
110 .entry(pkg_name)
111 .or_default()
112 .push((msg, bump_type));
113 }
114
115 let new_version_by_name: BTreeMap<String, String> = releases
120 .iter()
121 .map(|(name, (_old, new_ver))| (name.clone(), new_ver.clone()))
122 .collect();
123
124 let by_name: BTreeMap<String, &CrateInfo> = workspace
126 .members
127 .iter()
128 .map(|c| (c.name.clone(), c))
129 .collect();
130
131 for crate_name in releases.keys() {
133 if let Some(crate_info) = by_name.get(crate_name) {
134 let mut updated_deps = Vec::new();
136 for dep_name in &crate_info.internal_deps {
137 if let Some(new_version) = new_version_by_name.get(dep_name as &str) {
138 updated_deps.push((dep_name.clone(), new_version.clone()));
140 }
141 }
142
143 if !updated_deps.is_empty() {
144 let updates = build_dependency_updates(&updated_deps);
146 if let Some((msg, bump)) = create_dependency_update_entry(&updates) {
147 messages_by_pkg
148 .entry(crate_name.clone())
149 .or_default()
150 .push((msg, bump));
151 }
152 }
153 }
154 }
155
156 messages_by_pkg
157}
158
159pub fn detect_fixed_dependency_policy_packages(
165 changesets: &[ChangesetInfo],
166 workspace: &Workspace,
167 config: &Config,
168 bumped_packages: &BTreeSet<String>,
169) -> BTreeMap<String, Bump> {
170 let packages_with_changesets: BTreeSet<String> = changesets
172 .iter()
173 .flat_map(|cs| cs.packages.iter().cloned())
174 .collect();
175
176 let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
178 for crate_info in &workspace.members {
179 for dep_name in &crate_info.internal_deps {
180 dependents
181 .entry(dep_name.clone())
182 .or_default()
183 .insert(crate_info.name.clone());
184 }
185 }
186
187 let mut packages_affected_by_cascade = BTreeSet::new();
189 for pkg_with_changeset in &packages_with_changesets {
190 let mut queue = vec![pkg_with_changeset.clone()];
191 let mut visited = BTreeSet::new();
192
193 while let Some(pkg) = queue.pop() {
194 if visited.contains(&pkg) {
195 continue;
196 }
197 visited.insert(pkg.clone());
198
199 if let Some(deps) = dependents.get(&pkg) {
200 for dep in deps {
201 packages_affected_by_cascade.insert(dep.clone());
202 queue.push(dep.clone());
203 }
204 }
205 }
206 }
207
208 let mut result = BTreeMap::new();
210
211 for pkg_name in bumped_packages {
212 if packages_with_changesets.contains(pkg_name) {
214 continue;
215 }
216
217 if packages_affected_by_cascade.contains(pkg_name) {
219 continue;
220 }
221
222 for group in &config.fixed_dependencies {
224 if group.contains(&pkg_name.to_string()) {
225 let has_affected_group_member = group.iter().any(|group_member| {
227 group_member != pkg_name
228 && (packages_with_changesets.contains(group_member)
229 || packages_affected_by_cascade.contains(group_member))
230 });
231
232 if has_affected_group_member {
233 let group_bump = group
235 .iter()
236 .filter_map(|member| {
237 if packages_with_changesets.contains(member) {
238 changesets
240 .iter()
241 .filter(|cs| cs.packages.contains(member))
242 .map(|cs| cs.bump)
243 .max()
244 } else {
245 None
246 }
247 })
248 .max()
249 .unwrap_or(Bump::Patch);
250
251 result.insert(pkg_name.clone(), group_bump);
252 break;
253 }
254 }
255 }
256 }
257
258 result
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn formats_single_dependency_update() {
267 let updates = vec![DependencyUpdate {
268 name: "pkg1".to_string(),
269 new_version: "1.2.0".to_string(),
270 }];
271 let msg = format_dependency_updates_message(&updates).unwrap();
272 assert_eq!(msg, "Updated dependencies: pkg1@1.2.0");
273 }
274
275 #[test]
276 fn formats_multiple_dependency_updates() {
277 let updates = vec![
278 DependencyUpdate {
279 name: "pkg1".to_string(),
280 new_version: "1.2.0".to_string(),
281 },
282 DependencyUpdate {
283 name: "pkg2".to_string(),
284 new_version: "2.0.0".to_string(),
285 },
286 ];
287 let msg = format_dependency_updates_message(&updates).unwrap();
288 assert_eq!(msg, "Updated dependencies: pkg1@1.2.0, pkg2@2.0.0");
289 }
290
291 #[test]
292 fn returns_none_for_empty_updates() {
293 let updates = vec![];
294 let msg = format_dependency_updates_message(&updates);
295 assert_eq!(msg, None);
296 }
297
298 #[test]
299 fn builds_dependency_updates_from_tuples() {
300 let tuples = vec![
301 ("pkg1".to_string(), "1.2.0".to_string()),
302 ("pkg2".to_string(), "2.0.0".to_string()),
303 ];
304 let updates = build_dependency_updates(&tuples);
305 assert_eq!(updates.len(), 2);
306 assert_eq!(updates[0].name, "pkg1");
307 assert_eq!(updates[0].new_version, "1.2.0");
308 assert_eq!(updates[1].name, "pkg2");
309 assert_eq!(updates[1].new_version, "2.0.0");
310 }
311
312 #[test]
313 fn creates_dependency_update_entry() {
314 let updates = vec![DependencyUpdate {
315 name: "pkg1".to_string(),
316 new_version: "1.2.0".to_string(),
317 }];
318 let (msg, bump) = create_dependency_update_entry(&updates).unwrap();
319 assert_eq!(msg, "Updated dependencies: pkg1@1.2.0");
320 assert_eq!(bump, Bump::Patch);
321 }
322
323 #[test]
324 fn creates_fixed_dependency_policy_entry() {
325 let (msg, bump) = create_fixed_dependency_policy_entry(Bump::Major);
326 assert_eq!(msg, "Bumped due to fixed dependency group policy");
327 assert_eq!(bump, Bump::Major);
328
329 let (msg, bump) = create_fixed_dependency_policy_entry(Bump::Minor);
330 assert_eq!(msg, "Bumped due to fixed dependency group policy");
331 assert_eq!(bump, Bump::Minor);
332 }
333
334 #[test]
335 fn infers_bump_from_version_changes() {
336 assert_eq!(infer_bump_from_versions("1.0.0", "2.0.0"), Bump::Major);
337 assert_eq!(infer_bump_from_versions("1.0.0", "1.1.0"), Bump::Minor);
338 assert_eq!(infer_bump_from_versions("1.0.0", "1.0.1"), Bump::Patch);
339
340 assert_eq!(infer_bump_from_versions("0.1", "0.2"), Bump::Patch);
342 assert_eq!(infer_bump_from_versions("invalid", "1.0.0"), Bump::Patch);
343 }
344
345 #[test]
346 fn detect_all_dependency_explanations_comprehensive() {
347 use crate::types::{CrateInfo, Workspace};
348 use std::collections::BTreeSet;
349 use std::path::PathBuf;
350
351 let ws = Workspace {
353 root: PathBuf::from("/test"),
354 members: vec![
355 CrateInfo {
356 name: "pkg-a".to_string(),
357 version: "1.0.0".to_string(),
358 path: PathBuf::from("/test/pkg-a"),
359 internal_deps: BTreeSet::from(["pkg-b".to_string()]),
360 },
361 CrateInfo {
362 name: "pkg-b".to_string(),
363 version: "1.0.0".to_string(),
364 path: PathBuf::from("/test/pkg-b"),
365 internal_deps: BTreeSet::new(),
366 },
367 CrateInfo {
368 name: "pkg-c".to_string(),
369 version: "1.0.0".to_string(),
370 path: PathBuf::from("/test/pkg-c"),
371 internal_deps: BTreeSet::new(),
372 },
373 ],
374 };
375
376 let config = Config {
378 version: 1,
379 github_repository: None,
380 changelog_show_commit_hash: true,
381 changelog_show_acknowledgments: true,
382 fixed_dependencies: vec![vec!["pkg-a".to_string(), "pkg-c".to_string()]],
383 linked_dependencies: vec![],
384 };
385
386 let changesets = vec![ChangesetInfo {
388 packages: vec!["pkg-b".to_string()],
389 bump: Bump::Minor,
390 message: "feat: new feature".to_string(),
391 path: PathBuf::from("/test/.sampo/changesets/test.md"),
392 }];
393
394 let mut releases = BTreeMap::new();
396 releases.insert(
397 "pkg-a".to_string(),
398 ("1.0.0".to_string(), "1.1.0".to_string()),
399 );
400 releases.insert(
401 "pkg-b".to_string(),
402 ("1.0.0".to_string(), "1.1.0".to_string()),
403 );
404 releases.insert(
405 "pkg-c".to_string(),
406 ("1.0.0".to_string(), "1.1.0".to_string()),
407 );
408
409 let explanations = detect_all_dependency_explanations(&changesets, &ws, &config, &releases);
410
411 let pkg_a_messages = explanations.get("pkg-a").unwrap();
413 assert_eq!(pkg_a_messages.len(), 1);
414 assert!(
415 pkg_a_messages[0]
416 .0
417 .contains("Updated dependencies: pkg-b@1.1.0")
418 );
419 assert_eq!(pkg_a_messages[0].1, Bump::Patch);
420
421 let pkg_c_messages = explanations.get("pkg-c").unwrap();
423 assert_eq!(pkg_c_messages.len(), 1);
424 assert_eq!(
425 pkg_c_messages[0].0,
426 "Bumped due to fixed dependency group policy"
427 );
428 assert_eq!(pkg_c_messages[0].1, Bump::Minor); assert!(!explanations.contains_key("pkg-b"));
432 }
433
434 #[test]
435 fn detect_all_dependency_explanations_empty_cases() {
436 use crate::types::{CrateInfo, Workspace};
437 use std::collections::BTreeSet;
438 use std::path::PathBuf;
439
440 let ws = Workspace {
441 root: PathBuf::from("/test"),
442 members: vec![CrateInfo {
443 name: "pkg-a".to_string(),
444 version: "1.0.0".to_string(),
445 path: PathBuf::from("/test/pkg-a"),
446 internal_deps: BTreeSet::new(),
447 }],
448 };
449
450 let config = Config::default();
451 let changesets = vec![];
452 let releases = BTreeMap::new();
453
454 let explanations = detect_all_dependency_explanations(&changesets, &ws, &config, &releases);
455 assert!(explanations.is_empty());
456 }
457}