1use crate::types::{Bump, CrateInfo, DependencyUpdate, ReleaseOutput, ReleasedPackage, Workspace};
2use crate::{
3 changeset::ChangesetInfo, config::Config, detect_changesets_dir,
4 detect_github_repo_slug_with_config, discover_workspace, enrich_changeset_message,
5 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 ws = discover_workspace(root).map_err(io::Error::other)?;
282 let cfg = Config::load(&ws.root).map_err(io::Error::other)?;
283
284 validate_fixed_dependencies(&cfg, &ws).map_err(io::Error::other)?;
286
287 let changesets_dir = detect_changesets_dir(&ws.root);
288 let changesets = load_changesets(&changesets_dir)?;
289 if changesets.is_empty() {
290 println!(
291 "No changesets found in {}",
292 ws.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, &ws, &cfg)?;
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(&ws);
314 apply_dependency_cascade(&mut bump_by_pkg, &dependents, &cfg);
315 apply_linked_dependencies(&mut bump_by_pkg, &cfg);
316
317 let releases = prepare_release_plan(&bump_by_pkg, &ws)?;
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(&releases, &ws, &mut messages_by_pkg, &changesets, &cfg)?;
353
354 cleanup_consumed_changesets(used_paths)?;
356
357 Ok(ReleaseOutput {
358 released_packages,
359 dry_run: false,
360 })
361}
362
363fn compute_initial_bumps(
365 changesets: &[ChangesetInfo],
366 ws: &Workspace,
367 cfg: &Config,
368) -> io::Result<InitialBumpsResult> {
369 let mut bump_by_pkg: BTreeMap<String, Bump> = BTreeMap::new();
370 let mut messages_by_pkg: BTreeMap<String, Vec<(String, Bump)>> = BTreeMap::new();
371 let mut used_paths: BTreeSet<std::path::PathBuf> = BTreeSet::new();
372
373 let repo_slug = detect_github_repo_slug_with_config(&ws.root, cfg.github_repository.as_deref());
375 let github_token = std::env::var("GITHUB_TOKEN")
376 .ok()
377 .or_else(|| std::env::var("GH_TOKEN").ok());
378
379 for cs in changesets {
380 for pkg in &cs.packages {
381 used_paths.insert(cs.path.clone());
382 bump_by_pkg
383 .entry(pkg.clone())
384 .and_modify(|b| {
385 if cs.bump > *b {
386 *b = cs.bump;
387 }
388 })
389 .or_insert(cs.bump);
390
391 let commit_hash = get_commit_hash_for_path(&ws.root, &cs.path);
393 let enriched = if let Some(hash) = commit_hash {
394 enrich_changeset_message(
395 &cs.message,
396 &hash,
397 &ws.root,
398 repo_slug.as_deref(),
399 github_token.as_deref(),
400 cfg.changelog_show_commit_hash,
401 cfg.changelog_show_acknowledgments,
402 )
403 } else {
404 cs.message.clone()
405 };
406
407 messages_by_pkg
408 .entry(pkg.clone())
409 .or_default()
410 .push((enriched, cs.bump));
411 }
412 }
413
414 Ok((bump_by_pkg, messages_by_pkg, used_paths))
415}
416
417fn build_dependency_graph(ws: &Workspace) -> BTreeMap<String, BTreeSet<String>> {
419 let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
420 for c in &ws.members {
421 for dep in &c.internal_deps {
422 dependents
423 .entry(dep.clone())
424 .or_default()
425 .insert(c.name.clone());
426 }
427 }
428 dependents
429}
430
431fn apply_dependency_cascade(
433 bump_by_pkg: &mut BTreeMap<String, Bump>,
434 dependents: &BTreeMap<String, BTreeSet<String>>,
435 cfg: &Config,
436) {
437 let find_fixed_group = |pkg_name: &str| -> Option<usize> {
439 cfg.fixed_dependencies
440 .iter()
441 .position(|group| group.contains(&pkg_name.to_string()))
442 };
443
444 let mut queue: Vec<String> = bump_by_pkg.keys().cloned().collect();
445 let mut seen: BTreeSet<String> = queue.iter().cloned().collect();
446
447 while let Some(changed) = queue.pop() {
448 let changed_bump = bump_by_pkg.get(&changed).copied().unwrap_or(Bump::Patch);
449
450 if let Some(deps) = dependents.get(&changed) {
452 for dep_name in deps {
453 let dependent_bump = if find_fixed_group(dep_name).is_some() {
455 changed_bump
457 } else {
458 Bump::Patch
460 };
461
462 let entry = bump_by_pkg
463 .entry(dep_name.clone())
464 .or_insert(dependent_bump);
465 if *entry < dependent_bump {
467 *entry = dependent_bump;
468 }
469 if !seen.contains(dep_name) {
470 queue.push(dep_name.clone());
471 seen.insert(dep_name.clone());
472 }
473 }
474 }
475
476 if let Some(group_idx) = find_fixed_group(&changed) {
478 for group_member in &cfg.fixed_dependencies[group_idx] {
480 if group_member != &changed {
481 let entry = bump_by_pkg
482 .entry(group_member.clone())
483 .or_insert(changed_bump);
484 if *entry < changed_bump {
486 *entry = changed_bump;
487 }
488 if !seen.contains(group_member) {
489 queue.push(group_member.clone());
490 seen.insert(group_member.clone());
491 }
492 }
493 }
494 }
495 }
496}
497
498fn apply_linked_dependencies(bump_by_pkg: &mut BTreeMap<String, Bump>, cfg: &Config) {
500 for group in &cfg.linked_dependencies {
501 let mut group_has_bumps = false;
503 let mut highest_bump = Bump::Patch;
504
505 for group_member in group {
507 if let Some(&member_bump) = bump_by_pkg.get(group_member) {
508 group_has_bumps = true;
509 if member_bump > highest_bump {
510 highest_bump = member_bump;
511 }
512 }
513 }
514
515 if group_has_bumps {
517 for group_member in group {
520 if bump_by_pkg.contains_key(group_member) {
521 let current_bump = bump_by_pkg
523 .get(group_member)
524 .copied()
525 .unwrap_or(Bump::Patch);
526 if highest_bump > current_bump {
527 bump_by_pkg.insert(group_member.clone(), highest_bump);
528 }
529 }
530 }
531 }
532 }
533}
534
535fn prepare_release_plan(
537 bump_by_pkg: &BTreeMap<String, Bump>,
538 ws: &Workspace,
539) -> io::Result<ReleasePlan> {
540 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
542 for c in &ws.members {
543 by_name.insert(c.name.clone(), c);
544 }
545
546 let mut releases: Vec<(String, String, String)> = Vec::new(); for (name, bump) in bump_by_pkg {
548 if let Some(info) = by_name.get(name) {
549 let old = if info.version.is_empty() {
550 "0.0.0".to_string()
551 } else {
552 info.version.clone()
553 };
554
555 let newv = bump_version(&old, *bump).unwrap_or_else(|_| old.clone());
556
557 releases.push((name.clone(), old, newv));
558 }
559 }
560
561 Ok(releases)
562}
563
564fn print_release_plan(releases: &ReleasePlan) {
566 println!("Planned releases:");
567 for (name, old, newv) in releases {
568 println!(" {name}: {old} -> {newv}");
569 }
570}
571
572fn apply_releases(
574 releases: &ReleasePlan,
575 ws: &Workspace,
576 messages_by_pkg: &mut BTreeMap<String, Vec<(String, Bump)>>,
577 changesets: &[ChangesetInfo],
578 cfg: &Config,
579) -> io::Result<()> {
580 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
582 for c in &ws.members {
583 by_name.insert(c.name.clone(), c);
584 }
585
586 let mut new_version_by_name: BTreeMap<String, String> = BTreeMap::new();
587 for (name, _old, newv) in releases {
588 new_version_by_name.insert(name.clone(), newv.clone());
589 }
590
591 let releases_map: BTreeMap<String, (String, String)> = releases
593 .iter()
594 .map(|(name, old, new)| (name.clone(), (old.clone(), new.clone())))
595 .collect();
596
597 let dependency_explanations =
599 detect_all_dependency_explanations(changesets, ws, cfg, &releases_map);
600
601 for (pkg_name, explanations) in dependency_explanations {
603 messages_by_pkg
604 .entry(pkg_name)
605 .or_default()
606 .extend(explanations);
607 }
608
609 for (name, old, newv) in releases {
611 let info = by_name.get(name.as_str()).unwrap();
612 let manifest_path = info.path.join("Cargo.toml");
613 let text = fs::read_to_string(&manifest_path)?;
614
615 let (updated, _dep_updates) =
617 update_manifest_versions(&text, Some(newv.as_str()), ws, &new_version_by_name)?;
618 fs::write(&manifest_path, updated)?;
619
620 let messages = messages_by_pkg.get(name).cloned().unwrap_or_default();
621 update_changelog(&info.path, name, old, newv, &messages)?;
622 }
623
624 Ok(())
625}
626
627fn cleanup_consumed_changesets(used_paths: BTreeSet<std::path::PathBuf>) -> io::Result<()> {
629 for p in used_paths {
630 let _ = fs::remove_file(p);
631 }
632 println!("Removed consumed changesets.");
633 Ok(())
634}
635
636pub fn bump_version(old: &str, bump: Bump) -> Result<String, String> {
638 let mut parts = old
639 .split('.')
640 .map(|s| s.parse::<u64>().unwrap_or(0))
641 .collect::<Vec<_>>();
642 while parts.len() < 3 {
643 parts.push(0);
644 }
645 let (maj, min, pat) = (parts[0], parts[1], parts[2]);
646 let (maj, min, pat) = match bump {
647 Bump::Patch => (maj, min, pat + 1),
648 Bump::Minor => (maj, min + 1, 0),
649 Bump::Major => (maj + 1, 0, 0),
650 };
651 Ok(format!("{maj}.{min}.{pat}"))
652}
653
654pub fn update_manifest_versions(
661 input: &str,
662 new_pkg_version: Option<&str>,
663 ws: &Workspace,
664 new_version_by_name: &BTreeMap<String, String>,
665) -> io::Result<(String, Vec<(String, String)>)> {
666 let mut result = input.to_string();
667 let mut applied: Vec<(String, String)> = Vec::new();
668
669 if let Some(new_version) = new_pkg_version {
671 result = update_package_version(&result, new_version)?;
672 }
673
674 let workspace_crates: BTreeSet<String> = ws.members.iter().map(|c| c.name.clone()).collect();
676 for (crate_name, new_version) in new_version_by_name {
677 if workspace_crates.contains(crate_name) {
678 let (updated_result, was_updated) =
679 update_dependency_version(&result, crate_name, new_version)?;
680 result = updated_result;
681 if was_updated {
682 applied.push((crate_name.clone(), new_version.clone()));
683 }
684 }
685 }
686
687 Ok((result, applied))
688}
689
690fn update_package_version(input: &str, new_version: &str) -> io::Result<String> {
692 let lines: Vec<&str> = input.lines().collect();
693 let mut result_lines = Vec::new();
694 let mut in_package_section = false;
695
696 for line in lines {
697 let trimmed = line.trim();
698
699 if trimmed == "[package]" {
701 in_package_section = true;
702 result_lines.push(line.to_string());
703 continue;
704 }
705
706 if in_package_section && trimmed.starts_with('[') && trimmed != "[package]" {
708 in_package_section = false;
709 }
710
711 if in_package_section && trimmed.starts_with("version") {
713 let indent = line.len() - line.trim_start().len();
715 let spaces = " ".repeat(indent);
716
717 result_lines.push(format!("{}version = \"{}\"", spaces, new_version));
719 } else {
720 result_lines.push(line.to_string());
721 }
722 }
723
724 Ok(result_lines.join("\n"))
725}
726
727fn update_dependency_version(
729 input: &str,
730 dep_name: &str,
731 new_version: &str,
732) -> io::Result<(String, bool)> {
733 let lines: Vec<&str> = input.lines().collect();
734 let mut result_lines = Vec::new();
735 let mut was_updated = false;
736 let mut current_section: Option<String> = None;
737
738 for line in lines {
739 let trimmed = line.trim();
740
741 if trimmed.starts_with('[') && trimmed.ends_with(']') {
743 let section_name = trimmed.trim_start_matches('[').trim_end_matches(']');
744 if section_name.starts_with("dependencies")
745 || section_name.starts_with("dev-dependencies")
746 || section_name.starts_with("build-dependencies")
747 {
748 current_section = Some(section_name.to_string());
749 } else {
750 current_section = None;
751 }
752 result_lines.push(line.to_string());
753 continue;
754 }
755
756 if current_section.is_some() {
758 if trimmed.starts_with(&format!("{} =", dep_name))
759 || trimmed.starts_with(&format!("\"{}\" =", dep_name))
760 {
761 let indent = line.len() - line.trim_start().len();
763 let spaces = " ".repeat(indent);
764
765 if trimmed.contains("{ path =") || trimmed.contains("{path =") {
767 let updated_line = update_dependency_table_line(line, new_version);
769 result_lines.push(updated_line);
770 was_updated = true;
771 } else if trimmed.contains("version =") {
772 let updated_line = update_dependency_table_line(line, new_version);
774 result_lines.push(updated_line);
775 was_updated = true;
776 } else {
777 result_lines.push(format!(
779 "{}\"{}\" = {{ version = \"{}\" }}",
780 spaces, dep_name, new_version
781 ));
782 was_updated = true;
783 }
784 } else {
785 result_lines.push(line.to_string());
786 }
787 } else {
788 result_lines.push(line.to_string());
789 }
790 }
791
792 Ok((result_lines.join("\n"), was_updated))
793}
794
795fn update_dependency_table_line(line: &str, new_version: &str) -> String {
797 if let Some(version_start) = line.find("version") {
799 let after_version = &line[version_start..];
800 if let Some(equals_pos) = after_version.find('=') {
801 let before_equals = &line[..version_start + equals_pos + 1];
802 let after_equals = &after_version[equals_pos + 1..];
803
804 if let Some(quote_start) = after_equals.find('"') {
806 let after_first_quote = &after_equals[quote_start + 1..];
807 if let Some(quote_end) = after_first_quote.find('"') {
808 let after_second_quote = &after_first_quote[quote_end + 1..];
809 return format!(
810 "{} \"{}\"{}",
811 before_equals, new_version, after_second_quote
812 );
813 }
814 }
815 }
816 }
817
818 if line.contains("{ path =") {
820 if let Some(closing_brace_pos) = line.rfind('}') {
822 let before_brace = &line[..closing_brace_pos];
823 let after_brace = &line[closing_brace_pos..];
824 return format!(
825 "{}, version = \"{}\" {}",
826 before_brace, new_version, after_brace
827 );
828 }
829 }
830
831 line.to_string()
832}
833
834fn update_changelog(
835 crate_dir: &Path,
836 package: &str,
837 old_version: &str,
838 new_version: &str,
839 entries: &[(String, Bump)],
840) -> io::Result<()> {
841 let path = crate_dir.join("CHANGELOG.md");
842 let existing = if path.exists() {
843 fs::read_to_string(&path)?
844 } else {
845 String::new()
846 };
847 let mut body = existing.trim_start_matches('\u{feff}').to_string();
848 let package_header = format!("# {}", package);
850 if body.starts_with(&package_header) {
851 if let Some(idx) = body.find('\n') {
852 body = body[idx + 1..].to_string();
853 } else {
854 body.clear();
855 }
856 }
857
858 let mut merged_major: Vec<String> = Vec::new();
862 let mut merged_minor: Vec<String> = Vec::new();
863 let mut merged_patch: Vec<String> = Vec::new();
864
865 let push_unique = |list: &mut Vec<String>, msg: &str| {
867 if !list.iter().any(|m| m == msg) {
868 list.push(msg.to_string());
869 }
870 };
871
872 for (msg, bump) in entries {
874 match bump {
875 Bump::Major => push_unique(&mut merged_major, msg),
876 Bump::Minor => push_unique(&mut merged_minor, msg),
877 Bump::Patch => push_unique(&mut merged_patch, msg),
878 }
879 }
880
881 let trimmed = body.trim_start();
885 if trimmed.starts_with("## ") {
886 let mut lines_iter = trimmed.lines();
888 let header_line = lines_iter.next().unwrap_or("").trim();
889 let header_text = header_line.trim_start_matches("## ").trim();
890
891 let is_published_top = header_text == old_version;
892
893 if !is_published_top {
894 let after_header_offset = header_line.len();
896 let rest_after_header = &trimmed[after_header_offset..];
897 let next_rel = rest_after_header.find("\n## ");
899 let (section_text, remaining) = match next_rel {
900 Some(pos) => {
901 let end = after_header_offset + pos + 1; (&trimmed[..end], &trimmed[end..])
903 }
904 None => (trimmed, ""),
905 };
906
907 let mut current = None::<&str>;
908 for line in section_text.lines() {
909 let t = line.trim();
910 if t.eq_ignore_ascii_case("### Major changes") {
911 current = Some("major");
912 continue;
913 } else if t.eq_ignore_ascii_case("### Minor changes") {
914 current = Some("minor");
915 continue;
916 } else if t.eq_ignore_ascii_case("### Patch changes") {
917 current = Some("patch");
918 continue;
919 }
920 if t.starts_with("- ") {
921 let msg = t.trim_start_matches("- ").trim();
922 match current {
923 Some("major") => push_unique(&mut merged_major, msg),
924 Some("minor") => push_unique(&mut merged_minor, msg),
925 Some("patch") => push_unique(&mut merged_patch, msg),
926 _ => {}
927 }
928 }
929 }
930
931 body = remaining.to_string();
932 }
933 }
934
935 let mut section = String::new();
937 section.push_str(&format!("# {}\n\n", package));
938 section.push_str(&format!("## {}\n\n", new_version));
939
940 if !merged_major.is_empty() {
941 section.push_str("### Major changes\n\n");
942 for msg in &merged_major {
943 section.push_str("- ");
944 section.push_str(msg);
945 section.push('\n');
946 }
947 section.push('\n');
948 }
949 if !merged_minor.is_empty() {
950 section.push_str("### Minor changes\n\n");
951 for msg in &merged_minor {
952 section.push_str("- ");
953 section.push_str(msg);
954 section.push('\n');
955 }
956 section.push('\n');
957 }
958 if !merged_patch.is_empty() {
959 section.push_str("### Patch changes\n\n");
960 for msg in &merged_patch {
961 section.push_str("- ");
962 section.push_str(msg);
963 section.push('\n');
964 }
965 section.push('\n');
966 }
967
968 let combined = if body.trim().is_empty() {
969 section
970 } else {
971 format!("{}{}", section, body)
972 };
973 fs::write(&path, combined)
974}
975
976fn validate_fixed_dependencies(cfg: &Config, ws: &Workspace) -> Result<(), String> {
978 let workspace_packages: FxHashSet<String> = ws.members.iter().map(|c| c.name.clone()).collect();
979
980 for (group_idx, group) in cfg.fixed_dependencies.iter().enumerate() {
981 for package in group {
982 if !workspace_packages.contains(package) {
983 let available_packages: Vec<String> = workspace_packages.iter().cloned().collect();
984 return Err(format!(
985 "Package '{}' in fixed dependency group {} does not exist in the workspace. Available packages: [{}]",
986 package,
987 group_idx + 1,
988 available_packages.join(", ")
989 ));
990 }
991 }
992 }
993 Ok(())
994}