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 .filter(|c| !should_ignore_crate(config, workspace, c).unwrap_or(false))
137 .map(|c| (c.name.clone(), c))
138 .collect();
139
140 for crate_name in releases.keys() {
142 if let Some(crate_info) = by_name.get(crate_name) {
143 let mut updated_deps = Vec::new();
145 for dep_name in &crate_info.internal_deps {
146 if let Some(new_version) = new_version_by_name.get(dep_name as &str) {
147 updated_deps.push((dep_name.clone(), new_version.clone()));
149 }
150 }
151
152 if !updated_deps.is_empty() {
153 let updates = build_dependency_updates(&updated_deps);
155 if let Some((msg, bump)) = create_dependency_update_entry(&updates) {
156 messages_by_pkg
157 .entry(crate_name.clone())
158 .or_default()
159 .push((msg, bump));
160 }
161 }
162 }
163 }
164
165 messages_by_pkg
166}
167
168pub fn detect_fixed_dependency_policy_packages(
174 changesets: &[ChangesetInfo],
175 workspace: &Workspace,
176 config: &Config,
177 bumped_packages: &BTreeSet<String>,
178) -> BTreeMap<String, Bump> {
179 let packages_with_changesets: BTreeSet<String> = changesets
181 .iter()
182 .flat_map(|cs| cs.packages.iter().cloned())
183 .collect();
184
185 let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
187 for crate_info in &workspace.members {
188 if should_ignore_crate(config, workspace, crate_info).unwrap_or(false) {
190 continue;
191 }
192
193 for dep_name in &crate_info.internal_deps {
194 dependents
195 .entry(dep_name.clone())
196 .or_default()
197 .insert(crate_info.name.clone());
198 }
199 }
200
201 let mut packages_affected_by_cascade = BTreeSet::new();
203 for pkg_with_changeset in &packages_with_changesets {
204 let mut queue = vec![pkg_with_changeset.clone()];
205 let mut visited = BTreeSet::new();
206
207 while let Some(pkg) = queue.pop() {
208 if visited.contains(&pkg) {
209 continue;
210 }
211 visited.insert(pkg.clone());
212
213 if let Some(deps) = dependents.get(&pkg) {
214 for dep in deps {
215 packages_affected_by_cascade.insert(dep.clone());
216 queue.push(dep.clone());
217 }
218 }
219 }
220 }
221
222 let mut result = BTreeMap::new();
224
225 for pkg_name in bumped_packages {
226 if packages_with_changesets.contains(pkg_name) {
228 continue;
229 }
230
231 if packages_affected_by_cascade.contains(pkg_name) {
233 continue;
234 }
235
236 for group in &config.fixed_dependencies {
238 if group.contains(&pkg_name.to_string()) {
239 let has_affected_group_member = group.iter().any(|group_member| {
241 group_member != pkg_name
242 && (packages_with_changesets.contains(group_member)
243 || packages_affected_by_cascade.contains(group_member))
244 });
245
246 if has_affected_group_member {
247 let group_bump = group
249 .iter()
250 .filter_map(|member| {
251 if packages_with_changesets.contains(member) {
252 changesets
254 .iter()
255 .filter(|cs| cs.packages.contains(member))
256 .map(|cs| cs.bump)
257 .max()
258 } else {
259 None
260 }
261 })
262 .max()
263 .unwrap_or(Bump::Patch);
264
265 result.insert(pkg_name.clone(), group_bump);
266 break;
267 }
268 }
269 }
270 }
271
272 result
273}
274
275type InitialBumpsResult = (
277 BTreeMap<String, Bump>, BTreeMap<String, Vec<(String, Bump)>>, BTreeSet<std::path::PathBuf>, );
281
282type ReleasePlan = Vec<(String, String, String)>; pub fn run_release(root: &std::path::Path, dry_run: bool) -> io::Result<ReleaseOutput> {
287 let workspace = discover_workspace(root).map_err(io::Error::other)?;
288 let config = Config::load(&workspace.root).map_err(io::Error::other)?;
289
290 validate_fixed_dependencies(&config, &workspace).map_err(io::Error::other)?;
292
293 let changesets_dir = workspace.root.join(".sampo").join("changesets");
294 let changesets = load_changesets(&changesets_dir)?;
295 if changesets.is_empty() {
296 println!(
297 "No changesets found in {}",
298 workspace.root.join(".sampo").join("changesets").display()
299 );
300 return Ok(ReleaseOutput {
301 released_packages: vec![],
302 dry_run,
303 });
304 }
305
306 let (mut bump_by_pkg, mut messages_by_pkg, used_paths) =
308 compute_initial_bumps(&changesets, &workspace, &config)?;
309
310 if bump_by_pkg.is_empty() {
311 println!("No applicable packages found in changesets.");
312 return Ok(ReleaseOutput {
313 released_packages: vec![],
314 dry_run,
315 });
316 }
317
318 let dependents = build_dependency_graph(&workspace, &config);
320 apply_dependency_cascade(&mut bump_by_pkg, &dependents, &config, &workspace);
321 apply_linked_dependencies(&mut bump_by_pkg, &config);
322
323 let releases = prepare_release_plan(&bump_by_pkg, &workspace)?;
325 if releases.is_empty() {
326 println!("No matching workspace crates to release.");
327 return Ok(ReleaseOutput {
328 released_packages: vec![],
329 dry_run,
330 });
331 }
332
333 print_release_plan(&releases);
334
335 let released_packages: Vec<ReleasedPackage> = releases
337 .iter()
338 .map(|(name, old_version, new_version)| {
339 let bump = bump_by_pkg.get(name).copied().unwrap_or(Bump::Patch);
340 ReleasedPackage {
341 name: name.clone(),
342 old_version: old_version.clone(),
343 new_version: new_version.clone(),
344 bump,
345 }
346 })
347 .collect();
348
349 if dry_run {
350 println!("Dry-run: no files modified, no tags created.");
351 return Ok(ReleaseOutput {
352 released_packages,
353 dry_run: true,
354 });
355 }
356
357 apply_releases(
359 &releases,
360 &workspace,
361 &mut messages_by_pkg,
362 &changesets,
363 &config,
364 )?;
365
366 cleanup_consumed_changesets(used_paths)?;
368
369 Ok(ReleaseOutput {
370 released_packages,
371 dry_run: false,
372 })
373}
374
375fn compute_initial_bumps(
377 changesets: &[ChangesetInfo],
378 ws: &Workspace,
379 cfg: &Config,
380) -> io::Result<InitialBumpsResult> {
381 let mut bump_by_pkg: BTreeMap<String, Bump> = BTreeMap::new();
382 let mut messages_by_pkg: BTreeMap<String, Vec<(String, Bump)>> = BTreeMap::new();
383 let mut used_paths: BTreeSet<std::path::PathBuf> = BTreeSet::new();
384
385 let repo_slug = detect_github_repo_slug_with_config(&ws.root, cfg.github_repository.as_deref());
387 let github_token = std::env::var("GITHUB_TOKEN")
388 .ok()
389 .or_else(|| std::env::var("GH_TOKEN").ok());
390
391 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
393 for c in &ws.members {
394 by_name.insert(c.name.clone(), c);
395 }
396
397 for cs in changesets {
398 let mut consumed_changeset = false;
399 for pkg in &cs.packages {
400 if let Some(info) = by_name.get(pkg)
401 && should_ignore_crate(cfg, ws, info)?
402 {
403 continue;
404 }
405
406 consumed_changeset = true;
408
409 bump_by_pkg
410 .entry(pkg.clone())
411 .and_modify(|b| {
412 if cs.bump > *b {
413 *b = cs.bump;
414 }
415 })
416 .or_insert(cs.bump);
417
418 let commit_hash = get_commit_hash_for_path(&ws.root, &cs.path);
420 let enriched = if let Some(hash) = commit_hash {
421 enrich_changeset_message(
422 &cs.message,
423 &hash,
424 &ws.root,
425 repo_slug.as_deref(),
426 github_token.as_deref(),
427 cfg.changelog_show_commit_hash,
428 cfg.changelog_show_acknowledgments,
429 )
430 } else {
431 cs.message.clone()
432 };
433
434 messages_by_pkg
435 .entry(pkg.clone())
436 .or_default()
437 .push((enriched, cs.bump));
438 }
439 if consumed_changeset {
440 used_paths.insert(cs.path.clone());
441 }
442 }
443
444 Ok((bump_by_pkg, messages_by_pkg, used_paths))
445}
446
447fn build_dependency_graph(ws: &Workspace, cfg: &Config) -> BTreeMap<String, BTreeSet<String>> {
450 let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
451
452 let ignored_packages: BTreeSet<String> = ws
454 .members
455 .iter()
456 .filter(|c| should_ignore_crate(cfg, ws, c).unwrap_or(false))
457 .map(|c| c.name.clone())
458 .collect();
459
460 for c in &ws.members {
461 if ignored_packages.contains(&c.name) {
463 continue;
464 }
465
466 for dep in &c.internal_deps {
467 if ignored_packages.contains(dep) {
469 continue;
470 }
471
472 dependents
473 .entry(dep.clone())
474 .or_default()
475 .insert(c.name.clone());
476 }
477 }
478 dependents
479}
480
481fn apply_dependency_cascade(
483 bump_by_pkg: &mut BTreeMap<String, Bump>,
484 dependents: &BTreeMap<String, BTreeSet<String>>,
485 cfg: &Config,
486 ws: &Workspace,
487) {
488 let find_fixed_group = |pkg_name: &str| -> Option<usize> {
490 cfg.fixed_dependencies
491 .iter()
492 .position(|group| group.contains(&pkg_name.to_string()))
493 };
494
495 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
497 for c in &ws.members {
498 by_name.insert(c.name.clone(), c);
499 }
500
501 let mut queue: Vec<String> = bump_by_pkg.keys().cloned().collect();
502 let mut seen: BTreeSet<String> = queue.iter().cloned().collect();
503
504 while let Some(changed) = queue.pop() {
505 let changed_bump = bump_by_pkg.get(&changed).copied().unwrap_or(Bump::Patch);
506
507 if let Some(deps) = dependents.get(&changed) {
509 for dep_name in deps {
510 if let Some(info) = by_name.get(dep_name) {
512 match should_ignore_crate(cfg, ws, info) {
513 Ok(true) => continue,
514 Ok(false) => {} Err(_) => {
516 }
519 }
520 }
521
522 let dependent_bump = if find_fixed_group(dep_name).is_some() {
524 changed_bump
526 } else {
527 Bump::Patch
529 };
530
531 let entry = bump_by_pkg
532 .entry(dep_name.clone())
533 .or_insert(dependent_bump);
534 if *entry < dependent_bump {
536 *entry = dependent_bump;
537 }
538 if !seen.contains(dep_name) {
539 queue.push(dep_name.clone());
540 seen.insert(dep_name.clone());
541 }
542 }
543 }
544
545 if let Some(group_idx) = find_fixed_group(&changed) {
547 for group_member in &cfg.fixed_dependencies[group_idx] {
549 if group_member != &changed {
550 if let Some(info) = by_name.get(group_member) {
552 match should_ignore_crate(cfg, ws, info) {
553 Ok(true) => continue,
554 Ok(false) => {} Err(_) => {
556 }
559 }
560 }
561
562 let entry = bump_by_pkg
563 .entry(group_member.clone())
564 .or_insert(changed_bump);
565 if *entry < changed_bump {
567 *entry = changed_bump;
568 }
569 if !seen.contains(group_member) {
570 queue.push(group_member.clone());
571 seen.insert(group_member.clone());
572 }
573 }
574 }
575 }
576 }
577}
578
579fn apply_linked_dependencies(bump_by_pkg: &mut BTreeMap<String, Bump>, cfg: &Config) {
581 for group in &cfg.linked_dependencies {
582 let mut group_has_bumps = false;
584 let mut highest_bump = Bump::Patch;
585
586 for group_member in group {
588 if let Some(&member_bump) = bump_by_pkg.get(group_member) {
589 group_has_bumps = true;
590 if member_bump > highest_bump {
591 highest_bump = member_bump;
592 }
593 }
594 }
595
596 if group_has_bumps {
598 for group_member in group {
601 if bump_by_pkg.contains_key(group_member) {
602 let current_bump = bump_by_pkg
604 .get(group_member)
605 .copied()
606 .unwrap_or(Bump::Patch);
607 if highest_bump > current_bump {
608 bump_by_pkg.insert(group_member.clone(), highest_bump);
609 }
610 }
611 }
612 }
613 }
614}
615
616fn prepare_release_plan(
618 bump_by_pkg: &BTreeMap<String, Bump>,
619 ws: &Workspace,
620) -> io::Result<ReleasePlan> {
621 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
623 for c in &ws.members {
624 by_name.insert(c.name.clone(), c);
625 }
626
627 let mut releases: Vec<(String, String, String)> = Vec::new(); for (name, bump) in bump_by_pkg {
629 if let Some(info) = by_name.get(name) {
630 let old = if info.version.is_empty() {
631 "0.0.0".to_string()
632 } else {
633 info.version.clone()
634 };
635
636 let newv = bump_version(&old, *bump).unwrap_or_else(|_| old.clone());
637
638 releases.push((name.clone(), old, newv));
639 }
640 }
641
642 Ok(releases)
643}
644
645fn print_release_plan(releases: &ReleasePlan) {
647 println!("Planned releases:");
648 for (name, old, newv) in releases {
649 println!(" {name}: {old} -> {newv}");
650 }
651}
652
653fn apply_releases(
655 releases: &ReleasePlan,
656 ws: &Workspace,
657 messages_by_pkg: &mut BTreeMap<String, Vec<(String, Bump)>>,
658 changesets: &[ChangesetInfo],
659 cfg: &Config,
660) -> io::Result<()> {
661 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
663 for c in &ws.members {
664 by_name.insert(c.name.clone(), c);
665 }
666
667 let mut new_version_by_name: BTreeMap<String, String> = BTreeMap::new();
668 for (name, _old, newv) in releases {
669 new_version_by_name.insert(name.clone(), newv.clone());
670 }
671
672 let releases_map: BTreeMap<String, (String, String)> = releases
674 .iter()
675 .map(|(name, old, new)| (name.clone(), (old.clone(), new.clone())))
676 .collect();
677
678 let dependency_explanations =
680 detect_all_dependency_explanations(changesets, ws, cfg, &releases_map);
681
682 for (pkg_name, explanations) in dependency_explanations {
684 messages_by_pkg
685 .entry(pkg_name)
686 .or_default()
687 .extend(explanations);
688 }
689
690 for (name, old, newv) in releases {
692 let info = by_name.get(name.as_str()).unwrap();
693 let manifest_path = info.path.join("Cargo.toml");
694 let text = fs::read_to_string(&manifest_path)?;
695
696 let (updated, _dep_updates) =
698 update_manifest_versions(&text, Some(newv.as_str()), ws, &new_version_by_name)?;
699 fs::write(&manifest_path, updated)?;
700
701 let messages = messages_by_pkg.get(name).cloned().unwrap_or_default();
702 update_changelog(&info.path, name, old, newv, &messages)?;
703 }
704
705 Ok(())
706}
707
708fn cleanup_consumed_changesets(used_paths: BTreeSet<std::path::PathBuf>) -> io::Result<()> {
710 for p in used_paths {
711 let _ = fs::remove_file(p);
712 }
713 println!("Removed consumed changesets.");
714 Ok(())
715}
716
717pub fn bump_version(old: &str, bump: Bump) -> Result<String, String> {
719 let mut parts = old
720 .split('.')
721 .map(|s| s.parse::<u64>().unwrap_or(0))
722 .collect::<Vec<_>>();
723 while parts.len() < 3 {
724 parts.push(0);
725 }
726 let (maj, min, pat) = (parts[0], parts[1], parts[2]);
727 let (maj, min, pat) = match bump {
728 Bump::Patch => (maj, min, pat + 1),
729 Bump::Minor => (maj, min + 1, 0),
730 Bump::Major => (maj + 1, 0, 0),
731 };
732 Ok(format!("{maj}.{min}.{pat}"))
733}
734
735pub fn update_manifest_versions(
742 input: &str,
743 new_pkg_version: Option<&str>,
744 ws: &Workspace,
745 new_version_by_name: &BTreeMap<String, String>,
746) -> io::Result<(String, Vec<(String, String)>)> {
747 let mut result = input.to_string();
748 let mut applied: Vec<(String, String)> = Vec::new();
749
750 if let Some(new_version) = new_pkg_version {
752 result = update_package_version(&result, new_version)?;
753 }
754
755 let workspace_crates: BTreeSet<String> = ws.members.iter().map(|c| c.name.clone()).collect();
757 for (crate_name, new_version) in new_version_by_name {
758 if workspace_crates.contains(crate_name) {
759 let (updated_result, was_updated) =
760 update_dependency_version(&result, crate_name, new_version)?;
761 result = updated_result;
762 if was_updated {
763 applied.push((crate_name.clone(), new_version.clone()));
764 }
765 }
766 }
767
768 Ok((result, applied))
769}
770
771fn update_package_version(input: &str, new_version: &str) -> io::Result<String> {
773 let lines: Vec<&str> = input.lines().collect();
774 let mut result_lines = Vec::new();
775 let mut in_package_section = false;
776
777 for line in lines {
778 let trimmed = line.trim();
779
780 if trimmed == "[package]" {
782 in_package_section = true;
783 result_lines.push(line.to_string());
784 continue;
785 }
786
787 if in_package_section && trimmed.starts_with('[') && trimmed != "[package]" {
789 in_package_section = false;
790 }
791
792 if in_package_section && trimmed.starts_with("version") {
794 let indent = line.len() - line.trim_start().len();
796 let spaces = " ".repeat(indent);
797
798 result_lines.push(format!("{}version = \"{}\"", spaces, new_version));
800 } else {
801 result_lines.push(line.to_string());
802 }
803 }
804
805 Ok(result_lines.join("\n"))
806}
807
808fn update_dependency_version(
810 input: &str,
811 dep_name: &str,
812 new_version: &str,
813) -> io::Result<(String, bool)> {
814 let lines: Vec<&str> = input.lines().collect();
815 let mut result_lines = Vec::new();
816 let mut was_updated = false;
817 let mut current_section: Option<String> = None;
818
819 for line in lines {
820 let trimmed = line.trim();
821
822 if trimmed.starts_with('[') && trimmed.ends_with(']') {
824 let section_name = trimmed.trim_start_matches('[').trim_end_matches(']');
825 if section_name.starts_with("dependencies")
826 || section_name.starts_with("dev-dependencies")
827 || section_name.starts_with("build-dependencies")
828 {
829 current_section = Some(section_name.to_string());
830 } else {
831 current_section = None;
832 }
833 result_lines.push(line.to_string());
834 continue;
835 }
836
837 if current_section.is_some() {
839 if trimmed.starts_with(&format!("{} =", dep_name))
840 || trimmed.starts_with(&format!("\"{}\" =", dep_name))
841 {
842 let indent = line.len() - line.trim_start().len();
844 let spaces = " ".repeat(indent);
845
846 if trimmed.contains("workspace = true") || trimmed.contains("workspace=true") {
847 result_lines.push(line.to_string());
849 } else if trimmed.contains("{ path =") || trimmed.contains("{path =") {
850 let updated_line = update_dependency_table_line(line, new_version);
852 result_lines.push(updated_line);
853 was_updated = true;
854 } else if trimmed.contains("version =") {
855 let updated_line = update_dependency_table_line(line, new_version);
857 result_lines.push(updated_line);
858 was_updated = true;
859 } else {
860 result_lines.push(format!(
862 "{}{} = {{ version = \"{}\" }}",
863 spaces, dep_name, new_version
864 ));
865 was_updated = true;
866 }
867 } else {
868 result_lines.push(line.to_string());
869 }
870 } else {
871 result_lines.push(line.to_string());
872 }
873 }
874
875 Ok((result_lines.join("\n"), was_updated))
876}
877
878fn update_dependency_table_line(line: &str, new_version: &str) -> String {
880 if let Some(version_start) = line.find("version") {
882 let after_version = &line[version_start..];
883 if let Some(equals_pos) = after_version.find('=') {
884 let before_equals = &line[..version_start + equals_pos + 1];
885 let after_equals = &after_version[equals_pos + 1..];
886
887 if let Some(quote_start) = after_equals.find('"') {
889 let after_first_quote = &after_equals[quote_start + 1..];
890 if let Some(quote_end) = after_first_quote.find('"') {
891 let after_second_quote = &after_first_quote[quote_end + 1..];
892 return format!(
893 "{} \"{}\"{}",
894 before_equals, new_version, after_second_quote
895 );
896 }
897 }
898 }
899 }
900
901 if line.contains("{ path =") {
903 if let Some(closing_brace_pos) = line.rfind('}') {
905 let before_brace = &line[..closing_brace_pos];
906 let after_brace = &line[closing_brace_pos..];
907 return format!(
908 "{}, version = \"{}\" {}",
909 before_brace, new_version, after_brace
910 );
911 }
912 }
913
914 line.to_string()
915}
916
917fn update_changelog(
918 crate_dir: &Path,
919 package: &str,
920 old_version: &str,
921 new_version: &str,
922 entries: &[(String, Bump)],
923) -> io::Result<()> {
924 let path = crate_dir.join("CHANGELOG.md");
925 let existing = if path.exists() {
926 fs::read_to_string(&path)?
927 } else {
928 String::new()
929 };
930 let mut body = existing.trim_start_matches('\u{feff}').to_string();
931 let package_header = format!("# {}", package);
933 if body.starts_with(&package_header) {
934 if let Some(idx) = body.find('\n') {
935 body = body[idx + 1..].to_string();
936 } else {
937 body.clear();
938 }
939 }
940
941 let mut merged_major: Vec<String> = Vec::new();
945 let mut merged_minor: Vec<String> = Vec::new();
946 let mut merged_patch: Vec<String> = Vec::new();
947
948 let push_unique = |list: &mut Vec<String>, msg: &str| {
950 if !list.iter().any(|m| m == msg) {
951 list.push(msg.to_string());
952 }
953 };
954
955 for (msg, bump) in entries {
957 match bump {
958 Bump::Major => push_unique(&mut merged_major, msg),
959 Bump::Minor => push_unique(&mut merged_minor, msg),
960 Bump::Patch => push_unique(&mut merged_patch, msg),
961 }
962 }
963
964 let trimmed = body.trim_start();
968 if trimmed.starts_with("## ") {
969 let mut lines_iter = trimmed.lines();
971 let header_line = lines_iter.next().unwrap_or("").trim();
972 let header_text = header_line.trim_start_matches("## ").trim();
973
974 let is_published_top = header_text == old_version;
975
976 if !is_published_top {
977 let after_header_offset = header_line.len();
979 let rest_after_header = &trimmed[after_header_offset..];
980 let next_rel = rest_after_header.find("\n## ");
982 let (section_text, remaining) = match next_rel {
983 Some(pos) => {
984 let end = after_header_offset + pos + 1; (&trimmed[..end], &trimmed[end..])
986 }
987 None => (trimmed, ""),
988 };
989
990 let mut current = None::<&str>;
991 for line in section_text.lines() {
992 let t = line.trim();
993 if t.eq_ignore_ascii_case("### Major changes") {
994 current = Some("major");
995 continue;
996 } else if t.eq_ignore_ascii_case("### Minor changes") {
997 current = Some("minor");
998 continue;
999 } else if t.eq_ignore_ascii_case("### Patch changes") {
1000 current = Some("patch");
1001 continue;
1002 }
1003 if t.starts_with("- ") {
1004 let msg = t.trim_start_matches("- ").trim();
1005 match current {
1006 Some("major") => push_unique(&mut merged_major, msg),
1007 Some("minor") => push_unique(&mut merged_minor, msg),
1008 Some("patch") => push_unique(&mut merged_patch, msg),
1009 _ => {}
1010 }
1011 }
1012 }
1013
1014 body = remaining.to_string();
1015 }
1016 }
1017
1018 let mut section = String::new();
1020 section.push_str(&format!("# {}\n\n", package));
1021 section.push_str(&format!("## {}\n\n", new_version));
1022
1023 if !merged_major.is_empty() {
1024 section.push_str("### Major changes\n\n");
1025 for msg in &merged_major {
1026 section.push_str(&crate::markdown::format_markdown_list_item(msg));
1027 }
1028 section.push('\n');
1029 }
1030 if !merged_minor.is_empty() {
1031 section.push_str("### Minor changes\n\n");
1032 for msg in &merged_minor {
1033 section.push_str(&crate::markdown::format_markdown_list_item(msg));
1034 }
1035 section.push('\n');
1036 }
1037 if !merged_patch.is_empty() {
1038 section.push_str("### Patch changes\n\n");
1039 for msg in &merged_patch {
1040 section.push_str(&crate::markdown::format_markdown_list_item(msg));
1041 }
1042 section.push('\n');
1043 }
1044
1045 let combined = if body.trim().is_empty() {
1046 section
1047 } else {
1048 format!("{}{}", section, body)
1049 };
1050 fs::write(&path, combined)
1051}
1052
1053fn validate_fixed_dependencies(config: &Config, workspace: &Workspace) -> Result<(), String> {
1055 let workspace_packages: FxHashSet<String> =
1056 workspace.members.iter().map(|c| c.name.clone()).collect();
1057
1058 for (group_idx, group) in config.fixed_dependencies.iter().enumerate() {
1059 for package in group {
1060 if !workspace_packages.contains(package) {
1061 let available_packages: Vec<String> = workspace_packages.iter().cloned().collect();
1062 return Err(format!(
1063 "Package '{}' in fixed dependency group {} does not exist in the workspace. Available packages: [{}]",
1064 package,
1065 group_idx + 1,
1066 available_packages.join(", ")
1067 ));
1068 }
1069 }
1070 }
1071 Ok(())
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077
1078 #[test]
1079 fn skips_workspace_dependencies_when_updating() {
1080 let input = "[dependencies]\nfoo = { workspace = true, optional = true }\n";
1081 let (out, changed) = update_dependency_version(input, "foo", "1.2.3").unwrap();
1082 assert_eq!(out.trim_end(), input.trim_end());
1083 assert!(!changed);
1084 }
1085
1086 #[test]
1087 fn converts_simple_dep_without_quotes() {
1088 let input = "[dependencies]\nbar = \"0.1.0\"\n";
1089 let (out, changed) = update_dependency_version(input, "bar", "0.2.0").unwrap();
1090 assert!(changed);
1091 assert!(out.contains("bar = { version = \"0.2.0\" }"));
1092 assert!(!out.contains("\"bar\""));
1093 }
1094
1095 #[test]
1096 fn test_ignore_packages_in_dependency_cascade() {
1097 use crate::types::{CrateInfo, Workspace};
1098 use std::path::PathBuf;
1099
1100 let root = PathBuf::from("/tmp/test");
1102 let workspace = Workspace {
1103 root: root.clone(),
1104 members: vec![
1105 CrateInfo {
1106 name: "main-package".to_string(),
1107 version: "1.0.0".to_string(),
1108 path: root.join("main-package"),
1109 internal_deps: BTreeSet::new(),
1110 },
1111 CrateInfo {
1112 name: "examples-package".to_string(),
1113 version: "1.0.0".to_string(),
1114 path: root.join("examples/package"),
1115 internal_deps: BTreeSet::new(),
1116 },
1117 CrateInfo {
1118 name: "benchmarks-utils".to_string(),
1119 version: "1.0.0".to_string(),
1120 path: root.join("benchmarks/utils"),
1121 internal_deps: BTreeSet::new(),
1122 },
1123 ],
1124 };
1125
1126 let config = Config {
1128 ignore: vec!["examples/*".to_string(), "benchmarks/*".to_string()],
1129 ..Default::default()
1130 };
1131
1132 let mut dependents = BTreeMap::new();
1134 dependents.insert(
1135 "main-package".to_string(),
1136 ["examples-package", "benchmarks-utils"]
1137 .iter()
1138 .map(|s| s.to_string())
1139 .collect(),
1140 );
1141
1142 let mut bump_by_pkg = BTreeMap::new();
1144 bump_by_pkg.insert("main-package".to_string(), Bump::Minor);
1145
1146 apply_dependency_cascade(&mut bump_by_pkg, &dependents, &config, &workspace);
1148
1149 assert_eq!(bump_by_pkg.len(), 1);
1151 assert!(bump_by_pkg.contains_key("main-package"));
1152 assert!(!bump_by_pkg.contains_key("examples-package"));
1153 assert!(!bump_by_pkg.contains_key("benchmarks-utils"));
1154 }
1155
1156 #[test]
1157 fn test_ignored_packages_excluded_from_dependency_graph() {
1158 use crate::types::{CrateInfo, Workspace};
1159 use std::collections::BTreeSet;
1160 use std::path::PathBuf;
1161
1162 let root = PathBuf::from("/tmp/test");
1163 let workspace = Workspace {
1164 root: root.clone(),
1165 members: vec![
1166 CrateInfo {
1167 name: "main-package".to_string(),
1168 version: "1.0.0".to_string(),
1169 path: root.join("main-package"),
1170 internal_deps: ["examples-package".to_string()].into_iter().collect(),
1171 },
1172 CrateInfo {
1173 name: "examples-package".to_string(),
1174 version: "1.0.0".to_string(),
1175 path: root.join("examples/package"),
1176 internal_deps: BTreeSet::new(),
1177 },
1178 ],
1179 };
1180
1181 let config = Config {
1183 ignore: vec!["examples/*".to_string()],
1184 ..Default::default()
1185 };
1186
1187 let dependents = build_dependency_graph(&workspace, &config);
1189
1190 assert!(!dependents.contains_key("examples-package"));
1193
1194 assert!(dependents.is_empty());
1197 }
1198}