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
133    let by_name: BTreeMap<String, &CrateInfo> = workspace
134        .members
135        .iter()
136        .map(|c| (c.name.clone(), c))
137        .collect();
138
139    // For each released crate, check if it has internal dependencies that were updated
140    for crate_name in releases.keys() {
141        if let Some(crate_info) = by_name.get(crate_name) {
142            // Find which internal dependencies were updated
143            let mut updated_deps = Vec::new();
144            for dep_name in &crate_info.internal_deps {
145                if let Some(new_version) = new_version_by_name.get(dep_name as &str) {
146                    // This internal dependency was updated
147                    updated_deps.push((dep_name.clone(), new_version.clone()));
148                }
149            }
150
151            if !updated_deps.is_empty() {
152                // Create dependency update entry
153                let updates = build_dependency_updates(&updated_deps);
154                if let Some((msg, bump)) = create_dependency_update_entry(&updates) {
155                    messages_by_pkg
156                        .entry(crate_name.clone())
157                        .or_default()
158                        .push((msg, bump));
159                }
160            }
161        }
162    }
163
164    messages_by_pkg
165}
166
167/// Detect packages that need fixed dependency group policy messages
168///
169/// This function identifies packages that were bumped solely due to fixed dependency
170/// group policies (not due to direct changesets or normal dependency cascades).
171/// Returns a map of package name to the bump level they received.
172pub fn detect_fixed_dependency_policy_packages(
173    changesets: &[ChangesetInfo],
174    workspace: &Workspace,
175    config: &Config,
176    bumped_packages: &BTreeSet<String>,
177) -> BTreeMap<String, Bump> {
178    // Build set of packages with direct changesets
179    let packages_with_changesets: BTreeSet<String> = changesets
180        .iter()
181        .flat_map(|cs| cs.packages.iter().cloned())
182        .collect();
183
184    // Build dependency graph (dependent -> set of dependencies)
185    let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
186    for crate_info in &workspace.members {
187        for dep_name in &crate_info.internal_deps {
188            dependents
189                .entry(dep_name.clone())
190                .or_default()
191                .insert(crate_info.name.clone());
192        }
193    }
194
195    // Find packages affected by normal dependency cascade
196    let mut packages_affected_by_cascade = BTreeSet::new();
197    for pkg_with_changeset in &packages_with_changesets {
198        let mut queue = vec![pkg_with_changeset.clone()];
199        let mut visited = BTreeSet::new();
200
201        while let Some(pkg) = queue.pop() {
202            if visited.contains(&pkg) {
203                continue;
204            }
205            visited.insert(pkg.clone());
206
207            if let Some(deps) = dependents.get(&pkg) {
208                for dep in deps {
209                    packages_affected_by_cascade.insert(dep.clone());
210                    queue.push(dep.clone());
211                }
212            }
213        }
214    }
215
216    // Find packages that need fixed dependency policy messages
217    let mut result = BTreeMap::new();
218
219    for pkg_name in bumped_packages {
220        // Skip if package has direct changeset
221        if packages_with_changesets.contains(pkg_name) {
222            continue;
223        }
224
225        // Skip if package is affected by normal dependency cascade
226        if packages_affected_by_cascade.contains(pkg_name) {
227            continue;
228        }
229
230        // Check if this package is in a fixed dependency group with an affected package
231        for group in &config.fixed_dependencies {
232            if group.contains(&pkg_name.to_string()) {
233                // Check if any other package in this group has changes
234                let has_affected_group_member = group.iter().any(|group_member| {
235                    group_member != pkg_name
236                        && (packages_with_changesets.contains(group_member)
237                            || packages_affected_by_cascade.contains(group_member))
238                });
239
240                if has_affected_group_member {
241                    // Find the highest bump level in the group to determine the policy bump
242                    let group_bump = group
243                        .iter()
244                        .filter_map(|member| {
245                            if packages_with_changesets.contains(member) {
246                                // Find the highest bump from changesets affecting this member
247                                changesets
248                                    .iter()
249                                    .filter(|cs| cs.packages.contains(member))
250                                    .map(|cs| cs.bump)
251                                    .max()
252                            } else {
253                                None
254                            }
255                        })
256                        .max()
257                        .unwrap_or(Bump::Patch);
258
259                    result.insert(pkg_name.clone(), group_bump);
260                    break;
261                }
262            }
263        }
264    }
265
266    result
267}
268
269/// Type alias for initial bumps computation result
270type InitialBumpsResult = (
271    BTreeMap<String, Bump>,                // bump_by_pkg
272    BTreeMap<String, Vec<(String, Bump)>>, // messages_by_pkg
273    BTreeSet<std::path::PathBuf>,          // used_paths
274);
275
276/// Type alias for release plan
277type ReleasePlan = Vec<(String, String, String)>; // (name, old_version, new_version)
278
279/// Main release function that can be called from CLI or other interfaces
280pub fn run_release(root: &std::path::Path, dry_run: bool) -> io::Result<ReleaseOutput> {
281    let workspace = discover_workspace(root).map_err(io::Error::other)?;
282    let config = Config::load(&workspace.root).map_err(io::Error::other)?;
283
284    // Validate fixed dependencies configuration
285    validate_fixed_dependencies(&config, &workspace).map_err(io::Error::other)?;
286
287    let changesets_dir = workspace.root.join(".sampo").join("changesets");
288    let changesets = load_changesets(&changesets_dir)?;
289    if changesets.is_empty() {
290        println!(
291            "No changesets found in {}",
292            workspace.root.join(".sampo").join("changesets").display()
293        );
294        return Ok(ReleaseOutput {
295            released_packages: vec![],
296            dry_run,
297        });
298    }
299
300    // Compute initial bumps from changesets
301    let (mut bump_by_pkg, mut messages_by_pkg, used_paths) =
302        compute_initial_bumps(&changesets, &workspace, &config)?;
303
304    if bump_by_pkg.is_empty() {
305        println!("No applicable packages found in changesets.");
306        return Ok(ReleaseOutput {
307            released_packages: vec![],
308            dry_run,
309        });
310    }
311
312    // Build dependency graph and apply cascading logic
313    let dependents = build_dependency_graph(&workspace);
314    apply_dependency_cascade(&mut bump_by_pkg, &dependents, &config);
315    apply_linked_dependencies(&mut bump_by_pkg, &config);
316
317    // Prepare and validate release plan
318    let releases = prepare_release_plan(&bump_by_pkg, &workspace)?;
319    if releases.is_empty() {
320        println!("No matching workspace crates to release.");
321        return Ok(ReleaseOutput {
322            released_packages: vec![],
323            dry_run,
324        });
325    }
326
327    print_release_plan(&releases);
328
329    // Convert releases to ReleasedPackage structs
330    let released_packages: Vec<ReleasedPackage> = releases
331        .iter()
332        .map(|(name, old_version, new_version)| {
333            let bump = bump_by_pkg.get(name).copied().unwrap_or(Bump::Patch);
334            ReleasedPackage {
335                name: name.clone(),
336                old_version: old_version.clone(),
337                new_version: new_version.clone(),
338                bump,
339            }
340        })
341        .collect();
342
343    if dry_run {
344        println!("Dry-run: no files modified, no tags created.");
345        return Ok(ReleaseOutput {
346            released_packages,
347            dry_run: true,
348        });
349    }
350
351    // Apply changes
352    apply_releases(
353        &releases,
354        &workspace,
355        &mut messages_by_pkg,
356        &changesets,
357        &config,
358    )?;
359
360    // Clean up
361    cleanup_consumed_changesets(used_paths)?;
362
363    Ok(ReleaseOutput {
364        released_packages,
365        dry_run: false,
366    })
367}
368
369/// Compute initial bumps from changesets and collect messages
370fn compute_initial_bumps(
371    changesets: &[ChangesetInfo],
372    ws: &Workspace,
373    cfg: &Config,
374) -> io::Result<InitialBumpsResult> {
375    let mut bump_by_pkg: BTreeMap<String, Bump> = BTreeMap::new();
376    let mut messages_by_pkg: BTreeMap<String, Vec<(String, Bump)>> = BTreeMap::new();
377    let mut used_paths: BTreeSet<std::path::PathBuf> = BTreeSet::new();
378
379    // Resolve GitHub repo slug once if available (config, env or origin remote)
380    let repo_slug = detect_github_repo_slug_with_config(&ws.root, cfg.github_repository.as_deref());
381    let github_token = std::env::var("GITHUB_TOKEN")
382        .ok()
383        .or_else(|| std::env::var("GH_TOKEN").ok());
384
385    // Build quick lookup for crate info
386    let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
387    for c in &ws.members {
388        by_name.insert(c.name.clone(), c);
389    }
390
391    for cs in changesets {
392        let mut consumed_changeset = false;
393        for pkg in &cs.packages {
394            if let Some(info) = by_name.get(pkg)
395                && should_ignore_crate(cfg, ws, info)?
396            {
397                continue;
398            }
399
400            // Mark this changeset as consumed since at least one package is applicable
401            consumed_changeset = true;
402
403            bump_by_pkg
404                .entry(pkg.clone())
405                .and_modify(|b| {
406                    if cs.bump > *b {
407                        *b = cs.bump;
408                    }
409                })
410                .or_insert(cs.bump);
411
412            // Enrich message with commit info and acknowledgments
413            let commit_hash = get_commit_hash_for_path(&ws.root, &cs.path);
414            let enriched = if let Some(hash) = commit_hash {
415                enrich_changeset_message(
416                    &cs.message,
417                    &hash,
418                    &ws.root,
419                    repo_slug.as_deref(),
420                    github_token.as_deref(),
421                    cfg.changelog_show_commit_hash,
422                    cfg.changelog_show_acknowledgments,
423                )
424            } else {
425                cs.message.clone()
426            };
427
428            messages_by_pkg
429                .entry(pkg.clone())
430                .or_default()
431                .push((enriched, cs.bump));
432        }
433        if consumed_changeset {
434            used_paths.insert(cs.path.clone());
435        }
436    }
437
438    Ok((bump_by_pkg, messages_by_pkg, used_paths))
439}
440
441/// Build reverse dependency graph: dep -> set of dependents
442fn build_dependency_graph(ws: &Workspace) -> BTreeMap<String, BTreeSet<String>> {
443    let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
444    for c in &ws.members {
445        for dep in &c.internal_deps {
446            dependents
447                .entry(dep.clone())
448                .or_default()
449                .insert(c.name.clone());
450        }
451    }
452    dependents
453}
454
455/// Apply dependency cascade logic and fixed dependency groups
456fn apply_dependency_cascade(
457    bump_by_pkg: &mut BTreeMap<String, Bump>,
458    dependents: &BTreeMap<String, BTreeSet<String>>,
459    cfg: &Config,
460) {
461    // Helper function to find which fixed group a package belongs to, if any
462    let find_fixed_group = |pkg_name: &str| -> Option<usize> {
463        cfg.fixed_dependencies
464            .iter()
465            .position(|group| group.contains(&pkg_name.to_string()))
466    };
467
468    let mut queue: Vec<String> = bump_by_pkg.keys().cloned().collect();
469    let mut seen: BTreeSet<String> = queue.iter().cloned().collect();
470
471    while let Some(changed) = queue.pop() {
472        let changed_bump = bump_by_pkg.get(&changed).copied().unwrap_or(Bump::Patch);
473
474        // 1. Handle normal dependency relationships (unchanged → dependent)
475        if let Some(deps) = dependents.get(&changed) {
476            for dep_name in deps {
477                // Determine bump level for this dependent
478                let dependent_bump = if find_fixed_group(dep_name).is_some() {
479                    // Fixed dependencies: same bump level as the dependency
480                    changed_bump
481                } else {
482                    // Normal dependencies: at least patch
483                    Bump::Patch
484                };
485
486                let entry = bump_by_pkg
487                    .entry(dep_name.clone())
488                    .or_insert(dependent_bump);
489                // If already present, keep the higher bump
490                if *entry < dependent_bump {
491                    *entry = dependent_bump;
492                }
493                if !seen.contains(dep_name) {
494                    queue.push(dep_name.clone());
495                    seen.insert(dep_name.clone());
496                }
497            }
498        }
499
500        // 2. Handle fixed dependency groups (bidirectional)
501        if let Some(group_idx) = find_fixed_group(&changed) {
502            // All packages in the same fixed group should bump together
503            for group_member in &cfg.fixed_dependencies[group_idx] {
504                if group_member != &changed {
505                    let entry = bump_by_pkg
506                        .entry(group_member.clone())
507                        .or_insert(changed_bump);
508                    // If already present, keep the higher bump
509                    if *entry < changed_bump {
510                        *entry = changed_bump;
511                    }
512                    if !seen.contains(group_member) {
513                        queue.push(group_member.clone());
514                        seen.insert(group_member.clone());
515                    }
516                }
517            }
518        }
519    }
520}
521
522/// Apply linked dependencies logic: highest bump level to affected packages only
523fn apply_linked_dependencies(bump_by_pkg: &mut BTreeMap<String, Bump>, cfg: &Config) {
524    for group in &cfg.linked_dependencies {
525        // Check if any package in this group has been bumped
526        let mut group_has_bumps = false;
527        let mut highest_bump = Bump::Patch;
528
529        // First pass: find the highest bump level in the group among affected packages
530        for group_member in group {
531            if let Some(&member_bump) = bump_by_pkg.get(group_member) {
532                group_has_bumps = true;
533                if member_bump > highest_bump {
534                    highest_bump = member_bump;
535                }
536            }
537        }
538
539        // If any package in the group is being bumped, apply highest bump to affected packages only
540        if group_has_bumps {
541            // Apply the highest bump level to packages that are already being bumped
542            // (either directly affected or through dependency cascade)
543            for group_member in group {
544                if bump_by_pkg.contains_key(group_member) {
545                    // Only update if the current bump is lower than the group's highest bump
546                    let current_bump = bump_by_pkg
547                        .get(group_member)
548                        .copied()
549                        .unwrap_or(Bump::Patch);
550                    if highest_bump > current_bump {
551                        bump_by_pkg.insert(group_member.clone(), highest_bump);
552                    }
553                }
554            }
555        }
556    }
557}
558
559/// Prepare the release plan by matching bumps to workspace members
560fn prepare_release_plan(
561    bump_by_pkg: &BTreeMap<String, Bump>,
562    ws: &Workspace,
563) -> io::Result<ReleasePlan> {
564    // Map crate name -> CrateInfo for quick lookup
565    let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
566    for c in &ws.members {
567        by_name.insert(c.name.clone(), c);
568    }
569
570    let mut releases: Vec<(String, String, String)> = Vec::new(); // (name, old_version, new_version)
571    for (name, bump) in bump_by_pkg {
572        if let Some(info) = by_name.get(name) {
573            let old = if info.version.is_empty() {
574                "0.0.0".to_string()
575            } else {
576                info.version.clone()
577            };
578
579            let newv = bump_version(&old, *bump).unwrap_or_else(|_| old.clone());
580
581            releases.push((name.clone(), old, newv));
582        }
583    }
584
585    Ok(releases)
586}
587
588/// Print the planned releases
589fn print_release_plan(releases: &ReleasePlan) {
590    println!("Planned releases:");
591    for (name, old, newv) in releases {
592        println!("  {name}: {old} -> {newv}");
593    }
594}
595
596/// Apply all releases: update manifests and changelogs
597fn apply_releases(
598    releases: &ReleasePlan,
599    ws: &Workspace,
600    messages_by_pkg: &mut BTreeMap<String, Vec<(String, Bump)>>,
601    changesets: &[ChangesetInfo],
602    cfg: &Config,
603) -> io::Result<()> {
604    // Build lookup maps
605    let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
606    for c in &ws.members {
607        by_name.insert(c.name.clone(), c);
608    }
609
610    let mut new_version_by_name: BTreeMap<String, String> = BTreeMap::new();
611    for (name, _old, newv) in releases {
612        new_version_by_name.insert(name.clone(), newv.clone());
613    }
614
615    // Build releases map for dependency explanations
616    let releases_map: BTreeMap<String, (String, String)> = releases
617        .iter()
618        .map(|(name, old, new)| (name.clone(), (old.clone(), new.clone())))
619        .collect();
620
621    // Use unified function to detect all dependency explanations
622    let dependency_explanations =
623        detect_all_dependency_explanations(changesets, ws, cfg, &releases_map);
624
625    // Merge dependency explanations into existing messages
626    for (pkg_name, explanations) in dependency_explanations {
627        messages_by_pkg
628            .entry(pkg_name)
629            .or_default()
630            .extend(explanations);
631    }
632
633    // Apply updates for each release
634    for (name, old, newv) in releases {
635        let info = by_name.get(name.as_str()).unwrap();
636        let manifest_path = info.path.join("Cargo.toml");
637        let text = fs::read_to_string(&manifest_path)?;
638
639        // Update manifest versions
640        let (updated, _dep_updates) =
641            update_manifest_versions(&text, Some(newv.as_str()), ws, &new_version_by_name)?;
642        fs::write(&manifest_path, updated)?;
643
644        let messages = messages_by_pkg.get(name).cloned().unwrap_or_default();
645        update_changelog(&info.path, name, old, newv, &messages)?;
646    }
647
648    Ok(())
649}
650
651/// Clean up consumed changeset files
652fn cleanup_consumed_changesets(used_paths: BTreeSet<std::path::PathBuf>) -> io::Result<()> {
653    for p in used_paths {
654        let _ = fs::remove_file(p);
655    }
656    println!("Removed consumed changesets.");
657    Ok(())
658}
659
660/// Bump a semver version string
661pub fn bump_version(old: &str, bump: Bump) -> Result<String, String> {
662    let mut parts = old
663        .split('.')
664        .map(|s| s.parse::<u64>().unwrap_or(0))
665        .collect::<Vec<_>>();
666    while parts.len() < 3 {
667        parts.push(0);
668    }
669    let (maj, min, pat) = (parts[0], parts[1], parts[2]);
670    let (maj, min, pat) = match bump {
671        Bump::Patch => (maj, min, pat + 1),
672        Bump::Minor => (maj, min + 1, 0),
673        Bump::Major => (maj + 1, 0, 0),
674    };
675    Ok(format!("{maj}.{min}.{pat}"))
676}
677
678/// Update a crate manifest, setting the crate version (if provided) and retargeting
679/// internal dependency version requirements to the latest planned versions.
680/// Returns the updated TOML string along with a list of (dep_name, new_version) applied.
681///
682/// This implementation preserves the original formatting and only modifies the necessary lines
683/// to avoid unwanted reformatting of the entire Cargo.toml file.
684pub fn update_manifest_versions(
685    input: &str,
686    new_pkg_version: Option<&str>,
687    ws: &Workspace,
688    new_version_by_name: &BTreeMap<String, String>,
689) -> io::Result<(String, Vec<(String, String)>)> {
690    let mut result = input.to_string();
691    let mut applied: Vec<(String, String)> = Vec::new();
692
693    // Update package version if provided
694    if let Some(new_version) = new_pkg_version {
695        result = update_package_version(&result, new_version)?;
696    }
697
698    // Update internal workspace dependency versions
699    let workspace_crates: BTreeSet<String> = ws.members.iter().map(|c| c.name.clone()).collect();
700    for (crate_name, new_version) in new_version_by_name {
701        if workspace_crates.contains(crate_name) {
702            let (updated_result, was_updated) =
703                update_dependency_version(&result, crate_name, new_version)?;
704            result = updated_result;
705            if was_updated {
706                applied.push((crate_name.clone(), new_version.clone()));
707            }
708        }
709    }
710
711    Ok((result, applied))
712}
713
714/// Update the package version in the [package] section
715fn update_package_version(input: &str, new_version: &str) -> io::Result<String> {
716    let lines: Vec<&str> = input.lines().collect();
717    let mut result_lines = Vec::new();
718    let mut in_package_section = false;
719
720    for line in lines {
721        let trimmed = line.trim();
722
723        // Check if we're entering the [package] section
724        if trimmed == "[package]" {
725            in_package_section = true;
726            result_lines.push(line.to_string());
727            continue;
728        }
729
730        // Check if we're leaving the [package] section
731        if in_package_section && trimmed.starts_with('[') && trimmed != "[package]" {
732            in_package_section = false;
733        }
734
735        // Update version line if we're in the [package] section
736        if in_package_section && trimmed.starts_with("version") {
737            // Extract the indentation and update the version
738            let indent = line.len() - line.trim_start().len();
739            let spaces = " ".repeat(indent);
740
741            // Try to preserve the original quoting style
742            result_lines.push(format!("{}version = \"{}\"", spaces, new_version));
743        } else {
744            result_lines.push(line.to_string());
745        }
746    }
747
748    Ok(result_lines.join("\n"))
749}
750
751/// Update the version of a specific dependency across all dependency sections
752fn update_dependency_version(
753    input: &str,
754    dep_name: &str,
755    new_version: &str,
756) -> io::Result<(String, bool)> {
757    let lines: Vec<&str> = input.lines().collect();
758    let mut result_lines = Vec::new();
759    let mut was_updated = false;
760    let mut current_section: Option<String> = None;
761
762    for line in lines {
763        let trimmed = line.trim();
764
765        // Track which section we're in
766        if trimmed.starts_with('[') && trimmed.ends_with(']') {
767            let section_name = trimmed.trim_start_matches('[').trim_end_matches(']');
768            if section_name.starts_with("dependencies")
769                || section_name.starts_with("dev-dependencies")
770                || section_name.starts_with("build-dependencies")
771            {
772                current_section = Some(section_name.to_string());
773            } else {
774                current_section = None;
775            }
776            result_lines.push(line.to_string());
777            continue;
778        }
779
780        // Check if this line defines our target dependency
781        if current_section.is_some() {
782            if trimmed.starts_with(&format!("{} =", dep_name))
783                || trimmed.starts_with(&format!("\"{}\" =", dep_name))
784            {
785                // This is a dependency line for our target crate
786                let indent = line.len() - line.trim_start().len();
787                let spaces = " ".repeat(indent);
788
789                if trimmed.contains("workspace = true") || trimmed.contains("workspace=true") {
790                    // Workspace-based dependency - leave unchanged
791                    result_lines.push(line.to_string());
792                } else if trimmed.contains("{ path =") || trimmed.contains("{path =") {
793                    // It's a table format with path - preserve path, update version
794                    let updated_line = update_dependency_table_line(line, new_version);
795                    result_lines.push(updated_line);
796                    was_updated = true;
797                } else if trimmed.contains("version =") {
798                    // It's already a table format or inline table
799                    let updated_line = update_dependency_table_line(line, new_version);
800                    result_lines.push(updated_line);
801                    was_updated = true;
802                } else {
803                    // It's likely a simple string version, convert to table with version
804                    result_lines.push(format!(
805                        "{}{} = {{ version = \"{}\" }}",
806                        spaces, dep_name, new_version
807                    ));
808                    was_updated = true;
809                }
810            } else {
811                result_lines.push(line.to_string());
812            }
813        } else {
814            result_lines.push(line.to_string());
815        }
816    }
817
818    Ok((result_lines.join("\n"), was_updated))
819}
820
821/// Update the version field in a dependency table line
822fn update_dependency_table_line(line: &str, new_version: &str) -> String {
823    // Try to update version field if it exists
824    if let Some(version_start) = line.find("version") {
825        let after_version = &line[version_start..];
826        if let Some(equals_pos) = after_version.find('=') {
827            let before_equals = &line[..version_start + equals_pos + 1];
828            let after_equals = &after_version[equals_pos + 1..];
829
830            // Find the quoted string after equals
831            if let Some(quote_start) = after_equals.find('"') {
832                let after_first_quote = &after_equals[quote_start + 1..];
833                if let Some(quote_end) = after_first_quote.find('"') {
834                    let after_second_quote = &after_first_quote[quote_end + 1..];
835                    return format!(
836                        "{} \"{}\"{}",
837                        before_equals, new_version, after_second_quote
838                    );
839                }
840            }
841        }
842    }
843
844    // If no version field exists but it's a table, add version field
845    if line.contains("{ path =") {
846        // Single-line table with path, add version
847        if let Some(closing_brace_pos) = line.rfind('}') {
848            let before_brace = &line[..closing_brace_pos];
849            let after_brace = &line[closing_brace_pos..];
850            return format!(
851                "{}, version = \"{}\" {}",
852                before_brace, new_version, after_brace
853            );
854        }
855    }
856
857    line.to_string()
858}
859
860fn update_changelog(
861    crate_dir: &Path,
862    package: &str,
863    old_version: &str,
864    new_version: &str,
865    entries: &[(String, Bump)],
866) -> io::Result<()> {
867    let path = crate_dir.join("CHANGELOG.md");
868    let existing = if path.exists() {
869        fs::read_to_string(&path)?
870    } else {
871        String::new()
872    };
873    let mut body = existing.trim_start_matches('\u{feff}').to_string();
874    // Remove existing top package header if present
875    let package_header = format!("# {}", package);
876    if body.starts_with(&package_header) {
877        if let Some(idx) = body.find('\n') {
878            body = body[idx + 1..].to_string();
879        } else {
880            body.clear();
881        }
882    }
883
884    // Parse and merge the current top section only if it's an unpublished section.
885    // Heuristic: if the top section header equals the current (old) version, it is published
886    // and must be preserved. Otherwise, treat it as in-progress and merge its bullets.
887    let mut merged_major: Vec<String> = Vec::new();
888    let mut merged_minor: Vec<String> = Vec::new();
889    let mut merged_patch: Vec<String> = Vec::new();
890
891    // helper to push without duplicates (preserve append order)
892    let push_unique = |list: &mut Vec<String>, msg: &str| {
893        if !list.iter().any(|m| m == msg) {
894            list.push(msg.to_string());
895        }
896    };
897
898    // Collect new entries
899    for (msg, bump) in entries {
900        match bump {
901            Bump::Major => push_unique(&mut merged_major, msg),
902            Bump::Minor => push_unique(&mut merged_minor, msg),
903            Bump::Patch => push_unique(&mut merged_patch, msg),
904        }
905    }
906
907    // If body starts with a previous top section (## ...), inspect its header.
908    // If header == old_version => preserve it (do not merge or strip).
909    // Else => parse and merge its bullets, then strip that section.
910    let trimmed = body.trim_start();
911    if trimmed.starts_with("## ") {
912        // Extract first header line text
913        let mut lines_iter = trimmed.lines();
914        let header_line = lines_iter.next().unwrap_or("").trim();
915        let header_text = header_line.trim_start_matches("## ").trim();
916
917        let is_published_top = header_text == old_version;
918
919        if !is_published_top {
920            // Determine the extent of the first section in 'trimmed'
921            let after_header_offset = header_line.len();
922            let rest_after_header = &trimmed[after_header_offset..];
923            // Find next section marker starting at a new line
924            let next_rel = rest_after_header.find("\n## ");
925            let (section_text, remaining) = match next_rel {
926                Some(pos) => {
927                    let end = after_header_offset + pos + 1; // include leading newline
928                    (&trimmed[..end], &trimmed[end..])
929                }
930                None => (trimmed, ""),
931            };
932
933            let mut current = None::<&str>;
934            for line in section_text.lines() {
935                let t = line.trim();
936                if t.eq_ignore_ascii_case("### Major changes") {
937                    current = Some("major");
938                    continue;
939                } else if t.eq_ignore_ascii_case("### Minor changes") {
940                    current = Some("minor");
941                    continue;
942                } else if t.eq_ignore_ascii_case("### Patch changes") {
943                    current = Some("patch");
944                    continue;
945                }
946                if t.starts_with("- ") {
947                    let msg = t.trim_start_matches("- ").trim();
948                    match current {
949                        Some("major") => push_unique(&mut merged_major, msg),
950                        Some("minor") => push_unique(&mut merged_minor, msg),
951                        Some("patch") => push_unique(&mut merged_patch, msg),
952                        _ => {}
953                    }
954                }
955            }
956
957            body = remaining.to_string();
958        }
959    }
960
961    // Build new aggregated top section
962    let mut section = String::new();
963    section.push_str(&format!("# {}\n\n", package));
964    section.push_str(&format!("## {}\n\n", new_version));
965
966    if !merged_major.is_empty() {
967        section.push_str("### Major changes\n\n");
968        for msg in &merged_major {
969            section.push_str(&crate::markdown::format_markdown_list_item(msg));
970        }
971        section.push('\n');
972    }
973    if !merged_minor.is_empty() {
974        section.push_str("### Minor changes\n\n");
975        for msg in &merged_minor {
976            section.push_str(&crate::markdown::format_markdown_list_item(msg));
977        }
978        section.push('\n');
979    }
980    if !merged_patch.is_empty() {
981        section.push_str("### Patch changes\n\n");
982        for msg in &merged_patch {
983            section.push_str(&crate::markdown::format_markdown_list_item(msg));
984        }
985        section.push('\n');
986    }
987
988    let combined = if body.trim().is_empty() {
989        section
990    } else {
991        format!("{}{}", section, body)
992    };
993    fs::write(&path, combined)
994}
995
996/// Validate fixed dependencies configuration against the workspace
997fn validate_fixed_dependencies(config: &Config, workspace: &Workspace) -> Result<(), String> {
998    let workspace_packages: FxHashSet<String> =
999        workspace.members.iter().map(|c| c.name.clone()).collect();
1000
1001    for (group_idx, group) in config.fixed_dependencies.iter().enumerate() {
1002        for package in group {
1003            if !workspace_packages.contains(package) {
1004                let available_packages: Vec<String> = workspace_packages.iter().cloned().collect();
1005                return Err(format!(
1006                    "Package '{}' in fixed dependency group {} does not exist in the workspace. Available packages: [{}]",
1007                    package,
1008                    group_idx + 1,
1009                    available_packages.join(", ")
1010                ));
1011            }
1012        }
1013    }
1014    Ok(())
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020
1021    #[test]
1022    fn skips_workspace_dependencies_when_updating() {
1023        let input = "[dependencies]\nfoo = { workspace = true, optional = true }\n";
1024        let (out, changed) = update_dependency_version(input, "foo", "1.2.3").unwrap();
1025        assert_eq!(out.trim_end(), input.trim_end());
1026        assert!(!changed);
1027    }
1028
1029    #[test]
1030    fn converts_simple_dep_without_quotes() {
1031        let input = "[dependencies]\nbar = \"0.1.0\"\n";
1032        let (out, changed) = update_dependency_version(input, "bar", "0.2.0").unwrap();
1033        assert!(changed);
1034        assert!(out.contains("bar = { version = \"0.2.0\" }"));
1035        assert!(!out.contains("\"bar\""));
1036    }
1037}