1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::commit::{CommitType, DEFAULT_COMMIT_PATTERN, default_commit_types};
7use crate::error::ReleaseError;
8use crate::version::BumpLevel;
9use crate::version_files::detect_version_files;
10
11pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
13
14pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
16
17pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(default)]
22pub struct ReleaseConfig {
23 pub branches: Vec<String>,
24 pub tag_prefix: String,
25 pub commit_pattern: String,
26 pub breaking_section: String,
27 pub misc_section: String,
28 pub types: Vec<CommitType>,
29 pub changelog: ChangelogConfig,
30 pub version_files: Vec<String>,
31 pub version_files_strict: bool,
32 pub artifacts: Vec<String>,
33 pub floating_tags: bool,
34 pub build_command: Option<String>,
35 pub stage_files: Vec<String>,
37 pub prerelease: Option<String>,
40 pub pre_release_command: Option<String>,
42 pub post_release_command: Option<String>,
44 pub sign_tags: bool,
46 pub draft: bool,
48 pub release_name_template: Option<String>,
52 pub hooks: HooksConfig,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub lifecycle: Vec<LifecycleStep>,
59 #[serde(default)]
63 pub versioning: VersioningMode,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub packages: Vec<PackageConfig>,
68 #[serde(skip)]
70 pub path_filter: Option<String>,
71}
72
73impl Default for ReleaseConfig {
74 fn default() -> Self {
75 Self {
76 branches: vec!["main".into()],
77 tag_prefix: "v".into(),
78 commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
79 breaking_section: "Breaking Changes".into(),
80 misc_section: "Miscellaneous".into(),
81 types: default_commit_types(),
82 changelog: ChangelogConfig::default(),
83 version_files: vec![],
84 version_files_strict: false,
85 artifacts: vec![],
86 floating_tags: true,
87 build_command: None,
88 stage_files: vec![],
89 prerelease: None,
90 pre_release_command: None,
91 post_release_command: None,
92 sign_tags: false,
93 draft: false,
94 release_name_template: None,
95 hooks: HooksConfig::with_defaults(),
96 lifecycle: vec![],
97 versioning: VersioningMode::default(),
98 packages: vec![],
99 path_filter: None,
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
106#[serde(rename_all = "lowercase")]
107pub enum VersioningMode {
108 #[default]
110 Independent,
111 Fixed,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PackageConfig {
131 pub name: String,
133 pub path: String,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub tag_prefix: Option<String>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub version_files: Vec<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub changelog: Option<ChangelogConfig>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub build_command: Option<String>,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub stage_files: Vec<String>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154#[serde(rename_all = "snake_case")]
155pub enum LifecycleEvent {
156 PreRelease,
158 PostBump,
160 PostBuild,
162 PostRelease,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
178pub struct LifecycleStep {
179 pub name: String,
181 pub when: LifecycleEvent,
183 pub run: String,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(untagged)]
205pub enum HookEntry {
206 Step {
207 step: String,
208 patterns: Vec<String>,
209 rules: Vec<String>,
210 },
211 Simple(String),
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, Default)]
222#[serde(transparent)]
223pub struct HooksConfig {
224 pub hooks: BTreeMap<String, Vec<HookEntry>>,
225}
226
227impl HooksConfig {
228 pub fn with_defaults() -> Self {
229 let mut hooks = BTreeMap::new();
230 hooks.insert(
231 "commit-msg".into(),
232 vec![HookEntry::Simple("sr hook commit-msg".into())],
233 );
234 Self { hooks }
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[serde(default)]
240pub struct ChangelogConfig {
241 pub file: Option<String>,
242 pub template: Option<String>,
243}
244
245impl Default for ChangelogConfig {
246 fn default() -> Self {
247 Self {
248 file: Some("CHANGELOG.md".into()),
249 template: None,
250 }
251 }
252}
253
254impl ReleaseConfig {
255 pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
258 for &candidate in CONFIG_CANDIDATES {
259 let path = dir.join(candidate);
260 if path.exists() {
261 let is_legacy = candidate == LEGACY_CONFIG_FILE;
262 return Some((path, is_legacy));
263 }
264 }
265 None
266 }
267
268 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
270 if !path.exists() {
271 return Ok(Self::default());
272 }
273
274 let contents =
275 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
276
277 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
278 }
279
280 pub fn resolve_package(&self, pkg: &PackageConfig) -> Self {
282 let mut config = self.clone();
283 config.tag_prefix = pkg
284 .tag_prefix
285 .clone()
286 .unwrap_or_else(|| format!("{}/v", pkg.name));
287 config.path_filter = Some(pkg.path.clone());
288 if !pkg.version_files.is_empty() {
289 config.version_files = pkg.version_files.clone();
290 } else if config.version_files.is_empty() {
291 let detected = detect_version_files(Path::new(&pkg.path));
293 if !detected.is_empty() {
294 config.version_files = detected
295 .into_iter()
296 .map(|f| format!("{}/{f}", pkg.path))
297 .collect();
298 }
299 }
300 if let Some(ref cl) = pkg.changelog {
301 config.changelog = cl.clone();
302 }
303 if let Some(ref cmd) = pkg.build_command {
304 config.build_command = Some(cmd.clone());
305 }
306 if !pkg.stage_files.is_empty() {
307 config.stage_files = pkg.stage_files.clone();
308 }
309 config.packages = vec![];
311 config
312 }
313
314 pub fn resolve_fixed(&self) -> Self {
320 let mut config = self.clone();
321 config.path_filter = None;
323
324 let mut version_files: Vec<String> = config.version_files.clone();
326 for pkg in &self.packages {
327 if !pkg.version_files.is_empty() {
328 version_files.extend(pkg.version_files.clone());
329 } else {
330 let detected = detect_version_files(Path::new(&pkg.path));
331 version_files.extend(detected.into_iter().map(|f| format!("{}/{f}", pkg.path)));
332 }
333 }
334 version_files.sort();
335 version_files.dedup();
336 config.version_files = version_files;
337
338 let mut stage_files = config.stage_files.clone();
340 for pkg in &self.packages {
341 stage_files.extend(pkg.stage_files.clone());
342 }
343 stage_files.sort();
344 stage_files.dedup();
345 config.stage_files = stage_files;
346
347 config.packages = vec![];
349 config
350 }
351
352 pub fn find_package(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
354 self.packages
355 .iter()
356 .find(|p| p.name == name)
357 .ok_or_else(|| {
358 let available: Vec<&str> = self.packages.iter().map(|p| p.name.as_str()).collect();
359 ReleaseError::Config(format!(
360 "package '{name}' not found. Available: {}",
361 if available.is_empty() {
362 "(none — no packages configured)".to_string()
363 } else {
364 available.join(", ")
365 }
366 ))
367 })
368 }
369}
370
371pub fn default_config_template(version_files: &[String]) -> String {
376 let vf = if version_files.is_empty() {
377 "version_files: []\n".to_string()
378 } else {
379 let mut s = "version_files:\n".to_string();
380 for f in version_files {
381 s.push_str(&format!(" - {f}\n"));
382 }
383 s
384 };
385
386 format!(
387 r#"# sr configuration
388# Full reference: https://github.com/urmzd/sr#configuration
389
390# Branches that trigger releases when commits are pushed.
391branches:
392 - main
393
394# Prefix prepended to version tags (e.g. "v1.2.0").
395tag_prefix: "v"
396
397# Regex for parsing conventional commits.
398# Required named groups: type, description.
399# Optional named groups: scope, breaking.
400commit_pattern: '^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)'
401
402# Changelog section heading for breaking changes.
403breaking_section: Breaking Changes
404
405# Fallback changelog section for unrecognised commit types.
406misc_section: Miscellaneous
407
408# Commit type definitions.
409# name: commit type prefix (e.g. "feat", "fix")
410# bump: version bump level — major, minor, patch, or omit for no bump
411# section: changelog section heading, or omit to exclude from changelog
412# pattern: optional regex to match non-conventional commits as this type (fallback)
413types:
414 - name: feat
415 bump: minor
416 section: Features
417 - name: fix
418 bump: patch
419 section: Bug Fixes
420 - name: perf
421 bump: patch
422 section: Performance
423 - name: docs
424 section: Documentation
425 - name: refactor
426 bump: patch
427 section: Refactoring
428 - name: revert
429 section: Reverts
430 - name: chore
431 - name: ci
432 - name: test
433 - name: build
434 - name: style
435
436# Changelog configuration.
437# file: path to the changelog file (e.g. CHANGELOG.md), or omit to skip writing
438# template: custom Minijinja template string for changelog rendering
439changelog:
440 file: CHANGELOG.md
441 template:
442
443# Manifest files to bump on release (e.g. Cargo.toml, package.json, pyproject.toml).
444# Auto-detected if empty.
445{vf}
446# Fail if a version file uses an unsupported format (default: skip unknown files).
447version_files_strict: false
448
449# Glob patterns for release assets to upload to GitHub (e.g. "dist/*.tar.gz").
450artifacts: []
451
452# Create floating major version tags (e.g. "v3" pointing to latest v3.x.x).
453floating_tags: true
454
455# Shell command to run after version files are bumped (e.g. "cargo build --release").
456build_command:
457
458# Additional files/globs to stage after build_command runs (e.g. Cargo.lock).
459stage_files: []
460
461# Pre-release identifier (e.g. "alpha", "beta", "rc").
462# When set, versions are formatted as X.Y.Z-<id>.N where N auto-increments.
463prerelease:
464
465# Shell command to run before the release starts (validation, checks).
466pre_release_command:
467
468# Shell command to run after the release completes (notifications, deployments).
469post_release_command:
470
471# Sign annotated tags with GPG/SSH (git tag -s).
472sign_tags: false
473
474# Create GitHub releases as drafts (requires manual publishing).
475draft: false
476
477# Minijinja template for the GitHub release name.
478# Available variables: version, tag_name, tag_prefix.
479# Default: uses the tag name (e.g. "v1.2.0").
480release_name_template:
481
482# Git hooks configuration.
483# Each key is a git hook name. Values can be simple commands or structured steps.
484# Steps with patterns only run when staged files match the globs.
485# Rules containing {{files}} receive the matched file list.
486# Hook scripts are generated in .githooks/ by "sr init".
487hooks:
488 commit-msg:
489 - sr hook commit-msg
490 # pre-commit:
491 # - step: format
492 # patterns:
493 # - "*.rs"
494 # rules:
495 # - "rustfmt --check --edition 2024 {{files}}"
496 # - step: lint
497 # patterns:
498 # - "*.rs"
499 # rules:
500 # - "cargo clippy --workspace -- -D warnings"
501
502# Release lifecycle hooks — run commands at specific points in the release pipeline.
503# Runs after pre_release_command/post_release_command (both systems coexist).
504# Supported events: pre_release, post_bump, post_build, post_release
505# SR_VERSION and SR_TAG env vars are set for all lifecycle steps.
506# lifecycle:
507# - name: lint
508# when: pre_release
509# run: "cargo clippy -- -D warnings"
510# - name: verify
511# when: post_bump
512# run: "./scripts/verify-version.sh"
513# - name: check
514# when: post_build
515# run: "./scripts/check-artifacts.sh"
516# - name: notify
517# when: post_release
518# run: "./scripts/notify-slack.sh"
519
520# Versioning strategy for monorepo packages.
521# "independent" (default): each package gets its own version and tags.
522# "fixed": all packages share one version, tag, and changelog.
523# versioning: independent
524
525# Monorepo packages (uncomment and configure if needed).
526# With versioning: independent — each package is released separately.
527# With versioning: fixed — all packages are released together under one version.
528# packages:
529# - name: core
530# path: crates/core
531# tag_prefix: "core/v" # default: "<name>/v" (independent only)
532# version_files:
533# - crates/core/Cargo.toml
534# changelog:
535# file: crates/core/CHANGELOG.md
536# build_command: cargo build -p core
537# stage_files:
538# - crates/core/Cargo.lock
539"#
540 )
541}
542
543pub fn merge_config_yaml(existing_yaml: &str) -> Result<String, ReleaseError> {
549 let mut existing: serde_yaml_ng::Value = serde_yaml_ng::from_str(existing_yaml)
550 .map_err(|e| ReleaseError::Config(format!("failed to parse existing config: {e}")))?;
551
552 let default_config = ReleaseConfig::default();
553 let default_yaml = serde_yaml_ng::to_string(&default_config)
554 .map_err(|e| ReleaseError::Config(e.to_string()))?;
555 let defaults: serde_yaml_ng::Value =
556 serde_yaml_ng::from_str(&default_yaml).map_err(|e| ReleaseError::Config(e.to_string()))?;
557
558 deep_merge_value(&mut existing, &defaults);
559
560 let merged =
561 serde_yaml_ng::to_string(&existing).map_err(|e| ReleaseError::Config(e.to_string()))?;
562
563 Ok(format!(
564 "# sr configuration — merged with new defaults\n\
565 # Run 'sr init --force' for a fully-commented template.\n\n\
566 {merged}"
567 ))
568}
569
570fn deep_merge_value(base: &mut serde_yaml_ng::Value, defaults: &serde_yaml_ng::Value) {
573 use serde_yaml_ng::Value;
574 if let (Value::Mapping(base_map), Value::Mapping(default_map)) = (base, defaults) {
575 for (key, default_val) in default_map {
576 match base_map.get_mut(key) {
577 Some(existing_val) => {
578 if matches!(default_val, Value::Mapping(_)) {
580 deep_merge_value(existing_val, default_val);
581 }
582 }
583 None => {
584 base_map.insert(key.clone(), default_val.clone());
585 }
586 }
587 }
588 }
589}
590
591impl<'de> Deserialize<'de> for BumpLevel {
593 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
594 where
595 D: serde::Deserializer<'de>,
596 {
597 let s = String::deserialize(deserializer)?;
598 match s.as_str() {
599 "major" => Ok(BumpLevel::Major),
600 "minor" => Ok(BumpLevel::Minor),
601 "patch" => Ok(BumpLevel::Patch),
602 _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
603 }
604 }
605}
606
607impl Serialize for BumpLevel {
608 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
609 where
610 S: serde::Serializer,
611 {
612 let s = match self {
613 BumpLevel::Major => "major",
614 BumpLevel::Minor => "minor",
615 BumpLevel::Patch => "patch",
616 };
617 serializer.serialize_str(s)
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use std::io::Write;
625
626 #[test]
627 fn default_values() {
628 let config = ReleaseConfig::default();
629 assert_eq!(config.branches, vec!["main"]);
630 assert_eq!(config.tag_prefix, "v");
631 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
632 assert_eq!(config.breaking_section, "Breaking Changes");
633 assert_eq!(config.misc_section, "Miscellaneous");
634 assert!(!config.types.is_empty());
635 assert!(!config.version_files_strict);
636 assert!(config.artifacts.is_empty());
637 assert!(config.floating_tags);
638 assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
639 let refactor = config.types.iter().find(|t| t.name == "refactor").unwrap();
641 assert_eq!(refactor.bump, Some(BumpLevel::Patch));
642 }
643
644 #[test]
645 fn load_missing_file() {
646 let dir = tempfile::tempdir().unwrap();
647 let path = dir.path().join("nonexistent.yml");
648 let config = ReleaseConfig::load(&path).unwrap();
649 assert_eq!(config.tag_prefix, "v");
650 }
651
652 #[test]
653 fn load_valid_yaml() {
654 let dir = tempfile::tempdir().unwrap();
655 let path = dir.path().join("config.yml");
656 let mut f = std::fs::File::create(&path).unwrap();
657 writeln!(f, "branches:\n - develop\ntag_prefix: release-").unwrap();
658
659 let config = ReleaseConfig::load(&path).unwrap();
660 assert_eq!(config.branches, vec!["develop"]);
661 assert_eq!(config.tag_prefix, "release-");
662 }
663
664 #[test]
665 fn load_partial_yaml() {
666 let dir = tempfile::tempdir().unwrap();
667 let path = dir.path().join("config.yml");
668 std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
669
670 let config = ReleaseConfig::load(&path).unwrap();
671 assert_eq!(config.tag_prefix, "rel-");
672 assert_eq!(config.branches, vec!["main"]);
673 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
675 assert_eq!(config.breaking_section, "Breaking Changes");
676 assert!(!config.types.is_empty());
677 }
678
679 #[test]
680 fn load_yaml_with_artifacts() {
681 let dir = tempfile::tempdir().unwrap();
682 let path = dir.path().join("config.yml");
683 std::fs::write(
684 &path,
685 "artifacts:\n - \"dist/*.tar.gz\"\n - \"build/output-*\"\n",
686 )
687 .unwrap();
688
689 let config = ReleaseConfig::load(&path).unwrap();
690 assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
691 assert_eq!(config.tag_prefix, "v");
693 }
694
695 #[test]
696 fn load_yaml_with_floating_tags() {
697 let dir = tempfile::tempdir().unwrap();
698 let path = dir.path().join("config.yml");
699 std::fs::write(&path, "floating_tags: true\n").unwrap();
700
701 let config = ReleaseConfig::load(&path).unwrap();
702 assert!(config.floating_tags);
703 assert_eq!(config.tag_prefix, "v");
705 }
706
707 #[test]
708 fn bump_level_roundtrip() {
709 for (level, expected) in [
710 (BumpLevel::Major, "major"),
711 (BumpLevel::Minor, "minor"),
712 (BumpLevel::Patch, "patch"),
713 ] {
714 let yaml = serde_yaml_ng::to_string(&level).unwrap();
715 assert!(yaml.contains(expected));
716 let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
717 assert_eq!(parsed, level);
718 }
719 }
720
721 #[test]
722 fn types_roundtrip() {
723 let config = ReleaseConfig::default();
724 let yaml = serde_yaml_ng::to_string(&config).unwrap();
725 let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
726 assert_eq!(parsed.types.len(), config.types.len());
727 assert_eq!(parsed.types[0].name, "feat");
728 assert_eq!(parsed.commit_pattern, config.commit_pattern);
729 assert_eq!(parsed.breaking_section, config.breaking_section);
730 }
731
732 #[test]
733 fn load_yaml_with_packages() {
734 let dir = tempfile::tempdir().unwrap();
735 let path = dir.path().join("config.yml");
736 std::fs::write(
737 &path,
738 r#"
739packages:
740 - name: core
741 path: crates/core
742 version_files:
743 - crates/core/Cargo.toml
744 - name: cli
745 path: crates/cli
746 tag_prefix: "cli-v"
747"#,
748 )
749 .unwrap();
750
751 let config = ReleaseConfig::load(&path).unwrap();
752 assert_eq!(config.packages.len(), 2);
753 assert_eq!(config.packages[0].name, "core");
754 assert_eq!(config.packages[0].path, "crates/core");
755 assert_eq!(config.packages[1].tag_prefix.as_deref(), Some("cli-v"));
756 }
757
758 #[test]
759 fn resolve_package_defaults() {
760 let config = ReleaseConfig {
761 packages: vec![PackageConfig {
762 name: "core".into(),
763 path: "crates/core".into(),
764 tag_prefix: None,
765 version_files: vec![],
766 changelog: None,
767 build_command: None,
768 stage_files: vec![],
769 }],
770 ..Default::default()
771 };
772
773 let resolved = config.resolve_package(&config.packages[0]);
774 assert_eq!(resolved.tag_prefix, "core/v");
775 assert_eq!(resolved.path_filter.as_deref(), Some("crates/core"));
776 assert_eq!(resolved.branches, config.branches);
778 assert!(resolved.packages.is_empty());
779 }
780
781 #[test]
782 fn resolve_package_overrides() {
783 let config = ReleaseConfig {
784 version_files: vec!["Cargo.toml".into()],
785 packages: vec![PackageConfig {
786 name: "cli".into(),
787 path: "crates/cli".into(),
788 tag_prefix: Some("cli-v".into()),
789 version_files: vec!["crates/cli/Cargo.toml".into()],
790 changelog: Some(ChangelogConfig {
791 file: Some("crates/cli/CHANGELOG.md".into()),
792 template: None,
793 }),
794 build_command: Some("cargo build -p cli".into()),
795 stage_files: vec!["crates/cli/Cargo.lock".into()],
796 }],
797 ..Default::default()
798 };
799
800 let resolved = config.resolve_package(&config.packages[0]);
801 assert_eq!(resolved.tag_prefix, "cli-v");
802 assert_eq!(resolved.version_files, vec!["crates/cli/Cargo.toml"]);
803 assert_eq!(
804 resolved.changelog.file.as_deref(),
805 Some("crates/cli/CHANGELOG.md")
806 );
807 assert_eq!(
808 resolved.build_command.as_deref(),
809 Some("cargo build -p cli")
810 );
811 assert_eq!(resolved.stage_files, vec!["crates/cli/Cargo.lock"]);
812 }
813
814 #[test]
815 fn find_package_found() {
816 let config = ReleaseConfig {
817 packages: vec![PackageConfig {
818 name: "core".into(),
819 path: "crates/core".into(),
820 tag_prefix: None,
821 version_files: vec![],
822 changelog: None,
823 build_command: None,
824 stage_files: vec![],
825 }],
826 ..Default::default()
827 };
828
829 let pkg = config.find_package("core").unwrap();
830 assert_eq!(pkg.name, "core");
831 }
832
833 #[test]
834 fn find_package_not_found() {
835 let config = ReleaseConfig::default();
836 let err = config.find_package("nonexistent").unwrap_err();
837 assert!(err.to_string().contains("nonexistent"));
838 assert!(err.to_string().contains("no packages configured"));
839 }
840
841 #[test]
842 fn packages_not_serialized_when_empty() {
843 let config = ReleaseConfig::default();
844 let yaml = serde_yaml_ng::to_string(&config).unwrap();
845 assert!(!yaml.contains("packages"));
846 }
847
848 #[test]
849 fn default_template_parses() {
850 let template = default_config_template(&[]);
851 let config: ReleaseConfig = serde_yaml_ng::from_str(&template).unwrap();
852 let default = ReleaseConfig::default();
853 assert_eq!(config.branches, default.branches);
854 assert_eq!(config.tag_prefix, default.tag_prefix);
855 assert_eq!(config.commit_pattern, default.commit_pattern);
856 assert_eq!(config.breaking_section, default.breaking_section);
857 assert_eq!(config.types.len(), default.types.len());
858 assert!(config.floating_tags);
859 assert!(!config.sign_tags);
860 assert!(!config.draft);
861 }
862
863 #[test]
864 fn default_template_with_version_files() {
865 let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
866 let config: ReleaseConfig = serde_yaml_ng::from_str(&template).unwrap();
867 assert_eq!(config.version_files, vec!["Cargo.toml", "package.json"]);
868 }
869
870 #[test]
871 fn default_template_contains_all_fields() {
872 let template = default_config_template(&[]);
873 for field in [
874 "branches",
875 "tag_prefix",
876 "commit_pattern",
877 "breaking_section",
878 "misc_section",
879 "types",
880 "changelog",
881 "version_files",
882 "version_files_strict",
883 "artifacts",
884 "floating_tags",
885 "build_command",
886 "stage_files",
887 "prerelease",
888 "pre_release_command",
889 "post_release_command",
890 "sign_tags",
891 "draft",
892 "release_name_template",
893 "hooks",
894 "lifecycle",
895 "versioning",
896 "packages",
897 ] {
898 assert!(template.contains(field), "template missing field: {field}");
899 }
900 }
901
902 #[test]
903 fn merge_adds_missing_fields() {
904 let existing = "tag_prefix: rel-\n";
905 let merged = merge_config_yaml(existing).unwrap();
906 let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
907 assert_eq!(config.tag_prefix, "rel-");
909 assert_eq!(config.branches, vec!["main"]);
911 assert_eq!(config.breaking_section, "Breaking Changes");
912 assert!(!config.types.is_empty());
913 }
914
915 #[test]
916 fn merge_preserves_user_values() {
917 let existing = "branches:\n - develop\ntag_prefix: release-\nfloating_tags: true\n";
918 let merged = merge_config_yaml(existing).unwrap();
919 let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
920 assert_eq!(config.branches, vec!["develop"]);
921 assert_eq!(config.tag_prefix, "release-");
922 assert!(config.floating_tags);
923 }
924
925 #[test]
926 fn merge_nested_changelog() {
927 let existing = "changelog:\n file: CHANGELOG.md\n";
928 let merged = merge_config_yaml(existing).unwrap();
929 let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
930 assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
931 assert!(config.changelog.template.is_none());
933 }
934
935 #[test]
936 fn lifecycle_step_roundtrip() {
937 let step = LifecycleStep {
938 name: "lint".into(),
939 when: LifecycleEvent::PreRelease,
940 run: "cargo clippy".into(),
941 };
942 let yaml = serde_yaml_ng::to_string(&step).unwrap();
943 assert!(yaml.contains("pre_release"));
944 let parsed: LifecycleStep = serde_yaml_ng::from_str(&yaml).unwrap();
945 assert_eq!(parsed, step);
946 }
947
948 #[test]
949 fn lifecycle_config_parses_from_yaml() {
950 let yaml = r#"
951lifecycle:
952 - name: test
953 when: pre_release
954 run: "cargo test"
955 - name: audit
956 when: post_bump
957 run: "cargo audit"
958 - name: verify
959 when: post_build
960 run: "./scripts/verify.sh"
961 - name: notify
962 when: post_release
963 run: "./scripts/notify.sh"
964"#;
965 let config: ReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
966 assert_eq!(config.lifecycle.len(), 4);
967 assert_eq!(config.lifecycle[0].name, "test");
968 assert_eq!(config.lifecycle[0].when, LifecycleEvent::PreRelease);
969 assert_eq!(config.lifecycle[1].when, LifecycleEvent::PostBump);
970 assert_eq!(config.lifecycle[2].when, LifecycleEvent::PostBuild);
971 assert_eq!(config.lifecycle[3].when, LifecycleEvent::PostRelease);
972 }
973
974 #[test]
975 fn lifecycle_not_serialized_when_empty() {
976 let config = ReleaseConfig::default();
977 let yaml = serde_yaml_ng::to_string(&config).unwrap();
978 assert!(!yaml.contains("lifecycle"));
979 }
980
981 #[test]
982 fn versioning_mode_defaults_to_independent() {
983 let config = ReleaseConfig::default();
984 assert_eq!(config.versioning, VersioningMode::Independent);
985 }
986
987 #[test]
988 fn versioning_mode_roundtrip() {
989 for (mode, label) in [
990 (VersioningMode::Independent, "independent"),
991 (VersioningMode::Fixed, "fixed"),
992 ] {
993 let yaml = serde_yaml_ng::to_string(&mode).unwrap();
994 assert!(yaml.contains(label));
995 let parsed: VersioningMode = serde_yaml_ng::from_str(&yaml).unwrap();
996 assert_eq!(parsed, mode);
997 }
998 }
999
1000 #[test]
1001 fn load_yaml_with_versioning_fixed() {
1002 let dir = tempfile::tempdir().unwrap();
1003 let path = dir.path().join("config.yml");
1004 std::fs::write(
1005 &path,
1006 r#"
1007versioning: fixed
1008packages:
1009 - name: core
1010 path: crates/core
1011 version_files:
1012 - crates/core/Cargo.toml
1013 - name: cli
1014 path: crates/cli
1015 version_files:
1016 - crates/cli/Cargo.toml
1017"#,
1018 )
1019 .unwrap();
1020
1021 let config = ReleaseConfig::load(&path).unwrap();
1022 assert_eq!(config.versioning, VersioningMode::Fixed);
1023 assert_eq!(config.packages.len(), 2);
1024 }
1025
1026 #[test]
1027 fn resolve_fixed_collects_all_version_files() {
1028 let config = ReleaseConfig {
1029 version_files: vec!["Cargo.toml".into()],
1030 packages: vec![
1031 PackageConfig {
1032 name: "core".into(),
1033 path: "crates/core".into(),
1034 tag_prefix: Some("core/v".into()),
1035 version_files: vec!["crates/core/Cargo.toml".into()],
1036 changelog: None,
1037 build_command: None,
1038 stage_files: vec!["crates/core/Cargo.lock".into()],
1039 },
1040 PackageConfig {
1041 name: "cli".into(),
1042 path: "crates/cli".into(),
1043 tag_prefix: None,
1044 version_files: vec!["crates/cli/Cargo.toml".into()],
1045 changelog: None,
1046 build_command: None,
1047 stage_files: vec![],
1048 },
1049 ],
1050 versioning: VersioningMode::Fixed,
1051 ..Default::default()
1052 };
1053
1054 let resolved = config.resolve_fixed();
1055 assert_eq!(resolved.tag_prefix, "v");
1057 assert!(resolved.path_filter.is_none());
1059 assert!(resolved.version_files.contains(&"Cargo.toml".to_string()));
1061 assert!(
1062 resolved
1063 .version_files
1064 .contains(&"crates/core/Cargo.toml".to_string())
1065 );
1066 assert!(
1067 resolved
1068 .version_files
1069 .contains(&"crates/cli/Cargo.toml".to_string())
1070 );
1071 assert!(
1073 resolved
1074 .stage_files
1075 .contains(&"crates/core/Cargo.lock".to_string())
1076 );
1077 assert!(resolved.packages.is_empty());
1079 }
1080
1081 #[test]
1082 fn resolve_fixed_deduplicates() {
1083 let config = ReleaseConfig {
1084 version_files: vec!["Cargo.toml".into()],
1085 packages: vec![PackageConfig {
1086 name: "core".into(),
1087 path: "crates/core".into(),
1088 tag_prefix: None,
1089 version_files: vec!["Cargo.toml".into()], changelog: None,
1091 build_command: None,
1092 stage_files: vec![],
1093 }],
1094 versioning: VersioningMode::Fixed,
1095 ..Default::default()
1096 };
1097
1098 let resolved = config.resolve_fixed();
1099 let cargo_count = resolved
1100 .version_files
1101 .iter()
1102 .filter(|f| *f == "Cargo.toml")
1103 .count();
1104 assert_eq!(cargo_count, 1);
1105 }
1106}