1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5use crate::commit::CommitType;
6use crate::error::ReleaseError;
7use crate::version::BumpLevel;
8use crate::version_files::detect_version_files;
9
10pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
12
13pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
15
16pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct Config {
33 pub git: GitConfig,
34 pub commit: CommitConfig,
35 pub changelog: ChangelogConfig,
36 pub channels: ChannelsConfig,
37 pub vcs: VcsConfig,
38 #[serde(default = "default_packages")]
39 pub packages: Vec<PackageConfig>,
40}
41
42impl Default for Config {
43 fn default() -> Self {
44 Self {
45 git: GitConfig::default(),
46 commit: CommitConfig::default(),
47 changelog: ChangelogConfig::default(),
48 channels: ChannelsConfig::default(),
49 vcs: VcsConfig::default(),
50 packages: default_packages(),
51 }
52 }
53}
54
55fn default_packages() -> Vec<PackageConfig> {
56 vec![PackageConfig {
57 path: ".".into(),
58 ..Default::default()
59 }]
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(default)]
69pub struct GitConfig {
70 pub tag_prefix: String,
72 pub floating_tag: bool,
74 pub sign_tags: bool,
76 pub v0_protection: bool,
79 pub user: GitUserConfig,
82 pub skip_patterns: Vec<String>,
86}
87
88impl Default for GitConfig {
89 fn default() -> Self {
90 Self {
91 tag_prefix: "v".into(),
92 floating_tag: true,
93 sign_tags: false,
94 v0_protection: true,
95 user: GitUserConfig::default(),
96 skip_patterns: default_skip_patterns(),
97 }
98 }
99}
100
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[serde(default)]
104pub struct GitUserConfig {
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub name: Option<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub email: Option<String>,
111}
112
113pub fn default_skip_patterns() -> Vec<String> {
117 vec!["[skip release]".into(), "[skip sr]".into()]
118}
119
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
126#[serde(default)]
127pub struct CommitConfig {
128 pub types: CommitTypesConfig,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(default)]
136pub struct CommitTypesConfig {
137 pub minor: Vec<String>,
139 pub patch: Vec<String>,
141 pub none: Vec<String>,
143}
144
145impl Default for CommitTypesConfig {
146 fn default() -> Self {
147 Self {
148 minor: vec!["feat".into()],
149 patch: vec!["fix".into(), "perf".into(), "refactor".into()],
150 none: vec![
151 "docs".into(),
152 "revert".into(),
153 "chore".into(),
154 "ci".into(),
155 "test".into(),
156 "build".into(),
157 "style".into(),
158 ],
159 }
160 }
161}
162
163impl CommitTypesConfig {
164 pub fn all_type_names(&self) -> Vec<&str> {
166 self.minor
167 .iter()
168 .chain(self.patch.iter())
169 .chain(self.none.iter())
170 .map(|s| s.as_str())
171 .collect()
172 }
173
174 pub fn into_commit_types(&self) -> Vec<CommitType> {
176 let mut types = Vec::new();
177 for name in &self.minor {
178 types.push(CommitType {
179 name: name.clone(),
180 bump: Some(BumpLevel::Minor),
181 });
182 }
183 for name in &self.patch {
184 types.push(CommitType {
185 name: name.clone(),
186 bump: Some(BumpLevel::Patch),
187 });
188 }
189 for name in &self.none {
190 types.push(CommitType {
191 name: name.clone(),
192 bump: None,
193 });
194 }
195 types
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(default)]
206pub struct ChangelogConfig {
207 pub file: Option<String>,
209 pub template: Option<String>,
211 pub groups: Vec<ChangelogGroup>,
213}
214
215impl Default for ChangelogConfig {
216 fn default() -> Self {
217 Self {
218 file: Some("CHANGELOG.md".into()),
219 template: None,
220 groups: default_changelog_groups(),
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ChangelogGroup {
228 pub name: String,
230 pub content: Vec<String>,
232}
233
234pub fn default_changelog_groups() -> Vec<ChangelogGroup> {
235 vec![
236 ChangelogGroup {
237 name: "breaking".into(),
238 content: vec!["breaking".into()],
239 },
240 ChangelogGroup {
241 name: "features".into(),
242 content: vec!["feat".into()],
243 },
244 ChangelogGroup {
245 name: "bug-fixes".into(),
246 content: vec!["fix".into()],
247 },
248 ChangelogGroup {
249 name: "performance".into(),
250 content: vec!["perf".into()],
251 },
252 ChangelogGroup {
253 name: "refactoring".into(),
254 content: vec!["refactor".into()],
255 },
256 ChangelogGroup {
257 name: "misc".into(),
258 content: vec![
259 "docs".into(),
260 "revert".into(),
261 "chore".into(),
262 "ci".into(),
263 "test".into(),
264 "build".into(),
265 "style".into(),
266 ],
267 },
268 ]
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(default)]
280pub struct ChannelsConfig {
281 pub default: String,
283 pub branch: String,
285 pub content: Vec<ChannelConfig>,
287}
288
289impl Default for ChannelsConfig {
290 fn default() -> Self {
291 Self {
292 default: "stable".into(),
293 branch: "main".into(),
294 content: vec![ChannelConfig {
295 name: "stable".into(),
296 prerelease: None,
297 draft: false,
298 }],
299 }
300 }
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct ChannelConfig {
306 pub name: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub prerelease: Option<String>,
311 #[serde(default)]
313 pub draft: bool,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, Default)]
322#[serde(default)]
323pub struct VcsConfig {
324 pub github: GitHubConfig,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, Default)]
329#[serde(default)]
330pub struct GitHubConfig {
331 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub release_name_template: Option<String>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(default)]
343pub struct PackageConfig {
344 pub path: String,
346 pub independent: bool,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub tag_prefix: Option<String>,
351 pub version_files: Vec<String>,
353 pub version_files_strict: bool,
355 pub stage_files: Vec<String>,
357 pub artifacts: Vec<String>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub changelog: Option<ChangelogConfig>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub hooks: Option<HooksConfig>,
365}
366
367impl Default for PackageConfig {
368 fn default() -> Self {
369 Self {
370 path: ".".into(),
371 independent: false,
372 tag_prefix: None,
373 version_files: vec![],
374 version_files_strict: false,
375 stage_files: vec![],
376 artifacts: vec![],
377 changelog: None,
378 hooks: None,
379 }
380 }
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize, Default)]
385#[serde(default)]
386pub struct HooksConfig {
387 pub pre_release: Vec<String>,
389 pub build: Vec<String>,
397 pub post_release: Vec<String>,
399}
400
401impl Config {
406 pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
408 for &candidate in CONFIG_CANDIDATES {
409 let path = dir.join(candidate);
410 if path.exists() {
411 let is_legacy = candidate == LEGACY_CONFIG_FILE;
412 return Some((path, is_legacy));
413 }
414 }
415 None
416 }
417
418 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
420 if !path.exists() {
421 return Ok(Self::default());
422 }
423 let contents =
424 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
425 let config: Self =
426 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))?;
427 config.validate()?;
428 Ok(config)
429 }
430
431 fn validate(&self) -> Result<(), ReleaseError> {
433 let mut seen = std::collections::HashSet::new();
435 for name in self.commit.types.all_type_names() {
436 if !seen.insert(name) {
437 return Err(ReleaseError::Config(format!(
438 "duplicate commit type: {name}"
439 )));
440 }
441 }
442
443 if self.commit.types.minor.is_empty() && self.commit.types.patch.is_empty() {
445 return Err(ReleaseError::Config(
446 "commit.types must have at least one minor or patch type".into(),
447 ));
448 }
449
450 let mut channel_names = std::collections::HashSet::new();
452 for ch in &self.channels.content {
453 if !channel_names.insert(&ch.name) {
454 return Err(ReleaseError::Config(format!(
455 "duplicate channel name: {}",
456 ch.name
457 )));
458 }
459 }
460
461 Ok(())
462 }
463
464 pub fn resolve_channel(&self, name: &str) -> Result<&ChannelConfig, ReleaseError> {
466 self.channels
467 .content
468 .iter()
469 .find(|ch| ch.name == name)
470 .ok_or_else(|| {
471 let available: Vec<&str> = self
472 .channels
473 .content
474 .iter()
475 .map(|c| c.name.as_str())
476 .collect();
477 ReleaseError::Config(format!(
478 "channel '{name}' not found. Available: {}",
479 if available.is_empty() {
480 "(none)".to_string()
481 } else {
482 available.join(", ")
483 }
484 ))
485 })
486 }
487
488 pub fn default_channel(&self) -> Result<&ChannelConfig, ReleaseError> {
490 self.resolve_channel(&self.channels.default)
491 }
492
493 pub fn find_package(&self, path: &str) -> Result<&PackageConfig, ReleaseError> {
495 self.packages
496 .iter()
497 .find(|p| p.path == path)
498 .ok_or_else(|| {
499 let available: Vec<&str> = self.packages.iter().map(|p| p.path.as_str()).collect();
500 ReleaseError::Config(format!(
501 "package '{path}' not found. Available: {}",
502 if available.is_empty() {
503 "(none)".to_string()
504 } else {
505 available.join(", ")
506 }
507 ))
508 })
509 }
510
511 pub fn find_package_by_name(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
513 self.packages
514 .iter()
515 .find(|p| p.path.rsplit('/').next().unwrap_or(&p.path) == name)
516 .ok_or_else(|| {
517 let available: Vec<&str> = self
518 .packages
519 .iter()
520 .map(|p| p.path.rsplit('/').next().unwrap_or(&p.path))
521 .collect();
522 ReleaseError::Config(format!(
523 "package '{name}' not found. Available: {}",
524 if available.is_empty() {
525 "(none)".to_string()
526 } else {
527 available.join(", ")
528 }
529 ))
530 })
531 }
532
533 pub fn tag_prefix_for(&self, pkg: &PackageConfig) -> String {
535 if let Some(ref prefix) = pkg.tag_prefix {
536 return prefix.clone();
537 }
538 if pkg.path == "." {
539 self.git.tag_prefix.clone()
540 } else {
541 let dir_name = pkg.path.rsplit('/').next().unwrap_or(&pkg.path);
542 format!("{}/v", dir_name)
543 }
544 }
545
546 pub fn changelog_for<'a>(&'a self, pkg: &'a PackageConfig) -> &'a ChangelogConfig {
548 pkg.changelog.as_ref().unwrap_or(&self.changelog)
549 }
550
551 pub fn version_files_for(&self, pkg: &PackageConfig) -> Vec<String> {
553 if !pkg.version_files.is_empty() {
554 return pkg.version_files.clone();
555 }
556 let detected = detect_version_files(Path::new(&pkg.path));
557 if pkg.path == "." {
558 detected
559 } else {
560 detected
561 .into_iter()
562 .map(|f| format!("{}/{f}", pkg.path))
563 .collect()
564 }
565 }
566
567 pub fn fixed_packages(&self) -> Vec<&PackageConfig> {
569 self.packages.iter().filter(|p| !p.independent).collect()
570 }
571
572 pub fn independent_packages(&self) -> Vec<&PackageConfig> {
574 self.packages.iter().filter(|p| p.independent).collect()
575 }
576
577 pub fn all_artifacts(&self) -> Vec<String> {
579 self.packages
580 .iter()
581 .flat_map(|p| p.artifacts.clone())
582 .collect()
583 }
584}
585
586pub fn default_config_template(version_files: &[String]) -> String {
591 let vf = if version_files.is_empty() {
592 " version_files: []\n".to_string()
593 } else {
594 let mut s = " version_files:\n".to_string();
595 for f in version_files {
596 s.push_str(&format!(" - {f}\n"));
597 }
598 s
599 };
600
601 format!(
602 r#"# sr configuration
603# Full reference: https://github.com/urmzd/sr#configuration
604
605git:
606 tag_prefix: "v"
607 floating_tag: true
608 sign_tags: false
609 v0_protection: true
610 # user:
611 # name: "sr-releaser[bot]"
612 # email: "sr-releaser[bot]@users.noreply.github.com"
613 # Commits whose message contains any of these substrings are excluded from
614 # the release plan and changelog. chore(release): is always filtered.
615 skip_patterns:
616 - "[skip release]"
617 - "[skip sr]"
618
619commit:
620 types:
621 minor:
622 - feat
623 patch:
624 - fix
625 - perf
626 - refactor
627 none:
628 - docs
629 - revert
630 - chore
631 - ci
632 - test
633 - build
634 - style
635
636changelog:
637 file: CHANGELOG.md
638 # template: changelog.md.j2
639 groups:
640 - name: breaking
641 content:
642 - breaking
643 - name: features
644 content:
645 - feat
646 - name: bug-fixes
647 content:
648 - fix
649 - name: performance
650 content:
651 - perf
652 - name: misc
653 content:
654 - chore
655 - ci
656 - test
657 - build
658 - style
659
660channels:
661 default: stable
662 branch: main
663 content:
664 - name: stable
665 # - name: rc
666 # prerelease: rc
667 # draft: true
668 # - name: canary
669 # branch: develop
670 # prerelease: canary
671
672# vcs:
673# github:
674# release_name_template: "{{{{ tag_name }}}}"
675
676packages:
677 - path: .
678{vf} # version_files_strict: false
679 # stage_files: []
680 # artifacts: []
681 # hooks:
682 # # Runs before any mutation: tests, lints. May abort the release.
683 # pre_release:
684 # - cargo test
685 # # Runs after version bump, before tag/commit. Produces the declared
686 # # `artifacts` with the new version embedded. sr verifies every
687 # # artifact glob resolves to >=1 file before tagging.
688 # build:
689 # - cargo build --release
690 # # Runs after tag + GitHub release. Must be idempotent.
691 # post_release:
692 # - cargo publish
693"#
694 )
695}
696
697#[cfg(test)]
702mod tests {
703 use super::*;
704
705 #[test]
706 fn default_values() {
707 let config = Config::default();
708 assert_eq!(config.git.tag_prefix, "v");
709 assert!(config.git.floating_tag);
710 assert!(!config.git.sign_tags);
711 assert_eq!(config.commit.types.minor, vec!["feat"]);
712 assert!(config.commit.types.patch.contains(&"fix".to_string()));
713 assert!(config.commit.types.none.contains(&"chore".to_string()));
714 assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
715 assert!(!config.changelog.groups.is_empty());
716 assert_eq!(config.channels.default, "stable");
717 assert_eq!(config.channels.content.len(), 1);
718 assert_eq!(config.channels.content[0].name, "stable");
719 assert_eq!(config.channels.branch, "main");
720 assert_eq!(config.packages.len(), 1);
721 assert_eq!(config.packages[0].path, ".");
722 }
723
724 #[test]
725 fn load_missing_file() {
726 let dir = tempfile::tempdir().unwrap();
727 let path = dir.path().join("nonexistent.yml");
728 let config = Config::load(&path).unwrap();
729 assert_eq!(config.git.tag_prefix, "v");
730 }
731
732 #[test]
733 fn load_partial_yaml() {
734 let dir = tempfile::tempdir().unwrap();
735 let path = dir.path().join("config.yml");
736 std::fs::write(&path, "git:\n tag_prefix: rel-\n").unwrap();
737
738 let config = Config::load(&path).unwrap();
739 assert_eq!(config.git.tag_prefix, "rel-");
740 assert_eq!(config.channels.default, "stable");
741 }
742
743 #[test]
744 fn load_yaml_with_packages() {
745 let dir = tempfile::tempdir().unwrap();
746 let path = dir.path().join("config.yml");
747 std::fs::write(
748 &path,
749 "packages:\n - path: crates/core\n version_files:\n - crates/core/Cargo.toml\n",
750 )
751 .unwrap();
752
753 let config = Config::load(&path).unwrap();
754 assert_eq!(config.packages.len(), 1);
755 assert_eq!(config.packages[0].path, "crates/core");
756 }
757
758 #[test]
759 fn commit_types_conversion() {
760 let types = CommitTypesConfig::default();
761 let commit_types = types.into_commit_types();
762 let feat = commit_types.iter().find(|t| t.name == "feat").unwrap();
763 assert_eq!(feat.bump, Some(BumpLevel::Minor));
764 let fix = commit_types.iter().find(|t| t.name == "fix").unwrap();
765 assert_eq!(fix.bump, Some(BumpLevel::Patch));
766 let chore = commit_types.iter().find(|t| t.name == "chore").unwrap();
767 assert_eq!(chore.bump, None);
768 }
769
770 #[test]
771 fn all_type_names() {
772 let types = CommitTypesConfig::default();
773 let names = types.all_type_names();
774 assert!(names.contains(&"feat"));
775 assert!(names.contains(&"fix"));
776 assert!(names.contains(&"chore"));
777 }
778
779 #[test]
780 fn resolve_channel() {
781 let config = Config::default();
782 let channel = config.resolve_channel("stable").unwrap();
783 assert!(channel.prerelease.is_none());
784 }
785
786 #[test]
787 fn resolve_channel_not_found() {
788 let config = Config::default();
789 assert!(config.resolve_channel("missing").is_err());
790 }
791
792 #[test]
793 fn tag_prefix_root_package() {
794 let config = Config::default();
795 let pkg = &config.packages[0];
796 assert_eq!(config.tag_prefix_for(pkg), "v");
797 }
798
799 #[test]
800 fn tag_prefix_subpackage() {
801 let config = Config::default();
802 let pkg = PackageConfig {
803 path: "crates/core".into(),
804 ..Default::default()
805 };
806 assert_eq!(config.tag_prefix_for(&pkg), "core/v");
807 }
808
809 #[test]
810 fn tag_prefix_override() {
811 let config = Config::default();
812 let pkg = PackageConfig {
813 path: "crates/cli".into(),
814 tag_prefix: Some("cli-v".into()),
815 ..Default::default()
816 };
817 assert_eq!(config.tag_prefix_for(&pkg), "cli-v");
818 }
819
820 #[test]
821 fn validate_duplicate_types() {
822 let config = Config {
823 commit: CommitConfig {
824 types: CommitTypesConfig {
825 minor: vec!["feat".into()],
826 patch: vec!["feat".into()],
827 none: vec![],
828 },
829 },
830 ..Default::default()
831 };
832 assert!(config.validate().is_err());
833 }
834
835 #[test]
836 fn validate_no_bump_types() {
837 let config = Config {
838 commit: CommitConfig {
839 types: CommitTypesConfig {
840 minor: vec![],
841 patch: vec![],
842 none: vec!["chore".into()],
843 },
844 },
845 ..Default::default()
846 };
847 assert!(config.validate().is_err());
848 }
849
850 #[test]
851 fn validate_duplicate_channels() {
852 let config = Config {
853 channels: ChannelsConfig {
854 default: "stable".into(),
855 branch: "main".into(),
856 content: vec![
857 ChannelConfig {
858 name: "stable".into(),
859 prerelease: None,
860 draft: false,
861 },
862 ChannelConfig {
863 name: "stable".into(),
864 prerelease: None,
865 draft: false,
866 },
867 ],
868 },
869 ..Default::default()
870 };
871 assert!(config.validate().is_err());
872 }
873
874 #[test]
875 fn default_template_parses() {
876 let template = default_config_template(&[]);
877 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
878 assert_eq!(config.git.tag_prefix, "v");
879 assert!(config.git.floating_tag);
880 assert_eq!(config.channels.default, "stable");
881 assert!(
882 config
883 .git
884 .skip_patterns
885 .iter()
886 .any(|p| p == "[skip release]")
887 );
888 }
889
890 #[test]
891 fn default_skip_patterns_present() {
892 let config = Config::default();
893 assert_eq!(
894 config.git.skip_patterns,
895 vec!["[skip release]".to_string(), "[skip sr]".to_string()]
896 );
897 }
898
899 #[test]
900 fn git_user_defaults_to_none() {
901 let config = Config::default();
902 assert!(config.git.user.name.is_none());
903 assert!(config.git.user.email.is_none());
904 }
905
906 #[test]
907 fn git_user_loads_from_yaml() {
908 let dir = tempfile::tempdir().unwrap();
909 let path = dir.path().join("config.yml");
910 std::fs::write(
911 &path,
912 "git:\n user:\n name: \"Bot\"\n email: \"bot@example.com\"\n",
913 )
914 .unwrap();
915 let config = Config::load(&path).unwrap();
916 assert_eq!(config.git.user.name.as_deref(), Some("Bot"));
917 assert_eq!(config.git.user.email.as_deref(), Some("bot@example.com"));
918 }
919
920 #[test]
921 fn default_template_with_version_files() {
922 let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
923 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
924 assert_eq!(
925 config.packages[0].version_files,
926 vec!["Cargo.toml", "package.json"]
927 );
928 }
929
930 #[test]
931 fn find_package_by_name_works() {
932 let config = Config {
933 packages: vec![
934 PackageConfig {
935 path: "crates/core".into(),
936 ..Default::default()
937 },
938 PackageConfig {
939 path: "crates/cli".into(),
940 ..Default::default()
941 },
942 ],
943 ..Default::default()
944 };
945 let pkg = config.find_package_by_name("core").unwrap();
946 assert_eq!(pkg.path, "crates/core");
947 }
948
949 #[test]
950 fn collect_all_artifacts() {
951 let config = Config {
952 packages: vec![
953 PackageConfig {
954 path: "crates/core".into(),
955 artifacts: vec!["core-*".into()],
956 ..Default::default()
957 },
958 PackageConfig {
959 path: "crates/cli".into(),
960 artifacts: vec!["cli-*".into()],
961 ..Default::default()
962 },
963 ],
964 ..Default::default()
965 };
966 let artifacts = config.all_artifacts();
967 assert_eq!(artifacts, vec!["core-*", "cli-*"]);
968 }
969}