sampo_core/
release.rs

1use crate::filters::should_ignore_crate;
2use crate::types::{Bump, CrateInfo, DependencyUpdate, ReleaseOutput, ReleasedPackage, Workspace};
3use crate::{
4    changeset::ChangesetInfo, config::Config, detect_github_repo_slug_with_config,
5    discover_workspace, enrich_changeset_message, get_commit_hash_for_path, load_changesets,
6};
7use rustc_hash::FxHashSet;
8use std::collections::{BTreeMap, BTreeSet};
9use std::fs;
10use std::io;
11use std::path::Path;
12
13/// Format dependency updates for changelog display
14///
15/// Creates a message in the style of Changesets for dependency updates,
16/// e.g., "Updated dependencies [hash]: pkg1@1.2.0, pkg2@2.0.0"
17pub fn format_dependency_updates_message(updates: &[DependencyUpdate]) -> Option<String> {
18    if updates.is_empty() {
19        return None;
20    }
21
22    let dep_list = updates
23        .iter()
24        .map(|dep| format!("{}@{}", dep.name, dep.new_version))
25        .collect::<Vec<_>>()
26        .join(", ");
27
28    Some(format!("Updated dependencies: {}", dep_list))
29}
30
31/// Convert a list of (name, version) tuples into DependencyUpdate structs
32pub fn build_dependency_updates(updates: &[(String, String)]) -> Vec<DependencyUpdate> {
33    updates
34        .iter()
35        .map(|(name, version)| DependencyUpdate {
36            name: name.clone(),
37            new_version: version.clone(),
38        })
39        .collect()
40}
41
42/// Create a changelog entry for dependency updates
43///
44/// Returns a tuple of (message, bump_type) suitable for adding to changelog messages
45pub fn create_dependency_update_entry(updates: &[DependencyUpdate]) -> Option<(String, Bump)> {
46    format_dependency_updates_message(updates).map(|msg| (msg, Bump::Patch))
47}
48
49/// Create a changelog entry for fixed dependency group policy
50///
51/// Returns a tuple of (message, bump_type) suitable for adding to changelog messages
52pub fn create_fixed_dependency_policy_entry(bump: Bump) -> (String, Bump) {
53    (
54        "Bumped due to fixed dependency group policy".to_string(),
55        bump,
56    )
57}
58
59/// Infer bump type from version changes
60///
61/// This helper function determines the semantic version bump type based on
62/// the difference between old and new version strings.
63pub fn infer_bump_from_versions(old_ver: &str, new_ver: &str) -> Bump {
64    let old_parts: Vec<u32> = old_ver.split('.').filter_map(|s| s.parse().ok()).collect();
65    let new_parts: Vec<u32> = new_ver.split('.').filter_map(|s| s.parse().ok()).collect();
66
67    if old_parts.len() >= 3 && new_parts.len() >= 3 {
68        if new_parts[0] > old_parts[0] {
69            Bump::Major
70        } else if new_parts[1] > old_parts[1] {
71            Bump::Minor
72        } else {
73            Bump::Patch
74        }
75    } else {
76        Bump::Patch
77    }
78}
79
80/// Detect all dependency-related explanations for package releases
81///
82/// This function is the unified entry point for detecting all types of automatic
83/// dependency-related changelog entries. It identifies:
84/// - Packages bumped due to internal dependency updates ("Updated dependencies: ...")
85/// - Packages bumped due to fixed dependency group policy ("Bumped due to fixed dependency group policy")
86///
87/// # Arguments
88/// * `changesets` - The changesets being processed
89/// * `workspace` - The workspace containing all packages
90/// * `config` - The configuration with dependency policies
91/// * `releases` - Map of package name to (old_version, new_version) for all planned releases
92///
93/// # Returns
94/// A map of package name to list of (message, bump_type) explanations to add to changelogs
95pub fn detect_all_dependency_explanations(
96    changesets: &[ChangesetInfo],
97    workspace: &Workspace,
98    config: &Config,
99    releases: &BTreeMap<String, (String, String)>,
100) -> BTreeMap<String, Vec<(String, Bump)>> {
101    let mut messages_by_pkg: BTreeMap<String, Vec<(String, Bump)>> = BTreeMap::new();
102
103    // 1. Detect packages bumped due to fixed dependency group policy
104    let bumped_packages: BTreeSet<String> = releases.keys().cloned().collect();
105    let policy_packages =
106        detect_fixed_dependency_policy_packages(changesets, workspace, config, &bumped_packages);
107
108    for (pkg_name, policy_bump) in policy_packages {
109        // For accurate bump detection, infer from actual version changes
110        let actual_bump = if let Some((old_ver, new_ver)) = releases.get(&pkg_name) {
111            infer_bump_from_versions(old_ver, new_ver)
112        } else {
113            policy_bump
114        };
115
116        let (msg, bump_type) = create_fixed_dependency_policy_entry(actual_bump);
117        messages_by_pkg
118            .entry(pkg_name)
119            .or_default()
120            .push((msg, bump_type));
121    }
122
123    // 2. Detect packages bumped due to internal dependency updates
124    // Note: Even packages with explicit changesets can have dependency updates
125
126    // Build new version lookup from releases
127    let new_version_by_name: BTreeMap<String, String> = releases
128        .iter()
129        .map(|(name, (_old, new_ver))| (name.clone(), new_ver.clone()))
130        .collect();
131
132    // Build map of crate name -> CrateInfo for quick lookup (only non-ignored packages)
133    let by_name: BTreeMap<String, &CrateInfo> = workspace
134        .members
135        .iter()
136        .filter(|c| !should_ignore_crate(config, workspace, c).unwrap_or(false))
137        .map(|c| (c.name.clone(), c))
138        .collect();
139
140    // For each released crate, check if it has internal dependencies that were updated
141    for crate_name in releases.keys() {
142        if let Some(crate_info) = by_name.get(crate_name) {
143            // Find which internal dependencies were updated
144            let mut updated_deps = Vec::new();
145            for dep_name in &crate_info.internal_deps {
146                if let Some(new_version) = new_version_by_name.get(dep_name as &str) {
147                    // This internal dependency was updated
148                    updated_deps.push((dep_name.clone(), new_version.clone()));
149                }
150            }
151
152            if !updated_deps.is_empty() {
153                // Create dependency update entry
154                let updates = build_dependency_updates(&updated_deps);
155                if let Some((msg, bump)) = create_dependency_update_entry(&updates) {
156                    messages_by_pkg
157                        .entry(crate_name.clone())
158                        .or_default()
159                        .push((msg, bump));
160                }
161            }
162        }
163    }
164
165    messages_by_pkg
166}
167
168/// Detect packages that need fixed dependency group policy messages
169///
170/// This function identifies packages that were bumped solely due to fixed dependency
171/// group policies (not due to direct changesets or normal dependency cascades).
172/// Returns a map of package name to the bump level they received.
173pub fn detect_fixed_dependency_policy_packages(
174    changesets: &[ChangesetInfo],
175    workspace: &Workspace,
176    config: &Config,
177    bumped_packages: &BTreeSet<String>,
178) -> BTreeMap<String, Bump> {
179    // Build set of packages with direct changesets
180    let packages_with_changesets: BTreeSet<String> = changesets
181        .iter()
182        .flat_map(|cs| cs.packages.iter().cloned())
183        .collect();
184
185    // Build dependency graph (dependent -> set of dependencies) - only non-ignored packages
186    let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
187    for crate_info in &workspace.members {
188        // Skip ignored packages when building the dependency graph
189        if should_ignore_crate(config, workspace, crate_info).unwrap_or(false) {
190            continue;
191        }
192
193        for dep_name in &crate_info.internal_deps {
194            dependents
195                .entry(dep_name.clone())
196                .or_default()
197                .insert(crate_info.name.clone());
198        }
199    }
200
201    // Find packages affected by normal dependency cascade
202    let mut packages_affected_by_cascade = BTreeSet::new();
203    for pkg_with_changeset in &packages_with_changesets {
204        let mut queue = vec![pkg_with_changeset.clone()];
205        let mut visited = BTreeSet::new();
206
207        while let Some(pkg) = queue.pop() {
208            if visited.contains(&pkg) {
209                continue;
210            }
211            visited.insert(pkg.clone());
212
213            if let Some(deps) = dependents.get(&pkg) {
214                for dep in deps {
215                    packages_affected_by_cascade.insert(dep.clone());
216                    queue.push(dep.clone());
217                }
218            }
219        }
220    }
221
222    // Find packages that need fixed dependency policy messages
223    let mut result = BTreeMap::new();
224
225    for pkg_name in bumped_packages {
226        // Skip if package has direct changeset
227        if packages_with_changesets.contains(pkg_name) {
228            continue;
229        }
230
231        // Skip if package is affected by normal dependency cascade
232        if packages_affected_by_cascade.contains(pkg_name) {
233            continue;
234        }
235
236        // Check if this package is in a fixed dependency group with an affected package
237        for group in &config.fixed_dependencies {
238            if group.contains(&pkg_name.to_string()) {
239                // Check if any other package in this group has changes
240                let has_affected_group_member = group.iter().any(|group_member| {
241                    group_member != pkg_name
242                        && (packages_with_changesets.contains(group_member)
243                            || packages_affected_by_cascade.contains(group_member))
244                });
245
246                if has_affected_group_member {
247                    // Find the highest bump level in the group to determine the policy bump
248                    let group_bump = group
249                        .iter()
250                        .filter_map(|member| {
251                            if packages_with_changesets.contains(member) {
252                                // Find the highest bump from changesets affecting this member
253                                changesets
254                                    .iter()
255                                    .filter(|cs| cs.packages.contains(member))
256                                    .map(|cs| cs.bump)
257                                    .max()
258                            } else {
259                                None
260                            }
261                        })
262                        .max()
263                        .unwrap_or(Bump::Patch);
264
265                    result.insert(pkg_name.clone(), group_bump);
266                    break;
267                }
268            }
269        }
270    }
271
272    result
273}
274
275/// Type alias for initial bumps computation result
276type InitialBumpsResult = (
277    BTreeMap<String, Bump>,                // bump_by_pkg
278    BTreeMap<String, Vec<(String, Bump)>>, // messages_by_pkg
279    BTreeSet<std::path::PathBuf>,          // used_paths
280);
281
282/// Type alias for release plan
283type ReleasePlan = Vec<(String, String, String)>; // (name, old_version, new_version)
284
285/// Main release function that can be called from CLI or other interfaces
286pub fn run_release(root: &std::path::Path, dry_run: bool) -> io::Result<ReleaseOutput> {
287    let workspace = discover_workspace(root).map_err(io::Error::other)?;
288    let config = Config::load(&workspace.root).map_err(io::Error::other)?;
289
290    // Validate fixed dependencies configuration
291    validate_fixed_dependencies(&config, &workspace).map_err(io::Error::other)?;
292
293    let changesets_dir = workspace.root.join(".sampo").join("changesets");
294    let changesets = load_changesets(&changesets_dir)?;
295    if changesets.is_empty() {
296        println!(
297            "No changesets found in {}",
298            workspace.root.join(".sampo").join("changesets").display()
299        );
300        return Ok(ReleaseOutput {
301            released_packages: vec![],
302            dry_run,
303        });
304    }
305
306    // Compute initial bumps from changesets
307    let (mut bump_by_pkg, mut messages_by_pkg, used_paths) =
308        compute_initial_bumps(&changesets, &workspace, &config)?;
309
310    if bump_by_pkg.is_empty() {
311        println!("No applicable packages found in changesets.");
312        return Ok(ReleaseOutput {
313            released_packages: vec![],
314            dry_run,
315        });
316    }
317
318    // Build dependency graph and apply cascading logic
319    let dependents = build_dependency_graph(&workspace, &config);
320    apply_dependency_cascade(&mut bump_by_pkg, &dependents, &config, &workspace);
321    apply_linked_dependencies(&mut bump_by_pkg, &config);
322
323    // Prepare and validate release plan
324    let releases = prepare_release_plan(&bump_by_pkg, &workspace)?;
325    if releases.is_empty() {
326        println!("No matching workspace crates to release.");
327        return Ok(ReleaseOutput {
328            released_packages: vec![],
329            dry_run,
330        });
331    }
332
333    print_release_plan(&releases);
334
335    // Convert releases to ReleasedPackage structs
336    let released_packages: Vec<ReleasedPackage> = releases
337        .iter()
338        .map(|(name, old_version, new_version)| {
339            let bump = bump_by_pkg.get(name).copied().unwrap_or(Bump::Patch);
340            ReleasedPackage {
341                name: name.clone(),
342                old_version: old_version.clone(),
343                new_version: new_version.clone(),
344                bump,
345            }
346        })
347        .collect();
348
349    if dry_run {
350        println!("Dry-run: no files modified, no tags created.");
351        return Ok(ReleaseOutput {
352            released_packages,
353            dry_run: true,
354        });
355    }
356
357    // Apply changes
358    apply_releases(
359        &releases,
360        &workspace,
361        &mut messages_by_pkg,
362        &changesets,
363        &config,
364    )?;
365
366    // Clean up
367    cleanup_consumed_changesets(used_paths)?;
368
369    Ok(ReleaseOutput {
370        released_packages,
371        dry_run: false,
372    })
373}
374
375/// Compute initial bumps from changesets and collect messages
376fn compute_initial_bumps(
377    changesets: &[ChangesetInfo],
378    ws: &Workspace,
379    cfg: &Config,
380) -> io::Result<InitialBumpsResult> {
381    let mut bump_by_pkg: BTreeMap<String, Bump> = BTreeMap::new();
382    let mut messages_by_pkg: BTreeMap<String, Vec<(String, Bump)>> = BTreeMap::new();
383    let mut used_paths: BTreeSet<std::path::PathBuf> = BTreeSet::new();
384
385    // Resolve GitHub repo slug once if available (config, env or origin remote)
386    let repo_slug = detect_github_repo_slug_with_config(&ws.root, cfg.github_repository.as_deref());
387    let github_token = std::env::var("GITHUB_TOKEN")
388        .ok()
389        .or_else(|| std::env::var("GH_TOKEN").ok());
390
391    // Build quick lookup for crate info
392    let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
393    for c in &ws.members {
394        by_name.insert(c.name.clone(), c);
395    }
396
397    for cs in changesets {
398        let mut consumed_changeset = false;
399        for pkg in &cs.packages {
400            if let Some(info) = by_name.get(pkg)
401                && should_ignore_crate(cfg, ws, info)?
402            {
403                continue;
404            }
405
406            // Mark this changeset as consumed since at least one package is applicable
407            consumed_changeset = true;
408
409            bump_by_pkg
410                .entry(pkg.clone())
411                .and_modify(|b| {
412                    if cs.bump > *b {
413                        *b = cs.bump;
414                    }
415                })
416                .or_insert(cs.bump);
417
418            // Enrich message with commit info and acknowledgments
419            let commit_hash = get_commit_hash_for_path(&ws.root, &cs.path);
420            let enriched = if let Some(hash) = commit_hash {
421                enrich_changeset_message(
422                    &cs.message,
423                    &hash,
424                    &ws.root,
425                    repo_slug.as_deref(),
426                    github_token.as_deref(),
427                    cfg.changelog_show_commit_hash,
428                    cfg.changelog_show_acknowledgments,
429                )
430            } else {
431                cs.message.clone()
432            };
433
434            messages_by_pkg
435                .entry(pkg.clone())
436                .or_default()
437                .push((enriched, cs.bump));
438        }
439        if consumed_changeset {
440            used_paths.insert(cs.path.clone());
441        }
442    }
443
444    Ok((bump_by_pkg, messages_by_pkg, used_paths))
445}
446
447/// Build reverse dependency graph: dep -> set of dependents
448/// Only includes non-ignored packages in the graph
449fn build_dependency_graph(ws: &Workspace, cfg: &Config) -> BTreeMap<String, BTreeSet<String>> {
450    let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
451
452    // Build a set of ignored package names for quick lookup
453    let ignored_packages: BTreeSet<String> = ws
454        .members
455        .iter()
456        .filter(|c| should_ignore_crate(cfg, ws, c).unwrap_or(false))
457        .map(|c| c.name.clone())
458        .collect();
459
460    for c in &ws.members {
461        // Skip ignored packages when building the dependency graph
462        if ignored_packages.contains(&c.name) {
463            continue;
464        }
465
466        for dep in &c.internal_deps {
467            // Also skip dependencies that point to ignored packages
468            if ignored_packages.contains(dep) {
469                continue;
470            }
471
472            dependents
473                .entry(dep.clone())
474                .or_default()
475                .insert(c.name.clone());
476        }
477    }
478    dependents
479}
480
481/// Apply dependency cascade logic and fixed dependency groups
482fn apply_dependency_cascade(
483    bump_by_pkg: &mut BTreeMap<String, Bump>,
484    dependents: &BTreeMap<String, BTreeSet<String>>,
485    cfg: &Config,
486    ws: &Workspace,
487) {
488    // Helper function to find which fixed group a package belongs to, if any
489    let find_fixed_group = |pkg_name: &str| -> Option<usize> {
490        cfg.fixed_dependencies
491            .iter()
492            .position(|group| group.contains(&pkg_name.to_string()))
493    };
494
495    // Build a quick lookup map for crate info
496    let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
497    for c in &ws.members {
498        by_name.insert(c.name.clone(), c);
499    }
500
501    let mut queue: Vec<String> = bump_by_pkg.keys().cloned().collect();
502    let mut seen: BTreeSet<String> = queue.iter().cloned().collect();
503
504    while let Some(changed) = queue.pop() {
505        let changed_bump = bump_by_pkg.get(&changed).copied().unwrap_or(Bump::Patch);
506
507        // 1. Handle normal dependency relationships (unchanged → dependent)
508        if let Some(deps) = dependents.get(&changed) {
509            for dep_name in deps {
510                // Check if this dependent package should be ignored
511                if let Some(info) = by_name.get(dep_name) {
512                    match should_ignore_crate(cfg, ws, info) {
513                        Ok(true) => continue,
514                        Ok(false) => {} // Continue processing
515                        Err(_) => {
516                            // On I/O error reading manifest, err on the side of not ignoring
517                            // This maintains backwards compatibility and avoids silent failures
518                        }
519                    }
520                }
521
522                // Determine bump level for this dependent
523                let dependent_bump = if find_fixed_group(dep_name).is_some() {
524                    // Fixed dependencies: same bump level as the dependency
525                    changed_bump
526                } else {
527                    // Normal dependencies: at least patch
528                    Bump::Patch
529                };
530
531                let entry = bump_by_pkg
532                    .entry(dep_name.clone())
533                    .or_insert(dependent_bump);
534                // If already present, keep the higher bump
535                if *entry < dependent_bump {
536                    *entry = dependent_bump;
537                }
538                if !seen.contains(dep_name) {
539                    queue.push(dep_name.clone());
540                    seen.insert(dep_name.clone());
541                }
542            }
543        }
544
545        // 2. Handle fixed dependency groups (bidirectional)
546        if let Some(group_idx) = find_fixed_group(&changed) {
547            // All packages in the same fixed group should bump together
548            for group_member in &cfg.fixed_dependencies[group_idx] {
549                if group_member != &changed {
550                    // Check if this group member should be ignored
551                    if let Some(info) = by_name.get(group_member) {
552                        match should_ignore_crate(cfg, ws, info) {
553                            Ok(true) => continue,
554                            Ok(false) => {} // Continue processing
555                            Err(_) => {
556                                // On I/O error reading manifest, err on the side of not ignoring
557                                // This maintains backwards compatibility and avoids silent failures
558                            }
559                        }
560                    }
561
562                    let entry = bump_by_pkg
563                        .entry(group_member.clone())
564                        .or_insert(changed_bump);
565                    // If already present, keep the higher bump
566                    if *entry < changed_bump {
567                        *entry = changed_bump;
568                    }
569                    if !seen.contains(group_member) {
570                        queue.push(group_member.clone());
571                        seen.insert(group_member.clone());
572                    }
573                }
574            }
575        }
576    }
577}
578
579/// Apply linked dependencies logic: highest bump level to affected packages only
580fn apply_linked_dependencies(bump_by_pkg: &mut BTreeMap<String, Bump>, cfg: &Config) {
581    for group in &cfg.linked_dependencies {
582        // Check if any package in this group has been bumped
583        let mut group_has_bumps = false;
584        let mut highest_bump = Bump::Patch;
585
586        // First pass: find the highest bump level in the group among affected packages
587        for group_member in group {
588            if let Some(&member_bump) = bump_by_pkg.get(group_member) {
589                group_has_bumps = true;
590                if member_bump > highest_bump {
591                    highest_bump = member_bump;
592                }
593            }
594        }
595
596        // If any package in the group is being bumped, apply highest bump to affected packages only
597        if group_has_bumps {
598            // Apply the highest bump level to packages that are already being bumped
599            // (either directly affected or through dependency cascade)
600            for group_member in group {
601                if bump_by_pkg.contains_key(group_member) {
602                    // Only update if the current bump is lower than the group's highest bump
603                    let current_bump = bump_by_pkg
604                        .get(group_member)
605                        .copied()
606                        .unwrap_or(Bump::Patch);
607                    if highest_bump > current_bump {
608                        bump_by_pkg.insert(group_member.clone(), highest_bump);
609                    }
610                }
611            }
612        }
613    }
614}
615
616/// Prepare the release plan by matching bumps to workspace members
617fn prepare_release_plan(
618    bump_by_pkg: &BTreeMap<String, Bump>,
619    ws: &Workspace,
620) -> io::Result<ReleasePlan> {
621    // Map crate name -> CrateInfo for quick lookup
622    let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
623    for c in &ws.members {
624        by_name.insert(c.name.clone(), c);
625    }
626
627    let mut releases: Vec<(String, String, String)> = Vec::new(); // (name, old_version, new_version)
628    for (name, bump) in bump_by_pkg {
629        if let Some(info) = by_name.get(name) {
630            let old = if info.version.is_empty() {
631                "0.0.0".to_string()
632            } else {
633                info.version.clone()
634            };
635
636            let newv = bump_version(&old, *bump).unwrap_or_else(|_| old.clone());
637
638            releases.push((name.clone(), old, newv));
639        }
640    }
641
642    Ok(releases)
643}
644
645/// Print the planned releases
646fn print_release_plan(releases: &ReleasePlan) {
647    println!("Planned releases:");
648    for (name, old, newv) in releases {
649        println!("  {name}: {old} -> {newv}");
650    }
651}
652
653/// Apply all releases: update manifests and changelogs
654fn apply_releases(
655    releases: &ReleasePlan,
656    ws: &Workspace,
657    messages_by_pkg: &mut BTreeMap<String, Vec<(String, Bump)>>,
658    changesets: &[ChangesetInfo],
659    cfg: &Config,
660) -> io::Result<()> {
661    // Build lookup maps
662    let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
663    for c in &ws.members {
664        by_name.insert(c.name.clone(), c);
665    }
666
667    let mut new_version_by_name: BTreeMap<String, String> = BTreeMap::new();
668    for (name, _old, newv) in releases {
669        new_version_by_name.insert(name.clone(), newv.clone());
670    }
671
672    // Build releases map for dependency explanations
673    let releases_map: BTreeMap<String, (String, String)> = releases
674        .iter()
675        .map(|(name, old, new)| (name.clone(), (old.clone(), new.clone())))
676        .collect();
677
678    // Use unified function to detect all dependency explanations
679    let dependency_explanations =
680        detect_all_dependency_explanations(changesets, ws, cfg, &releases_map);
681
682    // Merge dependency explanations into existing messages
683    for (pkg_name, explanations) in dependency_explanations {
684        messages_by_pkg
685            .entry(pkg_name)
686            .or_default()
687            .extend(explanations);
688    }
689
690    // Apply updates for each release
691    for (name, old, newv) in releases {
692        let info = by_name.get(name.as_str()).unwrap();
693        let manifest_path = info.path.join("Cargo.toml");
694        let text = fs::read_to_string(&manifest_path)?;
695
696        // Update manifest versions
697        let (updated, _dep_updates) =
698            update_manifest_versions(&text, Some(newv.as_str()), ws, &new_version_by_name)?;
699        fs::write(&manifest_path, updated)?;
700
701        let messages = messages_by_pkg.get(name).cloned().unwrap_or_default();
702        update_changelog(&info.path, name, old, newv, &messages)?;
703    }
704
705    Ok(())
706}
707
708/// Clean up consumed changeset files
709fn cleanup_consumed_changesets(used_paths: BTreeSet<std::path::PathBuf>) -> io::Result<()> {
710    for p in used_paths {
711        let _ = fs::remove_file(p);
712    }
713    println!("Removed consumed changesets.");
714    Ok(())
715}
716
717/// Bump a semver version string
718pub fn bump_version(old: &str, bump: Bump) -> Result<String, String> {
719    let mut parts = old
720        .split('.')
721        .map(|s| s.parse::<u64>().unwrap_or(0))
722        .collect::<Vec<_>>();
723    while parts.len() < 3 {
724        parts.push(0);
725    }
726    let (maj, min, pat) = (parts[0], parts[1], parts[2]);
727    let (maj, min, pat) = match bump {
728        Bump::Patch => (maj, min, pat + 1),
729        Bump::Minor => (maj, min + 1, 0),
730        Bump::Major => (maj + 1, 0, 0),
731    };
732    Ok(format!("{maj}.{min}.{pat}"))
733}
734
735/// Update a crate manifest, setting the crate version (if provided) and retargeting
736/// internal dependency version requirements to the latest planned versions.
737/// Returns the updated TOML string along with a list of (dep_name, new_version) applied.
738///
739/// This implementation preserves the original formatting and only modifies the necessary lines
740/// to avoid unwanted reformatting of the entire Cargo.toml file.
741pub fn update_manifest_versions(
742    input: &str,
743    new_pkg_version: Option<&str>,
744    ws: &Workspace,
745    new_version_by_name: &BTreeMap<String, String>,
746) -> io::Result<(String, Vec<(String, String)>)> {
747    let mut result = input.to_string();
748    let mut applied: Vec<(String, String)> = Vec::new();
749
750    // Update package version if provided
751    if let Some(new_version) = new_pkg_version {
752        result = update_package_version(&result, new_version)?;
753    }
754
755    // Update internal workspace dependency versions
756    let workspace_crates: BTreeSet<String> = ws.members.iter().map(|c| c.name.clone()).collect();
757    for (crate_name, new_version) in new_version_by_name {
758        if workspace_crates.contains(crate_name) {
759            let (updated_result, was_updated) =
760                update_dependency_version(&result, crate_name, new_version)?;
761            result = updated_result;
762            if was_updated {
763                applied.push((crate_name.clone(), new_version.clone()));
764            }
765        }
766    }
767
768    Ok((result, applied))
769}
770
771/// Update the package version in the [package] section
772fn update_package_version(input: &str, new_version: &str) -> io::Result<String> {
773    let lines: Vec<&str> = input.lines().collect();
774    let mut result_lines = Vec::new();
775    let mut in_package_section = false;
776
777    for line in lines {
778        let trimmed = line.trim();
779
780        // Check if we're entering the [package] section
781        if trimmed == "[package]" {
782            in_package_section = true;
783            result_lines.push(line.to_string());
784            continue;
785        }
786
787        // Check if we're leaving the [package] section
788        if in_package_section && trimmed.starts_with('[') && trimmed != "[package]" {
789            in_package_section = false;
790        }
791
792        // Update version line if we're in the [package] section
793        if in_package_section && trimmed.starts_with("version") {
794            // Extract the indentation and update the version
795            let indent = line.len() - line.trim_start().len();
796            let spaces = " ".repeat(indent);
797
798            // Try to preserve the original quoting style
799            result_lines.push(format!("{}version = \"{}\"", spaces, new_version));
800        } else {
801            result_lines.push(line.to_string());
802        }
803    }
804
805    Ok(result_lines.join("\n"))
806}
807
808/// Update the version of a specific dependency across all dependency sections
809fn update_dependency_version(
810    input: &str,
811    dep_name: &str,
812    new_version: &str,
813) -> io::Result<(String, bool)> {
814    let lines: Vec<&str> = input.lines().collect();
815    let mut result_lines = Vec::new();
816    let mut was_updated = false;
817    let mut current_section: Option<String> = None;
818
819    for line in lines {
820        let trimmed = line.trim();
821
822        // Track which section we're in
823        if trimmed.starts_with('[') && trimmed.ends_with(']') {
824            let section_name = trimmed.trim_start_matches('[').trim_end_matches(']');
825            if section_name.starts_with("dependencies")
826                || section_name.starts_with("dev-dependencies")
827                || section_name.starts_with("build-dependencies")
828            {
829                current_section = Some(section_name.to_string());
830            } else {
831                current_section = None;
832            }
833            result_lines.push(line.to_string());
834            continue;
835        }
836
837        // Check if this line defines our target dependency
838        if current_section.is_some() {
839            if trimmed.starts_with(&format!("{} =", dep_name))
840                || trimmed.starts_with(&format!("\"{}\" =", dep_name))
841            {
842                // This is a dependency line for our target crate
843                let indent = line.len() - line.trim_start().len();
844                let spaces = " ".repeat(indent);
845
846                if trimmed.contains("workspace = true") || trimmed.contains("workspace=true") {
847                    // Workspace-based dependency - leave unchanged
848                    result_lines.push(line.to_string());
849                } else if trimmed.contains("{ path =") || trimmed.contains("{path =") {
850                    // It's a table format with path - preserve path, update version
851                    let updated_line = update_dependency_table_line(line, new_version);
852                    result_lines.push(updated_line);
853                    was_updated = true;
854                } else if trimmed.contains("version =") {
855                    // It's already a table format or inline table
856                    let updated_line = update_dependency_table_line(line, new_version);
857                    result_lines.push(updated_line);
858                    was_updated = true;
859                } else {
860                    // It's likely a simple string version, convert to table with version
861                    result_lines.push(format!(
862                        "{}{} = {{ version = \"{}\" }}",
863                        spaces, dep_name, new_version
864                    ));
865                    was_updated = true;
866                }
867            } else {
868                result_lines.push(line.to_string());
869            }
870        } else {
871            result_lines.push(line.to_string());
872        }
873    }
874
875    Ok((result_lines.join("\n"), was_updated))
876}
877
878/// Update the version field in a dependency table line
879fn update_dependency_table_line(line: &str, new_version: &str) -> String {
880    // Try to update version field if it exists
881    if let Some(version_start) = line.find("version") {
882        let after_version = &line[version_start..];
883        if let Some(equals_pos) = after_version.find('=') {
884            let before_equals = &line[..version_start + equals_pos + 1];
885            let after_equals = &after_version[equals_pos + 1..];
886
887            // Find the quoted string after equals
888            if let Some(quote_start) = after_equals.find('"') {
889                let after_first_quote = &after_equals[quote_start + 1..];
890                if let Some(quote_end) = after_first_quote.find('"') {
891                    let after_second_quote = &after_first_quote[quote_end + 1..];
892                    return format!(
893                        "{} \"{}\"{}",
894                        before_equals, new_version, after_second_quote
895                    );
896                }
897            }
898        }
899    }
900
901    // If no version field exists but it's a table, add version field
902    if line.contains("{ path =") {
903        // Single-line table with path, add version
904        if let Some(closing_brace_pos) = line.rfind('}') {
905            let before_brace = &line[..closing_brace_pos];
906            let after_brace = &line[closing_brace_pos..];
907            return format!(
908                "{}, version = \"{}\" {}",
909                before_brace, new_version, after_brace
910            );
911        }
912    }
913
914    line.to_string()
915}
916
917fn update_changelog(
918    crate_dir: &Path,
919    package: &str,
920    old_version: &str,
921    new_version: &str,
922    entries: &[(String, Bump)],
923) -> io::Result<()> {
924    let path = crate_dir.join("CHANGELOG.md");
925    let existing = if path.exists() {
926        fs::read_to_string(&path)?
927    } else {
928        String::new()
929    };
930    let mut body = existing.trim_start_matches('\u{feff}').to_string();
931    // Remove existing top package header if present
932    let package_header = format!("# {}", package);
933    if body.starts_with(&package_header) {
934        if let Some(idx) = body.find('\n') {
935            body = body[idx + 1..].to_string();
936        } else {
937            body.clear();
938        }
939    }
940
941    // Parse and merge the current top section only if it's an unpublished section.
942    // Heuristic: if the top section header equals the current (old) version, it is published
943    // and must be preserved. Otherwise, treat it as in-progress and merge its bullets.
944    let mut merged_major: Vec<String> = Vec::new();
945    let mut merged_minor: Vec<String> = Vec::new();
946    let mut merged_patch: Vec<String> = Vec::new();
947
948    // helper to push without duplicates (preserve append order)
949    let push_unique = |list: &mut Vec<String>, msg: &str| {
950        if !list.iter().any(|m| m == msg) {
951            list.push(msg.to_string());
952        }
953    };
954
955    // Collect new entries
956    for (msg, bump) in entries {
957        match bump {
958            Bump::Major => push_unique(&mut merged_major, msg),
959            Bump::Minor => push_unique(&mut merged_minor, msg),
960            Bump::Patch => push_unique(&mut merged_patch, msg),
961        }
962    }
963
964    // If body starts with a previous top section (## ...), inspect its header.
965    // If header == old_version => preserve it (do not merge or strip).
966    // Else => parse and merge its bullets, then strip that section.
967    let trimmed = body.trim_start();
968    if trimmed.starts_with("## ") {
969        // Extract first header line text
970        let mut lines_iter = trimmed.lines();
971        let header_line = lines_iter.next().unwrap_or("").trim();
972        let header_text = header_line.trim_start_matches("## ").trim();
973
974        let is_published_top = header_text == old_version;
975
976        if !is_published_top {
977            // Determine the extent of the first section in 'trimmed'
978            let after_header_offset = header_line.len();
979            let rest_after_header = &trimmed[after_header_offset..];
980            // Find next section marker starting at a new line
981            let next_rel = rest_after_header.find("\n## ");
982            let (section_text, remaining) = match next_rel {
983                Some(pos) => {
984                    let end = after_header_offset + pos + 1; // include leading newline
985                    (&trimmed[..end], &trimmed[end..])
986                }
987                None => (trimmed, ""),
988            };
989
990            let mut current = None::<&str>;
991            for line in section_text.lines() {
992                let t = line.trim();
993                if t.eq_ignore_ascii_case("### Major changes") {
994                    current = Some("major");
995                    continue;
996                } else if t.eq_ignore_ascii_case("### Minor changes") {
997                    current = Some("minor");
998                    continue;
999                } else if t.eq_ignore_ascii_case("### Patch changes") {
1000                    current = Some("patch");
1001                    continue;
1002                }
1003                if t.starts_with("- ") {
1004                    let msg = t.trim_start_matches("- ").trim();
1005                    match current {
1006                        Some("major") => push_unique(&mut merged_major, msg),
1007                        Some("minor") => push_unique(&mut merged_minor, msg),
1008                        Some("patch") => push_unique(&mut merged_patch, msg),
1009                        _ => {}
1010                    }
1011                }
1012            }
1013
1014            body = remaining.to_string();
1015        }
1016    }
1017
1018    // Build new aggregated top section
1019    let mut section = String::new();
1020    section.push_str(&format!("# {}\n\n", package));
1021    section.push_str(&format!("## {}\n\n", new_version));
1022
1023    if !merged_major.is_empty() {
1024        section.push_str("### Major changes\n\n");
1025        for msg in &merged_major {
1026            section.push_str(&crate::markdown::format_markdown_list_item(msg));
1027        }
1028        section.push('\n');
1029    }
1030    if !merged_minor.is_empty() {
1031        section.push_str("### Minor changes\n\n");
1032        for msg in &merged_minor {
1033            section.push_str(&crate::markdown::format_markdown_list_item(msg));
1034        }
1035        section.push('\n');
1036    }
1037    if !merged_patch.is_empty() {
1038        section.push_str("### Patch changes\n\n");
1039        for msg in &merged_patch {
1040            section.push_str(&crate::markdown::format_markdown_list_item(msg));
1041        }
1042        section.push('\n');
1043    }
1044
1045    let combined = if body.trim().is_empty() {
1046        section
1047    } else {
1048        format!("{}{}", section, body)
1049    };
1050    fs::write(&path, combined)
1051}
1052
1053/// Validate fixed dependencies configuration against the workspace
1054fn validate_fixed_dependencies(config: &Config, workspace: &Workspace) -> Result<(), String> {
1055    let workspace_packages: FxHashSet<String> =
1056        workspace.members.iter().map(|c| c.name.clone()).collect();
1057
1058    for (group_idx, group) in config.fixed_dependencies.iter().enumerate() {
1059        for package in group {
1060            if !workspace_packages.contains(package) {
1061                let available_packages: Vec<String> = workspace_packages.iter().cloned().collect();
1062                return Err(format!(
1063                    "Package '{}' in fixed dependency group {} does not exist in the workspace. Available packages: [{}]",
1064                    package,
1065                    group_idx + 1,
1066                    available_packages.join(", ")
1067                ));
1068            }
1069        }
1070    }
1071    Ok(())
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077
1078    #[test]
1079    fn skips_workspace_dependencies_when_updating() {
1080        let input = "[dependencies]\nfoo = { workspace = true, optional = true }\n";
1081        let (out, changed) = update_dependency_version(input, "foo", "1.2.3").unwrap();
1082        assert_eq!(out.trim_end(), input.trim_end());
1083        assert!(!changed);
1084    }
1085
1086    #[test]
1087    fn converts_simple_dep_without_quotes() {
1088        let input = "[dependencies]\nbar = \"0.1.0\"\n";
1089        let (out, changed) = update_dependency_version(input, "bar", "0.2.0").unwrap();
1090        assert!(changed);
1091        assert!(out.contains("bar = { version = \"0.2.0\" }"));
1092        assert!(!out.contains("\"bar\""));
1093    }
1094
1095    #[test]
1096    fn test_ignore_packages_in_dependency_cascade() {
1097        use crate::types::{CrateInfo, Workspace};
1098        use std::path::PathBuf;
1099
1100        // Create a mock workspace with packages
1101        let root = PathBuf::from("/tmp/test");
1102        let workspace = Workspace {
1103            root: root.clone(),
1104            members: vec![
1105                CrateInfo {
1106                    name: "main-package".to_string(),
1107                    version: "1.0.0".to_string(),
1108                    path: root.join("main-package"),
1109                    internal_deps: BTreeSet::new(),
1110                },
1111                CrateInfo {
1112                    name: "examples-package".to_string(),
1113                    version: "1.0.0".to_string(),
1114                    path: root.join("examples/package"),
1115                    internal_deps: BTreeSet::new(),
1116                },
1117                CrateInfo {
1118                    name: "benchmarks-utils".to_string(),
1119                    version: "1.0.0".to_string(),
1120                    path: root.join("benchmarks/utils"),
1121                    internal_deps: BTreeSet::new(),
1122                },
1123            ],
1124        };
1125
1126        // Create a config that ignores examples/* and benchmarks/*
1127        let config = Config {
1128            ignore: vec!["examples/*".to_string(), "benchmarks/*".to_string()],
1129            ..Default::default()
1130        };
1131
1132        // Create a dependency graph where main-package depends on the ignored packages
1133        let mut dependents = BTreeMap::new();
1134        dependents.insert(
1135            "main-package".to_string(),
1136            ["examples-package", "benchmarks-utils"]
1137                .iter()
1138                .map(|s| s.to_string())
1139                .collect(),
1140        );
1141
1142        // Start with main-package being bumped
1143        let mut bump_by_pkg = BTreeMap::new();
1144        bump_by_pkg.insert("main-package".to_string(), Bump::Minor);
1145
1146        // Apply dependency cascade
1147        apply_dependency_cascade(&mut bump_by_pkg, &dependents, &config, &workspace);
1148
1149        // The ignored packages should NOT be added to bump_by_pkg
1150        assert_eq!(bump_by_pkg.len(), 1);
1151        assert!(bump_by_pkg.contains_key("main-package"));
1152        assert!(!bump_by_pkg.contains_key("examples-package"));
1153        assert!(!bump_by_pkg.contains_key("benchmarks-utils"));
1154    }
1155
1156    #[test]
1157    fn test_ignored_packages_excluded_from_dependency_graph() {
1158        use crate::types::{CrateInfo, Workspace};
1159        use std::collections::BTreeSet;
1160        use std::path::PathBuf;
1161
1162        let root = PathBuf::from("/tmp/test");
1163        let workspace = Workspace {
1164            root: root.clone(),
1165            members: vec![
1166                CrateInfo {
1167                    name: "main-package".to_string(),
1168                    version: "1.0.0".to_string(),
1169                    path: root.join("main-package"),
1170                    internal_deps: ["examples-package".to_string()].into_iter().collect(),
1171                },
1172                CrateInfo {
1173                    name: "examples-package".to_string(),
1174                    version: "1.0.0".to_string(),
1175                    path: root.join("examples/package"),
1176                    internal_deps: BTreeSet::new(),
1177                },
1178            ],
1179        };
1180
1181        // Config that ignores examples/*
1182        let config = Config {
1183            ignore: vec!["examples/*".to_string()],
1184            ..Default::default()
1185        };
1186
1187        // Build dependency graph
1188        let dependents = build_dependency_graph(&workspace, &config);
1189
1190        // examples-package should not appear in the dependency graph because it's ignored
1191        // So main-package should not appear as a dependent of examples-package
1192        assert!(!dependents.contains_key("examples-package"));
1193
1194        // The dependency graph should be empty since examples-package is ignored
1195        // and main-package depends on it
1196        assert!(dependents.is_empty());
1197    }
1198}