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 CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml"];
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(default)]
29pub struct Config {
30 pub git: GitConfig,
31 pub commit: CommitConfig,
32 pub changelog: ChangelogConfig,
33 pub channels: ChannelsConfig,
34 pub vcs: VcsConfig,
35 #[serde(default = "default_packages")]
36 pub packages: Vec<PackageConfig>,
37}
38
39impl Default for Config {
40 fn default() -> Self {
41 Self {
42 git: GitConfig::default(),
43 commit: CommitConfig::default(),
44 changelog: ChangelogConfig::default(),
45 channels: ChannelsConfig::default(),
46 vcs: VcsConfig::default(),
47 packages: default_packages(),
48 }
49 }
50}
51
52fn default_packages() -> Vec<PackageConfig> {
53 vec![PackageConfig {
54 path: ".".into(),
55 ..Default::default()
56 }]
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(default)]
66pub struct GitConfig {
67 pub tag_prefix: String,
69 pub floating_tag: bool,
71 pub sign_tags: bool,
73 pub v0_protection: bool,
76 pub user: GitUserConfig,
79 pub skip_patterns: Vec<String>,
83}
84
85impl Default for GitConfig {
86 fn default() -> Self {
87 Self {
88 tag_prefix: "v".into(),
89 floating_tag: true,
90 sign_tags: false,
91 v0_protection: true,
92 user: GitUserConfig::default(),
93 skip_patterns: default_skip_patterns(),
94 }
95 }
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100#[serde(default)]
101pub struct GitUserConfig {
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub name: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub email: Option<String>,
108}
109
110pub fn default_skip_patterns() -> Vec<String> {
114 vec!["[skip release]".into(), "[skip sr]".into()]
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123#[serde(default)]
124pub struct CommitConfig {
125 pub types: CommitTypesConfig,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(default)]
133pub struct CommitTypesConfig {
134 pub minor: Vec<String>,
136 pub patch: Vec<String>,
138 pub none: Vec<String>,
140}
141
142impl Default for CommitTypesConfig {
143 fn default() -> Self {
144 Self {
145 minor: vec!["feat".into()],
146 patch: vec!["fix".into(), "perf".into(), "refactor".into()],
147 none: vec![
148 "docs".into(),
149 "revert".into(),
150 "chore".into(),
151 "ci".into(),
152 "test".into(),
153 "build".into(),
154 "style".into(),
155 ],
156 }
157 }
158}
159
160impl CommitTypesConfig {
161 pub fn all_type_names(&self) -> Vec<&str> {
163 self.minor
164 .iter()
165 .chain(self.patch.iter())
166 .chain(self.none.iter())
167 .map(|s| s.as_str())
168 .collect()
169 }
170
171 pub fn into_commit_types(&self) -> Vec<CommitType> {
173 let mut types = Vec::new();
174 for name in &self.minor {
175 types.push(CommitType {
176 name: name.clone(),
177 bump: Some(BumpLevel::Minor),
178 });
179 }
180 for name in &self.patch {
181 types.push(CommitType {
182 name: name.clone(),
183 bump: Some(BumpLevel::Patch),
184 });
185 }
186 for name in &self.none {
187 types.push(CommitType {
188 name: name.clone(),
189 bump: None,
190 });
191 }
192 types
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(default)]
203pub struct ChangelogConfig {
204 pub file: Option<String>,
206 pub template: Option<String>,
208 pub groups: Vec<ChangelogGroup>,
210}
211
212impl Default for ChangelogConfig {
213 fn default() -> Self {
214 Self {
215 file: Some("CHANGELOG.md".into()),
216 template: None,
217 groups: default_changelog_groups(),
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct ChangelogGroup {
225 pub name: String,
227 pub content: Vec<String>,
229}
230
231pub fn default_changelog_groups() -> Vec<ChangelogGroup> {
232 vec![
233 ChangelogGroup {
234 name: "breaking".into(),
235 content: vec!["breaking".into()],
236 },
237 ChangelogGroup {
238 name: "features".into(),
239 content: vec!["feat".into()],
240 },
241 ChangelogGroup {
242 name: "bug-fixes".into(),
243 content: vec!["fix".into()],
244 },
245 ChangelogGroup {
246 name: "performance".into(),
247 content: vec!["perf".into()],
248 },
249 ChangelogGroup {
250 name: "refactoring".into(),
251 content: vec!["refactor".into()],
252 },
253 ChangelogGroup {
254 name: "misc".into(),
255 content: vec![
256 "docs".into(),
257 "revert".into(),
258 "chore".into(),
259 "ci".into(),
260 "test".into(),
261 "build".into(),
262 "style".into(),
263 ],
264 },
265 ]
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(default)]
277pub struct ChannelsConfig {
278 pub default: String,
280 pub branch: String,
282 pub content: Vec<ChannelConfig>,
284}
285
286impl Default for ChannelsConfig {
287 fn default() -> Self {
288 Self {
289 default: "stable".into(),
290 branch: "main".into(),
291 content: vec![ChannelConfig {
292 name: "stable".into(),
293 prerelease: None,
294 draft: false,
295 }],
296 }
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct ChannelConfig {
303 pub name: String,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub prerelease: Option<String>,
308 #[serde(default)]
310 pub draft: bool,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, Default)]
319#[serde(default)]
320pub struct VcsConfig {
321 pub github: GitHubConfig,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, Default)]
326#[serde(default)]
327pub struct GitHubConfig {
328 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub release_name_template: Option<String>,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
348#[serde(default)]
349pub struct PackageConfig {
350 pub path: String,
353 pub version_files: Vec<String>,
356 pub version_files_strict: bool,
358 pub stage_files: Vec<String>,
360 pub artifacts: Vec<String>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub changelog: Option<ChangelogConfig>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub publish: Option<PublishConfig>,
369}
370
371impl Default for PackageConfig {
372 fn default() -> Self {
373 Self {
374 path: ".".into(),
375 version_files: vec![],
376 version_files_strict: false,
377 stage_files: vec![],
378 artifacts: vec![],
379 changelog: None,
380 publish: None,
381 }
382 }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(tag = "type", rename_all = "lowercase")]
394pub enum PublishConfig {
395 Cargo {
397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 features: Vec<String>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
403 registry: Option<String>,
404 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
408 workspace: bool,
409 },
410 Npm {
413 #[serde(default, skip_serializing_if = "Option::is_none")]
415 registry: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
418 access: Option<String>,
419 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
423 workspace: bool,
424 },
425 Docker {
427 image: String,
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
432 platforms: Vec<String>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
435 dockerfile: Option<String>,
436 },
437 Pypi {
439 #[serde(default, skip_serializing_if = "Option::is_none")]
441 repository: Option<String>,
442 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
445 workspace: bool,
446 },
447 Go,
450 Custom {
454 command: String,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
460 check: Option<String>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
463 cwd: Option<String>,
464 },
465}
466
467impl Config {
472 pub fn find_config(dir: &Path) -> Option<std::path::PathBuf> {
474 for &candidate in CONFIG_CANDIDATES {
475 let path = dir.join(candidate);
476 if path.exists() {
477 return Some(path);
478 }
479 }
480 None
481 }
482
483 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
485 if !path.exists() {
486 return Ok(Self::default());
487 }
488 let contents =
489 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
490 let config: Self =
491 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))?;
492 config.validate()?;
493 Ok(config)
494 }
495
496 fn validate(&self) -> Result<(), ReleaseError> {
498 let mut seen = std::collections::HashSet::new();
500 for name in self.commit.types.all_type_names() {
501 if !seen.insert(name) {
502 return Err(ReleaseError::Config(format!(
503 "duplicate commit type: {name}"
504 )));
505 }
506 }
507
508 if self.commit.types.minor.is_empty() && self.commit.types.patch.is_empty() {
510 return Err(ReleaseError::Config(
511 "commit.types must have at least one minor or patch type".into(),
512 ));
513 }
514
515 let mut channel_names = std::collections::HashSet::new();
517 for ch in &self.channels.content {
518 if !channel_names.insert(&ch.name) {
519 return Err(ReleaseError::Config(format!(
520 "duplicate channel name: {}",
521 ch.name
522 )));
523 }
524 }
525
526 Ok(())
527 }
528
529 pub fn resolve_channel(&self, name: &str) -> Result<&ChannelConfig, ReleaseError> {
531 self.channels
532 .content
533 .iter()
534 .find(|ch| ch.name == name)
535 .ok_or_else(|| {
536 let available: Vec<&str> = self
537 .channels
538 .content
539 .iter()
540 .map(|c| c.name.as_str())
541 .collect();
542 ReleaseError::Config(format!(
543 "channel '{name}' not found. Available: {}",
544 if available.is_empty() {
545 "(none)".to_string()
546 } else {
547 available.join(", ")
548 }
549 ))
550 })
551 }
552
553 pub fn default_channel(&self) -> Result<&ChannelConfig, ReleaseError> {
555 self.resolve_channel(&self.channels.default)
556 }
557
558 pub fn find_package(&self, path: &str) -> Result<&PackageConfig, ReleaseError> {
560 self.packages
561 .iter()
562 .find(|p| p.path == path)
563 .ok_or_else(|| {
564 let available: Vec<&str> = self.packages.iter().map(|p| p.path.as_str()).collect();
565 ReleaseError::Config(format!(
566 "package '{path}' not found. Available: {}",
567 if available.is_empty() {
568 "(none)".to_string()
569 } else {
570 available.join(", ")
571 }
572 ))
573 })
574 }
575
576 pub fn find_package_by_name(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
578 self.packages
579 .iter()
580 .find(|p| p.path.rsplit('/').next().unwrap_or(&p.path) == name)
581 .ok_or_else(|| {
582 let available: Vec<&str> = self
583 .packages
584 .iter()
585 .map(|p| p.path.rsplit('/').next().unwrap_or(&p.path))
586 .collect();
587 ReleaseError::Config(format!(
588 "package '{name}' not found. Available: {}",
589 if available.is_empty() {
590 "(none)".to_string()
591 } else {
592 available.join(", ")
593 }
594 ))
595 })
596 }
597
598 pub fn changelog_for<'a>(&'a self, pkg: &'a PackageConfig) -> &'a ChangelogConfig {
600 pkg.changelog.as_ref().unwrap_or(&self.changelog)
601 }
602
603 pub fn version_files_for(&self, pkg: &PackageConfig) -> Vec<String> {
605 if !pkg.version_files.is_empty() {
606 return pkg.version_files.clone();
607 }
608 let detected = detect_version_files(Path::new(&pkg.path));
609 if pkg.path == "." {
610 detected
611 } else {
612 detected
613 .into_iter()
614 .map(|f| format!("{}/{f}", pkg.path))
615 .collect()
616 }
617 }
618
619 pub fn all_artifacts(&self) -> Vec<String> {
621 self.packages
622 .iter()
623 .flat_map(|p| p.artifacts.clone())
624 .collect()
625 }
626}
627
628pub fn default_config_template(version_files: &[String]) -> String {
633 let vf = if version_files.is_empty() {
634 " version_files: []\n".to_string()
635 } else {
636 let mut s = " version_files:\n".to_string();
637 for f in version_files {
638 s.push_str(&format!(" - {f}\n"));
639 }
640 s
641 };
642
643 format!(
644 r#"# sr configuration
645# Full reference: https://github.com/urmzd/sr#configuration
646
647git:
648 tag_prefix: "v"
649 floating_tag: true
650 sign_tags: false
651 v0_protection: true
652 # user:
653 # name: "sr-releaser[bot]"
654 # email: "sr-releaser[bot]@users.noreply.github.com"
655 # Commits whose message contains any of these substrings are excluded from
656 # the release plan and changelog. chore(release): is always filtered.
657 skip_patterns:
658 - "[skip release]"
659 - "[skip sr]"
660
661commit:
662 types:
663 minor:
664 - feat
665 patch:
666 - fix
667 - perf
668 - refactor
669 none:
670 - docs
671 - revert
672 - chore
673 - ci
674 - test
675 - build
676 - style
677
678changelog:
679 file: CHANGELOG.md
680 # template: changelog.md.j2
681 groups:
682 - name: breaking
683 content:
684 - breaking
685 - name: features
686 content:
687 - feat
688 - name: bug-fixes
689 content:
690 - fix
691 - name: performance
692 content:
693 - perf
694 - name: misc
695 content:
696 - chore
697 - ci
698 - test
699 - build
700 - style
701
702channels:
703 default: stable
704 branch: main
705 content:
706 - name: stable
707 # - name: rc
708 # prerelease: rc
709 # draft: true
710 # - name: canary
711 # branch: develop
712 # prerelease: canary
713
714# vcs:
715# github:
716# release_name_template: "{{{{ tag_name }}}}"
717
718# Repo-wide lifecycle hooks. Run once per release.
719# hooks:
720# # Runs before any mutation: tests, lints. May abort the release.
721# pre_release:
722# - cargo test
723# # Runs after tag + GitHub release.
724# post_release:
725# - echo "released $SR_VERSION"
726
727packages:
728 - path: .
729{vf} # version_files_strict: false
730 # stage_files: []
731 # artifacts: []
732 # # Build commands produce this package's declared `artifacts`.
733 # # Runs after version bump, before commit.
734 # build:
735 # - cargo build --release
736 # # Per-package publish target for `sr publish`. Idempotent.
737 # publish:
738 # command: cargo publish
739"#
740 )
741}
742
743#[cfg(test)]
748mod tests {
749 use super::*;
750
751 #[test]
752 fn default_values() {
753 let config = Config::default();
754 assert_eq!(config.git.tag_prefix, "v");
755 assert!(config.git.floating_tag);
756 assert!(!config.git.sign_tags);
757 assert_eq!(config.commit.types.minor, vec!["feat"]);
758 assert!(config.commit.types.patch.contains(&"fix".to_string()));
759 assert!(config.commit.types.none.contains(&"chore".to_string()));
760 assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
761 assert!(!config.changelog.groups.is_empty());
762 assert_eq!(config.channels.default, "stable");
763 assert_eq!(config.channels.content.len(), 1);
764 assert_eq!(config.channels.content[0].name, "stable");
765 assert_eq!(config.channels.branch, "main");
766 assert_eq!(config.packages.len(), 1);
767 assert_eq!(config.packages[0].path, ".");
768 }
769
770 #[test]
771 fn load_missing_file() {
772 let dir = tempfile::tempdir().unwrap();
773 let path = dir.path().join("nonexistent.yml");
774 let config = Config::load(&path).unwrap();
775 assert_eq!(config.git.tag_prefix, "v");
776 }
777
778 #[test]
779 fn load_partial_yaml() {
780 let dir = tempfile::tempdir().unwrap();
781 let path = dir.path().join("config.yml");
782 std::fs::write(&path, "git:\n tag_prefix: rel-\n").unwrap();
783
784 let config = Config::load(&path).unwrap();
785 assert_eq!(config.git.tag_prefix, "rel-");
786 assert_eq!(config.channels.default, "stable");
787 }
788
789 #[test]
790 fn load_yaml_with_packages() {
791 let dir = tempfile::tempdir().unwrap();
792 let path = dir.path().join("config.yml");
793 std::fs::write(
794 &path,
795 "packages:\n - path: crates/core\n version_files:\n - crates/core/Cargo.toml\n",
796 )
797 .unwrap();
798
799 let config = Config::load(&path).unwrap();
800 assert_eq!(config.packages.len(), 1);
801 assert_eq!(config.packages[0].path, "crates/core");
802 }
803
804 #[test]
805 fn commit_types_conversion() {
806 let types = CommitTypesConfig::default();
807 let commit_types = types.into_commit_types();
808 let feat = commit_types.iter().find(|t| t.name == "feat").unwrap();
809 assert_eq!(feat.bump, Some(BumpLevel::Minor));
810 let fix = commit_types.iter().find(|t| t.name == "fix").unwrap();
811 assert_eq!(fix.bump, Some(BumpLevel::Patch));
812 let chore = commit_types.iter().find(|t| t.name == "chore").unwrap();
813 assert_eq!(chore.bump, None);
814 }
815
816 #[test]
817 fn all_type_names() {
818 let types = CommitTypesConfig::default();
819 let names = types.all_type_names();
820 assert!(names.contains(&"feat"));
821 assert!(names.contains(&"fix"));
822 assert!(names.contains(&"chore"));
823 }
824
825 #[test]
826 fn resolve_channel() {
827 let config = Config::default();
828 let channel = config.resolve_channel("stable").unwrap();
829 assert!(channel.prerelease.is_none());
830 }
831
832 #[test]
833 fn resolve_channel_not_found() {
834 let config = Config::default();
835 assert!(config.resolve_channel("missing").is_err());
836 }
837
838 #[test]
839 fn validate_duplicate_types() {
840 let config = Config {
841 commit: CommitConfig {
842 types: CommitTypesConfig {
843 minor: vec!["feat".into()],
844 patch: vec!["feat".into()],
845 none: vec![],
846 },
847 },
848 ..Default::default()
849 };
850 assert!(config.validate().is_err());
851 }
852
853 #[test]
854 fn validate_no_bump_types() {
855 let config = Config {
856 commit: CommitConfig {
857 types: CommitTypesConfig {
858 minor: vec![],
859 patch: vec![],
860 none: vec!["chore".into()],
861 },
862 },
863 ..Default::default()
864 };
865 assert!(config.validate().is_err());
866 }
867
868 #[test]
869 fn validate_duplicate_channels() {
870 let config = Config {
871 channels: ChannelsConfig {
872 default: "stable".into(),
873 branch: "main".into(),
874 content: vec![
875 ChannelConfig {
876 name: "stable".into(),
877 prerelease: None,
878 draft: false,
879 },
880 ChannelConfig {
881 name: "stable".into(),
882 prerelease: None,
883 draft: false,
884 },
885 ],
886 },
887 ..Default::default()
888 };
889 assert!(config.validate().is_err());
890 }
891
892 #[test]
893 fn default_template_parses() {
894 let template = default_config_template(&[]);
895 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
896 assert_eq!(config.git.tag_prefix, "v");
897 assert!(config.git.floating_tag);
898 assert_eq!(config.channels.default, "stable");
899 assert!(
900 config
901 .git
902 .skip_patterns
903 .iter()
904 .any(|p| p == "[skip release]")
905 );
906 }
907
908 #[test]
909 fn default_skip_patterns_present() {
910 let config = Config::default();
911 assert_eq!(
912 config.git.skip_patterns,
913 vec!["[skip release]".to_string(), "[skip sr]".to_string()]
914 );
915 }
916
917 #[test]
918 fn git_user_defaults_to_none() {
919 let config = Config::default();
920 assert!(config.git.user.name.is_none());
921 assert!(config.git.user.email.is_none());
922 }
923
924 #[test]
925 fn git_user_loads_from_yaml() {
926 let dir = tempfile::tempdir().unwrap();
927 let path = dir.path().join("config.yml");
928 std::fs::write(
929 &path,
930 "git:\n user:\n name: \"Bot\"\n email: \"bot@example.com\"\n",
931 )
932 .unwrap();
933 let config = Config::load(&path).unwrap();
934 assert_eq!(config.git.user.name.as_deref(), Some("Bot"));
935 assert_eq!(config.git.user.email.as_deref(), Some("bot@example.com"));
936 }
937
938 #[test]
939 fn default_template_with_version_files() {
940 let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
941 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
942 assert_eq!(
943 config.packages[0].version_files,
944 vec!["Cargo.toml", "package.json"]
945 );
946 }
947
948 #[test]
949 fn find_package_by_name_works() {
950 let config = Config {
951 packages: vec![
952 PackageConfig {
953 path: "crates/core".into(),
954 ..Default::default()
955 },
956 PackageConfig {
957 path: "crates/cli".into(),
958 ..Default::default()
959 },
960 ],
961 ..Default::default()
962 };
963 let pkg = config.find_package_by_name("core").unwrap();
964 assert_eq!(pkg.path, "crates/core");
965 }
966
967 #[test]
968 fn collect_all_artifacts() {
969 let config = Config {
970 packages: vec![
971 PackageConfig {
972 path: "crates/core".into(),
973 artifacts: vec!["core-*".into()],
974 ..Default::default()
975 },
976 PackageConfig {
977 path: "crates/cli".into(),
978 artifacts: vec!["cli-*".into()],
979 ..Default::default()
980 },
981 ],
982 ..Default::default()
983 };
984 let artifacts = config.all_artifacts();
985 assert_eq!(artifacts, vec!["core-*", "cli-*"]);
986 }
987}