sampo_core/
release.rs

1use crate::types::{Bump, CrateInfo, DependencyUpdate, Workspace};
2use crate::{changeset::ChangesetInfo, config::Config};
3use std::collections::{BTreeMap, BTreeSet};
4
5/// Format dependency updates for changelog display
6///
7/// Creates a message in the style of Changesets for dependency updates,
8/// e.g., "Updated dependencies [hash]: pkg1@1.2.0, pkg2@2.0.0"
9pub 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
23/// Convert a list of (name, version) tuples into DependencyUpdate structs
24pub 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
34/// Create a changelog entry for dependency updates
35///
36/// Returns a tuple of (message, bump_type) suitable for adding to changelog messages
37pub fn create_dependency_update_entry(updates: &[DependencyUpdate]) -> Option<(String, Bump)> {
38    format_dependency_updates_message(updates).map(|msg| (msg, Bump::Patch))
39}
40
41/// Create a changelog entry for fixed dependency group policy
42///
43/// Returns a tuple of (message, bump_type) suitable for adding to changelog messages
44pub 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
51/// Infer bump type from version changes
52///
53/// This helper function determines the semantic version bump type based on
54/// the difference between old and new version strings.
55pub 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
72/// Detect all dependency-related explanations for package releases
73///
74/// This function is the unified entry point for detecting all types of automatic
75/// dependency-related changelog entries. It identifies:
76/// - Packages bumped due to internal dependency updates ("Updated dependencies: ...")
77/// - Packages bumped due to fixed dependency group policy ("Bumped due to fixed dependency group policy")
78///
79/// # Arguments
80/// * `changesets` - The changesets being processed
81/// * `workspace` - The workspace containing all packages
82/// * `config` - The configuration with dependency policies
83/// * `releases` - Map of package name to (old_version, new_version) for all planned releases
84///
85/// # Returns
86/// A map of package name to list of (message, bump_type) explanations to add to changelogs
87pub 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    // 1. Detect packages bumped due to fixed dependency group policy
96    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        // For accurate bump detection, infer from actual version changes
102        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    // 2. Detect packages bumped due to internal dependency updates
116    // Note: Even packages with explicit changesets can have dependency updates
117
118    // Build new version lookup from releases
119    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    // Build map of crate name -> CrateInfo for quick lookup
125    let by_name: BTreeMap<String, &CrateInfo> = workspace
126        .members
127        .iter()
128        .map(|c| (c.name.clone(), c))
129        .collect();
130
131    // For each released crate, check if it has internal dependencies that were updated
132    for crate_name in releases.keys() {
133        if let Some(crate_info) = by_name.get(crate_name) {
134            // Find which internal dependencies were updated
135            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                    // This internal dependency was updated
139                    updated_deps.push((dep_name.clone(), new_version.clone()));
140                }
141            }
142
143            if !updated_deps.is_empty() {
144                // Create dependency update entry
145                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
159/// Detect packages that need fixed dependency group policy messages
160///
161/// This function identifies packages that were bumped solely due to fixed dependency
162/// group policies (not due to direct changesets or normal dependency cascades).
163/// Returns a map of package name to the bump level they received.
164pub 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    // Build set of packages with direct changesets
171    let packages_with_changesets: BTreeSet<String> = changesets
172        .iter()
173        .flat_map(|cs| cs.packages.iter().cloned())
174        .collect();
175
176    // Build dependency graph (dependent -> set of dependencies)
177    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    // Find packages affected by normal dependency cascade
188    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    // Find packages that need fixed dependency policy messages
209    let mut result = BTreeMap::new();
210
211    for pkg_name in bumped_packages {
212        // Skip if package has direct changeset
213        if packages_with_changesets.contains(pkg_name) {
214            continue;
215        }
216
217        // Skip if package is affected by normal dependency cascade
218        if packages_affected_by_cascade.contains(pkg_name) {
219            continue;
220        }
221
222        // Check if this package is in a fixed dependency group with an affected package
223        for group in &config.fixed_dependencies {
224            if group.contains(&pkg_name.to_string()) {
225                // Check if any other package in this group has changes
226                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                    // Find the highest bump level in the group to determine the policy bump
234                    let group_bump = group
235                        .iter()
236                        .filter_map(|member| {
237                            if packages_with_changesets.contains(member) {
238                                // Find the highest bump from changesets affecting this member
239                                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        // Edge cases
341        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        // Create test workspace with dependencies
352        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        // Create config with fixed dependencies
377        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        // Create changeset that affects pkg-b only
387        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        // Simulate releases: pkg-a and pkg-c get fixed bump, pkg-b gets direct bump
395        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        // pkg-a should have dependency update message (depends on pkg-b)
412        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        // pkg-c should have fixed dependency policy message (no deps but in fixed group)
422        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); // Inferred from version change
429
430        // pkg-b should have no messages (explicit changeset)
431        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}