sampo_core/
release.rs

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