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
13pub 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
31pub 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
42pub fn create_dependency_update_entry(updates: &[DependencyUpdate]) -> Option<(String, Bump)> {
46 format_dependency_updates_message(updates).map(|msg| (msg, Bump::Patch))
47}
48
49pub 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
59pub 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
80pub 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 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 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 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 let by_name: BTreeMap<String, &CrateInfo> = workspace
134 .members
135 .iter()
136 .map(|c| (c.name.clone(), c))
137 .collect();
138
139 for crate_name in releases.keys() {
141 if let Some(crate_info) = by_name.get(crate_name) {
142 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 updated_deps.push((dep_name.clone(), new_version.clone()));
148 }
149 }
150
151 if !updated_deps.is_empty() {
152 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
167pub 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 let packages_with_changesets: BTreeSet<String> = changesets
180 .iter()
181 .flat_map(|cs| cs.packages.iter().cloned())
182 .collect();
183
184 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 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 let mut result = BTreeMap::new();
218
219 for pkg_name in bumped_packages {
220 if packages_with_changesets.contains(pkg_name) {
222 continue;
223 }
224
225 if packages_affected_by_cascade.contains(pkg_name) {
227 continue;
228 }
229
230 for group in &config.fixed_dependencies {
232 if group.contains(&pkg_name.to_string()) {
233 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 let group_bump = group
243 .iter()
244 .filter_map(|member| {
245 if packages_with_changesets.contains(member) {
246 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
269type InitialBumpsResult = (
271 BTreeMap<String, Bump>, BTreeMap<String, Vec<(String, Bump)>>, BTreeSet<std::path::PathBuf>, );
275
276type ReleasePlan = Vec<(String, String, String)>; pub 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(&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 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 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 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 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_releases(
353 &releases,
354 &workspace,
355 &mut messages_by_pkg,
356 &changesets,
357 &config,
358 )?;
359
360 cleanup_consumed_changesets(used_paths)?;
362
363 Ok(ReleaseOutput {
364 released_packages,
365 dry_run: false,
366 })
367}
368
369fn 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 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 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 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 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
441fn 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
455fn apply_dependency_cascade(
457 bump_by_pkg: &mut BTreeMap<String, Bump>,
458 dependents: &BTreeMap<String, BTreeSet<String>>,
459 cfg: &Config,
460) {
461 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 if let Some(deps) = dependents.get(&changed) {
476 for dep_name in deps {
477 let dependent_bump = if find_fixed_group(dep_name).is_some() {
479 changed_bump
481 } else {
482 Bump::Patch
484 };
485
486 let entry = bump_by_pkg
487 .entry(dep_name.clone())
488 .or_insert(dependent_bump);
489 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 if let Some(group_idx) = find_fixed_group(&changed) {
502 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 *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
522fn apply_linked_dependencies(bump_by_pkg: &mut BTreeMap<String, Bump>, cfg: &Config) {
524 for group in &cfg.linked_dependencies {
525 let mut group_has_bumps = false;
527 let mut highest_bump = Bump::Patch;
528
529 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 group_has_bumps {
541 for group_member in group {
544 if bump_by_pkg.contains_key(group_member) {
545 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
559fn prepare_release_plan(
561 bump_by_pkg: &BTreeMap<String, Bump>,
562 ws: &Workspace,
563) -> io::Result<ReleasePlan> {
564 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(); 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
588fn print_release_plan(releases: &ReleasePlan) {
590 println!("Planned releases:");
591 for (name, old, newv) in releases {
592 println!(" {name}: {old} -> {newv}");
593 }
594}
595
596fn 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 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 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 let dependency_explanations =
623 detect_all_dependency_explanations(changesets, ws, cfg, &releases_map);
624
625 for (pkg_name, explanations) in dependency_explanations {
627 messages_by_pkg
628 .entry(pkg_name)
629 .or_default()
630 .extend(explanations);
631 }
632
633 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 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
651fn 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
660pub 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
678pub 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 if let Some(new_version) = new_pkg_version {
695 result = update_package_version(&result, new_version)?;
696 }
697
698 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
714fn 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 if trimmed == "[package]" {
725 in_package_section = true;
726 result_lines.push(line.to_string());
727 continue;
728 }
729
730 if in_package_section && trimmed.starts_with('[') && trimmed != "[package]" {
732 in_package_section = false;
733 }
734
735 if in_package_section && trimmed.starts_with("version") {
737 let indent = line.len() - line.trim_start().len();
739 let spaces = " ".repeat(indent);
740
741 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
751fn 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 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 if current_section.is_some() {
782 if trimmed.starts_with(&format!("{} =", dep_name))
783 || trimmed.starts_with(&format!("\"{}\" =", dep_name))
784 {
785 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 result_lines.push(line.to_string());
792 } else if trimmed.contains("{ path =") || trimmed.contains("{path =") {
793 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 let updated_line = update_dependency_table_line(line, new_version);
800 result_lines.push(updated_line);
801 was_updated = true;
802 } else {
803 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
821fn update_dependency_table_line(line: &str, new_version: &str) -> String {
823 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 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 line.contains("{ path =") {
846 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 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 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 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 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 let trimmed = body.trim_start();
911 if trimmed.starts_with("## ") {
912 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 let after_header_offset = header_line.len();
922 let rest_after_header = &trimmed[after_header_offset..];
923 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; (&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 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
996fn 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}