1use crate::errors::{Result, SampoError};
2use crate::filters::should_ignore_crate;
3use crate::types::{Bump, CrateInfo, DependencyUpdate, ReleaseOutput, ReleasedPackage, Workspace};
4use crate::{
5 changeset::ChangesetInfo, config::Config, detect_github_repo_slug_with_config,
6 discover_workspace, enrich_changeset_message, get_commit_hash_for_path, load_changesets,
7};
8use rustc_hash::FxHashSet;
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::path::Path;
12use std::process::Command;
13
14pub fn format_dependency_updates_message(updates: &[DependencyUpdate]) -> Option<String> {
19 if updates.is_empty() {
20 return None;
21 }
22
23 let dep_list = updates
24 .iter()
25 .map(|dep| format!("{}@{}", dep.name, dep.new_version))
26 .collect::<Vec<_>>()
27 .join(", ");
28
29 Some(format!("Updated dependencies: {}", dep_list))
30}
31
32pub fn build_dependency_updates(updates: &[(String, String)]) -> Vec<DependencyUpdate> {
34 updates
35 .iter()
36 .map(|(name, version)| DependencyUpdate {
37 name: name.clone(),
38 new_version: version.clone(),
39 })
40 .collect()
41}
42
43pub fn create_dependency_update_entry(updates: &[DependencyUpdate]) -> Option<(String, Bump)> {
47 format_dependency_updates_message(updates).map(|msg| (msg, Bump::Patch))
48}
49
50pub fn create_fixed_dependency_policy_entry(bump: Bump) -> (String, Bump) {
54 (
55 "Bumped due to fixed dependency group policy".to_string(),
56 bump,
57 )
58}
59
60pub fn infer_bump_from_versions(old_ver: &str, new_ver: &str) -> Bump {
65 let old_parts: Vec<u32> = old_ver.split('.').filter_map(|s| s.parse().ok()).collect();
66 let new_parts: Vec<u32> = new_ver.split('.').filter_map(|s| s.parse().ok()).collect();
67
68 if old_parts.len() >= 3 && new_parts.len() >= 3 {
69 if new_parts[0] > old_parts[0] {
70 Bump::Major
71 } else if new_parts[1] > old_parts[1] {
72 Bump::Minor
73 } else {
74 Bump::Patch
75 }
76 } else {
77 Bump::Patch
78 }
79}
80
81pub fn detect_all_dependency_explanations(
97 changesets: &[ChangesetInfo],
98 workspace: &Workspace,
99 config: &Config,
100 releases: &BTreeMap<String, (String, String)>,
101) -> BTreeMap<String, Vec<(String, Bump)>> {
102 let mut messages_by_pkg: BTreeMap<String, Vec<(String, Bump)>> = BTreeMap::new();
103
104 let bumped_packages: BTreeSet<String> = releases.keys().cloned().collect();
106 let policy_packages =
107 detect_fixed_dependency_policy_packages(changesets, workspace, config, &bumped_packages);
108
109 for (pkg_name, policy_bump) in policy_packages {
110 let actual_bump = if let Some((old_ver, new_ver)) = releases.get(&pkg_name) {
112 infer_bump_from_versions(old_ver, new_ver)
113 } else {
114 policy_bump
115 };
116
117 let (msg, bump_type) = create_fixed_dependency_policy_entry(actual_bump);
118 messages_by_pkg
119 .entry(pkg_name)
120 .or_default()
121 .push((msg, bump_type));
122 }
123
124 let new_version_by_name: BTreeMap<String, String> = releases
129 .iter()
130 .map(|(name, (_old, new_ver))| (name.clone(), new_ver.clone()))
131 .collect();
132
133 let by_name: BTreeMap<String, &CrateInfo> = workspace
135 .members
136 .iter()
137 .filter(|c| !should_ignore_crate(config, workspace, c).unwrap_or(false))
138 .map(|c| (c.name.clone(), c))
139 .collect();
140
141 for crate_name in releases.keys() {
143 if let Some(crate_info) = by_name.get(crate_name) {
144 let mut updated_deps = Vec::new();
146 for dep_name in &crate_info.internal_deps {
147 if let Some(new_version) = new_version_by_name.get(dep_name as &str) {
148 updated_deps.push((dep_name.clone(), new_version.clone()));
150 }
151 }
152
153 if !updated_deps.is_empty() {
154 let updates = build_dependency_updates(&updated_deps);
156 if let Some((msg, bump)) = create_dependency_update_entry(&updates) {
157 messages_by_pkg
158 .entry(crate_name.clone())
159 .or_default()
160 .push((msg, bump));
161 }
162 }
163 }
164 }
165
166 messages_by_pkg
167}
168
169pub fn detect_fixed_dependency_policy_packages(
175 changesets: &[ChangesetInfo],
176 workspace: &Workspace,
177 config: &Config,
178 bumped_packages: &BTreeSet<String>,
179) -> BTreeMap<String, Bump> {
180 let packages_with_changesets: BTreeSet<String> = changesets
182 .iter()
183 .flat_map(|cs| cs.entries.iter().map(|(name, _)| name.clone()))
184 .collect();
185
186 let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
188 for crate_info in &workspace.members {
189 if should_ignore_crate(config, workspace, crate_info).unwrap_or(false) {
191 continue;
192 }
193
194 for dep_name in &crate_info.internal_deps {
195 dependents
196 .entry(dep_name.clone())
197 .or_default()
198 .insert(crate_info.name.clone());
199 }
200 }
201
202 let mut packages_affected_by_cascade = BTreeSet::new();
204 for pkg_with_changeset in &packages_with_changesets {
205 let mut queue = vec![pkg_with_changeset.clone()];
206 let mut visited = BTreeSet::new();
207
208 while let Some(pkg) = queue.pop() {
209 if visited.contains(&pkg) {
210 continue;
211 }
212 visited.insert(pkg.clone());
213
214 if let Some(deps) = dependents.get(&pkg) {
215 for dep in deps {
216 packages_affected_by_cascade.insert(dep.clone());
217 queue.push(dep.clone());
218 }
219 }
220 }
221 }
222
223 let mut result = BTreeMap::new();
225
226 for pkg_name in bumped_packages {
227 if packages_with_changesets.contains(pkg_name) {
229 continue;
230 }
231
232 if packages_affected_by_cascade.contains(pkg_name) {
234 continue;
235 }
236
237 for group in &config.fixed_dependencies {
239 if group.contains(&pkg_name.to_string()) {
240 let has_affected_group_member = group.iter().any(|group_member| {
242 group_member != pkg_name
243 && (packages_with_changesets.contains(group_member)
244 || packages_affected_by_cascade.contains(group_member))
245 });
246
247 if has_affected_group_member {
248 let group_bump = group
250 .iter()
251 .filter_map(|member| {
252 if packages_with_changesets.contains(member) {
253 changesets
255 .iter()
256 .filter_map(|cs| {
257 cs.entries
258 .iter()
259 .find(|(name, _)| name == member)
260 .map(|(_, b)| *b)
261 })
262 .max()
263 } else {
264 None
265 }
266 })
267 .max()
268 .unwrap_or(Bump::Patch);
269
270 result.insert(pkg_name.clone(), group_bump);
271 break;
272 }
273 }
274 }
275 }
276
277 result
278}
279
280type InitialBumpsResult = (
282 BTreeMap<String, Bump>, BTreeMap<String, Vec<(String, Bump)>>, BTreeSet<std::path::PathBuf>, );
286
287type ReleasePlan = Vec<(String, String, String)>; pub fn run_release(root: &std::path::Path, dry_run: bool) -> Result<ReleaseOutput> {
292 let workspace = discover_workspace(root)?;
293 let config = Config::load(&workspace.root)?;
294
295 validate_fixed_dependencies(&config, &workspace)?;
297
298 let changesets_dir = workspace.root.join(".sampo").join("changesets");
299 let changesets = load_changesets(&changesets_dir)?;
300 if changesets.is_empty() {
301 println!(
302 "No changesets found in {}",
303 workspace.root.join(".sampo").join("changesets").display()
304 );
305 return Ok(ReleaseOutput {
306 released_packages: vec![],
307 dry_run,
308 });
309 }
310
311 let (mut bump_by_pkg, mut messages_by_pkg, used_paths) =
313 compute_initial_bumps(&changesets, &workspace, &config)?;
314
315 if bump_by_pkg.is_empty() {
316 println!("No applicable packages found in changesets.");
317 return Ok(ReleaseOutput {
318 released_packages: vec![],
319 dry_run,
320 });
321 }
322
323 let dependents = build_dependency_graph(&workspace, &config);
325 apply_dependency_cascade(&mut bump_by_pkg, &dependents, &config, &workspace);
326 apply_linked_dependencies(&mut bump_by_pkg, &config);
327
328 let releases = prepare_release_plan(&bump_by_pkg, &workspace)?;
330 if releases.is_empty() {
331 println!("No matching workspace crates to release.");
332 return Ok(ReleaseOutput {
333 released_packages: vec![],
334 dry_run,
335 });
336 }
337
338 print_release_plan(&releases);
339
340 let released_packages: Vec<ReleasedPackage> = releases
342 .iter()
343 .map(|(name, old_version, new_version)| {
344 let bump = bump_by_pkg.get(name).copied().unwrap_or(Bump::Patch);
345 ReleasedPackage {
346 name: name.clone(),
347 old_version: old_version.clone(),
348 new_version: new_version.clone(),
349 bump,
350 }
351 })
352 .collect();
353
354 if dry_run {
355 println!("Dry-run: no files modified, no tags created.");
356 return Ok(ReleaseOutput {
357 released_packages,
358 dry_run: true,
359 });
360 }
361
362 apply_releases(
364 &releases,
365 &workspace,
366 &mut messages_by_pkg,
367 &changesets,
368 &config,
369 )?;
370
371 cleanup_consumed_changesets(used_paths)?;
373
374 if workspace.root.join("Cargo.lock").exists()
379 && let Err(e) = regenerate_lockfile(&workspace.root)
380 {
381 eprintln!("Warning: failed to regenerate Cargo.lock, {}", e);
384 }
385
386 Ok(ReleaseOutput {
387 released_packages,
388 dry_run: false,
389 })
390}
391
392fn regenerate_lockfile(root: &Path) -> Result<()> {
397 let mut cmd = Command::new("cargo");
398 cmd.arg("generate-lockfile").current_dir(root);
399
400 println!("Regenerating Cargo.lockā¦");
401 let status = cmd.status().map_err(SampoError::Io)?;
402 if !status.success() {
403 return Err(SampoError::Release(format!(
404 "cargo generate-lockfile failed with status {}",
405 status
406 )));
407 }
408 println!("Cargo.lock updated.");
409 Ok(())
410}
411
412fn compute_initial_bumps(
414 changesets: &[ChangesetInfo],
415 ws: &Workspace,
416 cfg: &Config,
417) -> Result<InitialBumpsResult> {
418 let mut bump_by_pkg: BTreeMap<String, Bump> = BTreeMap::new();
419 let mut messages_by_pkg: BTreeMap<String, Vec<(String, Bump)>> = BTreeMap::new();
420 let mut used_paths: BTreeSet<std::path::PathBuf> = BTreeSet::new();
421
422 let repo_slug = detect_github_repo_slug_with_config(&ws.root, cfg.github_repository.as_deref());
424 let github_token = std::env::var("GITHUB_TOKEN")
425 .ok()
426 .or_else(|| std::env::var("GH_TOKEN").ok());
427
428 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
430 for c in &ws.members {
431 by_name.insert(c.name.clone(), c);
432 }
433
434 for cs in changesets {
435 let mut consumed_changeset = false;
436 for (pkg, bump) in &cs.entries {
437 if let Some(info) = by_name.get(pkg)
438 && should_ignore_crate(cfg, ws, info)?
439 {
440 continue;
441 }
442
443 consumed_changeset = true;
445
446 bump_by_pkg
447 .entry(pkg.clone())
448 .and_modify(|b| {
449 if *bump > *b {
450 *b = *bump;
451 }
452 })
453 .or_insert(*bump);
454
455 let commit_hash = get_commit_hash_for_path(&ws.root, &cs.path);
457 let enriched = if let Some(hash) = commit_hash {
458 enrich_changeset_message(
459 &cs.message,
460 &hash,
461 &ws.root,
462 repo_slug.as_deref(),
463 github_token.as_deref(),
464 cfg.changelog_show_commit_hash,
465 cfg.changelog_show_acknowledgments,
466 )
467 } else {
468 cs.message.clone()
469 };
470
471 messages_by_pkg
472 .entry(pkg.clone())
473 .or_default()
474 .push((enriched, *bump));
475 }
476 if consumed_changeset {
477 used_paths.insert(cs.path.clone());
478 }
479 }
480
481 Ok((bump_by_pkg, messages_by_pkg, used_paths))
482}
483
484fn build_dependency_graph(ws: &Workspace, cfg: &Config) -> BTreeMap<String, BTreeSet<String>> {
487 let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
488
489 let ignored_packages: BTreeSet<String> = ws
491 .members
492 .iter()
493 .filter(|c| should_ignore_crate(cfg, ws, c).unwrap_or(false))
494 .map(|c| c.name.clone())
495 .collect();
496
497 for c in &ws.members {
498 if ignored_packages.contains(&c.name) {
500 continue;
501 }
502
503 for dep in &c.internal_deps {
504 if ignored_packages.contains(dep) {
506 continue;
507 }
508
509 dependents
510 .entry(dep.clone())
511 .or_default()
512 .insert(c.name.clone());
513 }
514 }
515 dependents
516}
517
518fn apply_dependency_cascade(
520 bump_by_pkg: &mut BTreeMap<String, Bump>,
521 dependents: &BTreeMap<String, BTreeSet<String>>,
522 cfg: &Config,
523 ws: &Workspace,
524) {
525 let find_fixed_group = |pkg_name: &str| -> Option<usize> {
527 cfg.fixed_dependencies
528 .iter()
529 .position(|group| group.contains(&pkg_name.to_string()))
530 };
531
532 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
534 for c in &ws.members {
535 by_name.insert(c.name.clone(), c);
536 }
537
538 let mut queue: Vec<String> = bump_by_pkg.keys().cloned().collect();
539 let mut seen: BTreeSet<String> = queue.iter().cloned().collect();
540
541 while let Some(changed) = queue.pop() {
542 let changed_bump = bump_by_pkg.get(&changed).copied().unwrap_or(Bump::Patch);
543
544 if let Some(deps) = dependents.get(&changed) {
546 for dep_name in deps {
547 if let Some(info) = by_name.get(dep_name) {
549 match should_ignore_crate(cfg, ws, info) {
550 Ok(true) => continue,
551 Ok(false) => {} Err(_) => {
553 }
556 }
557 }
558
559 let dependent_bump = if find_fixed_group(dep_name).is_some() {
561 changed_bump
563 } else {
564 Bump::Patch
566 };
567
568 let entry = bump_by_pkg
569 .entry(dep_name.clone())
570 .or_insert(dependent_bump);
571 if *entry < dependent_bump {
573 *entry = dependent_bump;
574 }
575 if !seen.contains(dep_name) {
576 queue.push(dep_name.clone());
577 seen.insert(dep_name.clone());
578 }
579 }
580 }
581
582 if let Some(group_idx) = find_fixed_group(&changed) {
584 for group_member in &cfg.fixed_dependencies[group_idx] {
586 if group_member != &changed {
587 if let Some(info) = by_name.get(group_member) {
589 match should_ignore_crate(cfg, ws, info) {
590 Ok(true) => continue,
591 Ok(false) => {} Err(_) => {
593 }
596 }
597 }
598
599 let entry = bump_by_pkg
600 .entry(group_member.clone())
601 .or_insert(changed_bump);
602 if *entry < changed_bump {
604 *entry = changed_bump;
605 }
606 if !seen.contains(group_member) {
607 queue.push(group_member.clone());
608 seen.insert(group_member.clone());
609 }
610 }
611 }
612 }
613 }
614}
615
616fn apply_linked_dependencies(bump_by_pkg: &mut BTreeMap<String, Bump>, cfg: &Config) {
618 for group in &cfg.linked_dependencies {
619 let mut group_has_bumps = false;
621 let mut highest_bump = Bump::Patch;
622
623 for group_member in group {
625 if let Some(&member_bump) = bump_by_pkg.get(group_member) {
626 group_has_bumps = true;
627 if member_bump > highest_bump {
628 highest_bump = member_bump;
629 }
630 }
631 }
632
633 if group_has_bumps {
635 for group_member in group {
638 if bump_by_pkg.contains_key(group_member) {
639 let current_bump = bump_by_pkg
641 .get(group_member)
642 .copied()
643 .unwrap_or(Bump::Patch);
644 if highest_bump > current_bump {
645 bump_by_pkg.insert(group_member.clone(), highest_bump);
646 }
647 }
648 }
649 }
650 }
651}
652
653fn prepare_release_plan(
655 bump_by_pkg: &BTreeMap<String, Bump>,
656 ws: &Workspace,
657) -> Result<ReleasePlan> {
658 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
660 for c in &ws.members {
661 by_name.insert(c.name.clone(), c);
662 }
663
664 let mut releases: Vec<(String, String, String)> = Vec::new(); for (name, bump) in bump_by_pkg {
666 if let Some(info) = by_name.get(name) {
667 let old = if info.version.is_empty() {
668 "0.0.0".to_string()
669 } else {
670 info.version.clone()
671 };
672
673 let newv = bump_version(&old, *bump).unwrap_or_else(|_| old.clone());
674
675 releases.push((name.clone(), old, newv));
676 }
677 }
678
679 Ok(releases)
680}
681
682fn print_release_plan(releases: &ReleasePlan) {
684 println!("Planned releases:");
685 for (name, old, newv) in releases {
686 println!(" {name}: {old} -> {newv}");
687 }
688}
689
690fn apply_releases(
692 releases: &ReleasePlan,
693 ws: &Workspace,
694 messages_by_pkg: &mut BTreeMap<String, Vec<(String, Bump)>>,
695 changesets: &[ChangesetInfo],
696 cfg: &Config,
697) -> Result<()> {
698 let mut by_name: BTreeMap<String, &CrateInfo> = BTreeMap::new();
700 for c in &ws.members {
701 by_name.insert(c.name.clone(), c);
702 }
703
704 let mut new_version_by_name: BTreeMap<String, String> = BTreeMap::new();
705 for (name, _old, newv) in releases {
706 new_version_by_name.insert(name.clone(), newv.clone());
707 }
708
709 let releases_map: BTreeMap<String, (String, String)> = releases
711 .iter()
712 .map(|(name, old, new)| (name.clone(), (old.clone(), new.clone())))
713 .collect();
714
715 let dependency_explanations =
717 detect_all_dependency_explanations(changesets, ws, cfg, &releases_map);
718
719 for (pkg_name, explanations) in dependency_explanations {
721 messages_by_pkg
722 .entry(pkg_name)
723 .or_default()
724 .extend(explanations);
725 }
726
727 for (name, old, newv) in releases {
729 let info = by_name.get(name.as_str()).unwrap();
730 let manifest_path = info.path.join("Cargo.toml");
731 let text = fs::read_to_string(&manifest_path)?;
732
733 let (updated, _dep_updates) =
735 update_manifest_versions(&text, Some(newv.as_str()), ws, &new_version_by_name)?;
736 fs::write(&manifest_path, updated)?;
737
738 let messages = messages_by_pkg.get(name).cloned().unwrap_or_default();
739 update_changelog(&info.path, name, old, newv, &messages)?;
740 }
741
742 Ok(())
743}
744
745fn cleanup_consumed_changesets(used_paths: BTreeSet<std::path::PathBuf>) -> Result<()> {
747 for p in used_paths {
748 let _ = fs::remove_file(p);
749 }
750 println!("Removed consumed changesets.");
751 Ok(())
752}
753
754pub fn bump_version(old: &str, bump: Bump) -> std::result::Result<String, String> {
756 let mut parts = old
757 .split('.')
758 .map(|s| s.parse::<u64>().unwrap_or(0))
759 .collect::<Vec<_>>();
760 while parts.len() < 3 {
761 parts.push(0);
762 }
763 let (maj, min, pat) = (parts[0], parts[1], parts[2]);
764 let (maj, min, pat) = match bump {
765 Bump::Patch => (maj, min, pat + 1),
766 Bump::Minor => (maj, min + 1, 0),
767 Bump::Major => (maj + 1, 0, 0),
768 };
769 Ok(format!("{maj}.{min}.{pat}"))
770}
771
772pub fn update_manifest_versions(
779 input: &str,
780 new_pkg_version: Option<&str>,
781 ws: &Workspace,
782 new_version_by_name: &BTreeMap<String, String>,
783) -> Result<(String, Vec<(String, String)>)> {
784 let mut result = input.to_string();
785 let mut applied: Vec<(String, String)> = Vec::new();
786
787 if let Some(new_version) = new_pkg_version {
789 result = update_package_version(&result, new_version)?;
790 }
791
792 let workspace_crates: BTreeSet<String> = ws.members.iter().map(|c| c.name.clone()).collect();
794 for (crate_name, new_version) in new_version_by_name {
795 if workspace_crates.contains(crate_name) {
796 let (updated_result, was_updated) =
797 update_dependency_version(&result, crate_name, new_version)?;
798 result = updated_result;
799 if was_updated {
800 applied.push((crate_name.clone(), new_version.clone()));
801 }
802 }
803 }
804
805 Ok((result, applied))
806}
807
808fn update_package_version(input: &str, new_version: &str) -> Result<String> {
810 let lines: Vec<&str> = input.lines().collect();
811 let mut result_lines = Vec::new();
812 let mut in_package_section = false;
813
814 for line in lines {
815 let trimmed = line.trim();
816
817 if trimmed == "[package]" {
819 in_package_section = true;
820 result_lines.push(line.to_string());
821 continue;
822 }
823
824 if in_package_section && trimmed.starts_with('[') && trimmed != "[package]" {
826 in_package_section = false;
827 }
828
829 if in_package_section && trimmed.starts_with("version") {
831 let indent = line.len() - line.trim_start().len();
833 let spaces = " ".repeat(indent);
834
835 result_lines.push(format!("{}version = \"{}\"", spaces, new_version));
837 } else {
838 result_lines.push(line.to_string());
839 }
840 }
841
842 Ok(result_lines.join("\n"))
843}
844
845fn update_dependency_version(
847 input: &str,
848 dep_name: &str,
849 new_version: &str,
850) -> Result<(String, bool)> {
851 let lines: Vec<&str> = input.lines().collect();
852 let mut result_lines = Vec::new();
853 let mut was_updated = false;
854 let mut current_section: Option<String> = None;
855
856 for line in lines {
857 let trimmed = line.trim();
858
859 if trimmed.starts_with('[') && trimmed.ends_with(']') {
861 let section_name = trimmed.trim_start_matches('[').trim_end_matches(']');
862 if section_name.starts_with("dependencies")
863 || section_name.starts_with("dev-dependencies")
864 || section_name.starts_with("build-dependencies")
865 {
866 current_section = Some(section_name.to_string());
867 } else {
868 current_section = None;
869 }
870 result_lines.push(line.to_string());
871 continue;
872 }
873
874 if current_section.is_some() {
876 if trimmed.starts_with(&format!("{} =", dep_name))
877 || trimmed.starts_with(&format!("\"{}\" =", dep_name))
878 {
879 let indent = line.len() - line.trim_start().len();
881 let spaces = " ".repeat(indent);
882
883 if trimmed.contains("workspace = true") || trimmed.contains("workspace=true") {
884 result_lines.push(line.to_string());
886 } else if trimmed.contains("{ path =") || trimmed.contains("{path =") {
887 let updated_line = update_dependency_table_line(line, new_version);
889 result_lines.push(updated_line);
890 was_updated = true;
891 } else if trimmed.contains("version =") {
892 let updated_line = update_dependency_table_line(line, new_version);
894 result_lines.push(updated_line);
895 was_updated = true;
896 } else {
897 result_lines.push(format!(
899 "{}{} = {{ version = \"{}\" }}",
900 spaces, dep_name, new_version
901 ));
902 was_updated = true;
903 }
904 } else {
905 result_lines.push(line.to_string());
906 }
907 } else {
908 result_lines.push(line.to_string());
909 }
910 }
911
912 Ok((result_lines.join("\n"), was_updated))
913}
914
915fn update_dependency_table_line(line: &str, new_version: &str) -> String {
917 if let Some(version_start) = line.find("version") {
919 let after_version = &line[version_start..];
920 if let Some(equals_pos) = after_version.find('=') {
921 let before_equals = &line[..version_start + equals_pos + 1];
922 let after_equals = &after_version[equals_pos + 1..];
923
924 if let Some(quote_start) = after_equals.find('"') {
926 let after_first_quote = &after_equals[quote_start + 1..];
927 if let Some(quote_end) = after_first_quote.find('"') {
928 let after_second_quote = &after_first_quote[quote_end + 1..];
929 return format!(
930 "{} \"{}\"{}",
931 before_equals, new_version, after_second_quote
932 );
933 }
934 }
935 }
936 }
937
938 if line.contains("{ path =") {
940 if let Some(closing_brace_pos) = line.rfind('}') {
942 let before_brace = &line[..closing_brace_pos];
943 let after_brace = &line[closing_brace_pos..];
944 return format!(
945 "{}, version = \"{}\" {}",
946 before_brace, new_version, after_brace
947 );
948 }
949 }
950
951 line.to_string()
952}
953
954fn update_changelog(
955 crate_dir: &Path,
956 package: &str,
957 old_version: &str,
958 new_version: &str,
959 entries: &[(String, Bump)],
960) -> Result<()> {
961 let path = crate_dir.join("CHANGELOG.md");
962 let existing = if path.exists() {
963 fs::read_to_string(&path)?
964 } else {
965 String::new()
966 };
967 let mut body = existing.trim_start_matches('\u{feff}').to_string();
968 let package_header = format!("# {}", package);
970 if body.starts_with(&package_header) {
971 if let Some(idx) = body.find('\n') {
972 body = body[idx + 1..].to_string();
973 } else {
974 body.clear();
975 }
976 }
977
978 let mut merged_major: Vec<String> = Vec::new();
982 let mut merged_minor: Vec<String> = Vec::new();
983 let mut merged_patch: Vec<String> = Vec::new();
984
985 let push_unique = |list: &mut Vec<String>, msg: &str| {
987 if !list.iter().any(|m| m == msg) {
988 list.push(msg.to_string());
989 }
990 };
991
992 for (msg, bump) in entries {
994 match bump {
995 Bump::Major => push_unique(&mut merged_major, msg),
996 Bump::Minor => push_unique(&mut merged_minor, msg),
997 Bump::Patch => push_unique(&mut merged_patch, msg),
998 }
999 }
1000
1001 let trimmed = body.trim_start();
1005 if trimmed.starts_with("## ") {
1006 let mut lines_iter = trimmed.lines();
1008 let header_line = lines_iter.next().unwrap_or("").trim();
1009 let header_text = header_line.trim_start_matches("## ").trim();
1010
1011 let is_published_top = header_text == old_version;
1012
1013 if !is_published_top {
1014 let after_header_offset = header_line.len();
1016 let rest_after_header = &trimmed[after_header_offset..];
1017 let next_rel = rest_after_header.find("\n## ");
1019 let (section_text, remaining) = match next_rel {
1020 Some(pos) => {
1021 let end = after_header_offset + pos + 1; (&trimmed[..end], &trimmed[end..])
1023 }
1024 None => (trimmed, ""),
1025 };
1026
1027 let mut current = None::<&str>;
1028 for line in section_text.lines() {
1029 let t = line.trim();
1030 if t.eq_ignore_ascii_case("### Major changes") {
1031 current = Some("major");
1032 continue;
1033 } else if t.eq_ignore_ascii_case("### Minor changes") {
1034 current = Some("minor");
1035 continue;
1036 } else if t.eq_ignore_ascii_case("### Patch changes") {
1037 current = Some("patch");
1038 continue;
1039 }
1040 if t.starts_with("- ") {
1041 let msg = t.trim_start_matches("- ").trim();
1042 match current {
1043 Some("major") => push_unique(&mut merged_major, msg),
1044 Some("minor") => push_unique(&mut merged_minor, msg),
1045 Some("patch") => push_unique(&mut merged_patch, msg),
1046 _ => {}
1047 }
1048 }
1049 }
1050
1051 body = remaining.to_string();
1052 }
1053 }
1054
1055 let mut section = String::new();
1057 section.push_str(&format!("# {}\n\n", package));
1058 section.push_str(&format!("## {}\n\n", new_version));
1059
1060 if !merged_major.is_empty() {
1061 section.push_str("### Major changes\n\n");
1062 for msg in &merged_major {
1063 section.push_str(&crate::markdown::format_markdown_list_item(msg));
1064 }
1065 section.push('\n');
1066 }
1067 if !merged_minor.is_empty() {
1068 section.push_str("### Minor changes\n\n");
1069 for msg in &merged_minor {
1070 section.push_str(&crate::markdown::format_markdown_list_item(msg));
1071 }
1072 section.push('\n');
1073 }
1074 if !merged_patch.is_empty() {
1075 section.push_str("### Patch changes\n\n");
1076 for msg in &merged_patch {
1077 section.push_str(&crate::markdown::format_markdown_list_item(msg));
1078 }
1079 section.push('\n');
1080 }
1081
1082 let combined = if body.trim().is_empty() {
1083 section
1084 } else {
1085 format!("{}{}", section, body)
1086 };
1087 fs::write(&path, combined)?;
1088 Ok(())
1089}
1090
1091fn validate_fixed_dependencies(config: &Config, workspace: &Workspace) -> Result<()> {
1093 let workspace_packages: FxHashSet<String> =
1094 workspace.members.iter().map(|c| c.name.clone()).collect();
1095
1096 for (group_idx, group) in config.fixed_dependencies.iter().enumerate() {
1097 for package in group {
1098 if !workspace_packages.contains(package) {
1099 let available_packages: Vec<String> = workspace_packages.iter().cloned().collect();
1100 return Err(SampoError::Release(format!(
1101 "Package '{}' in fixed dependency group {} does not exist in the workspace. Available packages: [{}]",
1102 package,
1103 group_idx + 1,
1104 available_packages.join(", ")
1105 )));
1106 }
1107 }
1108 }
1109 Ok(())
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115
1116 #[test]
1117 fn skips_workspace_dependencies_when_updating() {
1118 let input = "[dependencies]\nfoo = { workspace = true, optional = true }\n";
1119 let (out, changed) = update_dependency_version(input, "foo", "1.2.3").unwrap();
1120 assert_eq!(out.trim_end(), input.trim_end());
1121 assert!(!changed);
1122 }
1123
1124 #[test]
1125 fn converts_simple_dep_without_quotes() {
1126 let input = "[dependencies]\nbar = \"0.1.0\"\n";
1127 let (out, changed) = update_dependency_version(input, "bar", "0.2.0").unwrap();
1128 assert!(changed);
1129 assert!(out.contains("bar = { version = \"0.2.0\" }"));
1130 assert!(!out.contains("\"bar\""));
1131 }
1132
1133 #[test]
1134 fn test_ignore_packages_in_dependency_cascade() {
1135 use crate::types::{CrateInfo, Workspace};
1136 use std::path::PathBuf;
1137
1138 let root = PathBuf::from("/tmp/test");
1140 let workspace = Workspace {
1141 root: root.clone(),
1142 members: vec![
1143 CrateInfo {
1144 name: "main-package".to_string(),
1145 version: "1.0.0".to_string(),
1146 path: root.join("main-package"),
1147 internal_deps: BTreeSet::new(),
1148 },
1149 CrateInfo {
1150 name: "examples-package".to_string(),
1151 version: "1.0.0".to_string(),
1152 path: root.join("examples/package"),
1153 internal_deps: BTreeSet::new(),
1154 },
1155 CrateInfo {
1156 name: "benchmarks-utils".to_string(),
1157 version: "1.0.0".to_string(),
1158 path: root.join("benchmarks/utils"),
1159 internal_deps: BTreeSet::new(),
1160 },
1161 ],
1162 };
1163
1164 let config = Config {
1166 ignore: vec!["examples/*".to_string(), "benchmarks/*".to_string()],
1167 ..Default::default()
1168 };
1169
1170 let mut dependents = BTreeMap::new();
1172 dependents.insert(
1173 "main-package".to_string(),
1174 ["examples-package", "benchmarks-utils"]
1175 .iter()
1176 .map(|s| s.to_string())
1177 .collect(),
1178 );
1179
1180 let mut bump_by_pkg = BTreeMap::new();
1182 bump_by_pkg.insert("main-package".to_string(), Bump::Minor);
1183
1184 apply_dependency_cascade(&mut bump_by_pkg, &dependents, &config, &workspace);
1186
1187 assert_eq!(bump_by_pkg.len(), 1);
1189 assert!(bump_by_pkg.contains_key("main-package"));
1190 assert!(!bump_by_pkg.contains_key("examples-package"));
1191 assert!(!bump_by_pkg.contains_key("benchmarks-utils"));
1192 }
1193
1194 #[test]
1195 fn test_ignored_packages_excluded_from_dependency_graph() {
1196 use crate::types::{CrateInfo, Workspace};
1197 use std::collections::BTreeSet;
1198 use std::path::PathBuf;
1199
1200 let root = PathBuf::from("/tmp/test");
1201 let workspace = Workspace {
1202 root: root.clone(),
1203 members: vec![
1204 CrateInfo {
1205 name: "main-package".to_string(),
1206 version: "1.0.0".to_string(),
1207 path: root.join("main-package"),
1208 internal_deps: ["examples-package".to_string()].into_iter().collect(),
1209 },
1210 CrateInfo {
1211 name: "examples-package".to_string(),
1212 version: "1.0.0".to_string(),
1213 path: root.join("examples/package"),
1214 internal_deps: BTreeSet::new(),
1215 },
1216 ],
1217 };
1218
1219 let config = Config {
1221 ignore: vec!["examples/*".to_string()],
1222 ..Default::default()
1223 };
1224
1225 let dependents = build_dependency_graph(&workspace, &config);
1227
1228 assert!(!dependents.contains_key("examples-package"));
1231
1232 assert!(dependents.is_empty());
1235 }
1236}