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 {
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 repository: Option<String>,
446 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
449 workspace: bool,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
453 dist_dir: Option<String>,
454 },
455 Go,
458 Custom {
462 command: String,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
468 check: Option<String>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 cwd: Option<String>,
472 },
473}
474
475impl Config {
480 pub fn find_config(dir: &Path) -> Option<std::path::PathBuf> {
482 for &candidate in CONFIG_CANDIDATES {
483 let path = dir.join(candidate);
484 if path.exists() {
485 return Some(path);
486 }
487 }
488 None
489 }
490
491 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
493 if !path.exists() {
494 return Ok(Self::default());
495 }
496 let contents =
497 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
498 let config: Self =
499 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))?;
500 config.validate()?;
501 Ok(config)
502 }
503
504 fn validate(&self) -> Result<(), ReleaseError> {
506 let mut seen = std::collections::HashSet::new();
508 for name in self.commit.types.all_type_names() {
509 if !seen.insert(name) {
510 return Err(ReleaseError::Config(format!(
511 "duplicate commit type: {name}"
512 )));
513 }
514 }
515
516 if self.commit.types.minor.is_empty() && self.commit.types.patch.is_empty() {
518 return Err(ReleaseError::Config(
519 "commit.types must have at least one minor or patch type".into(),
520 ));
521 }
522
523 let mut channel_names = std::collections::HashSet::new();
525 for ch in &self.channels.content {
526 if !channel_names.insert(&ch.name) {
527 return Err(ReleaseError::Config(format!(
528 "duplicate channel name: {}",
529 ch.name
530 )));
531 }
532 }
533
534 Ok(())
535 }
536
537 pub fn resolve_channel(&self, name: &str) -> Result<&ChannelConfig, ReleaseError> {
539 self.channels
540 .content
541 .iter()
542 .find(|ch| ch.name == name)
543 .ok_or_else(|| {
544 let available: Vec<&str> = self
545 .channels
546 .content
547 .iter()
548 .map(|c| c.name.as_str())
549 .collect();
550 ReleaseError::Config(format!(
551 "channel '{name}' not found. Available: {}",
552 if available.is_empty() {
553 "(none)".to_string()
554 } else {
555 available.join(", ")
556 }
557 ))
558 })
559 }
560
561 pub fn default_channel(&self) -> Result<&ChannelConfig, ReleaseError> {
563 self.resolve_channel(&self.channels.default)
564 }
565
566 pub fn find_package(&self, path: &str) -> Result<&PackageConfig, ReleaseError> {
568 self.packages
569 .iter()
570 .find(|p| p.path == path)
571 .ok_or_else(|| {
572 let available: Vec<&str> = self.packages.iter().map(|p| p.path.as_str()).collect();
573 ReleaseError::Config(format!(
574 "package '{path}' not found. Available: {}",
575 if available.is_empty() {
576 "(none)".to_string()
577 } else {
578 available.join(", ")
579 }
580 ))
581 })
582 }
583
584 pub fn find_package_by_name(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
586 self.packages
587 .iter()
588 .find(|p| p.path.rsplit('/').next().unwrap_or(&p.path) == name)
589 .ok_or_else(|| {
590 let available: Vec<&str> = self
591 .packages
592 .iter()
593 .map(|p| p.path.rsplit('/').next().unwrap_or(&p.path))
594 .collect();
595 ReleaseError::Config(format!(
596 "package '{name}' not found. Available: {}",
597 if available.is_empty() {
598 "(none)".to_string()
599 } else {
600 available.join(", ")
601 }
602 ))
603 })
604 }
605
606 pub fn changelog_for<'a>(&'a self, pkg: &'a PackageConfig) -> &'a ChangelogConfig {
608 pkg.changelog.as_ref().unwrap_or(&self.changelog)
609 }
610
611 pub fn version_files_for(&self, pkg: &PackageConfig) -> Vec<String> {
613 if !pkg.version_files.is_empty() {
614 return pkg.version_files.clone();
615 }
616 let detected = detect_version_files(Path::new(&pkg.path));
617 if pkg.path == "." {
618 detected
619 } else {
620 detected
621 .into_iter()
622 .map(|f| format!("{}/{f}", pkg.path))
623 .collect()
624 }
625 }
626
627 pub fn all_artifacts(&self) -> Vec<String> {
629 self.packages
630 .iter()
631 .flat_map(|p| p.artifacts.clone())
632 .collect()
633 }
634}
635
636pub fn default_config_template(version_files: &[String]) -> String {
641 let vf = if version_files.is_empty() {
642 " version_files: []\n".to_string()
643 } else {
644 let mut s = " version_files:\n".to_string();
645 for f in version_files {
646 s.push_str(&format!(" - {f}\n"));
647 }
648 s
649 };
650
651 format!(
652 r#"# sr configuration
653# Full reference: https://github.com/urmzd/sr#configuration
654
655git:
656 tag_prefix: "v"
657 floating_tag: true
658 sign_tags: false
659 v0_protection: true
660 # user:
661 # name: "sr-releaser[bot]"
662 # email: "sr-releaser[bot]@users.noreply.github.com"
663 # Commits whose message contains any of these substrings are excluded from
664 # the release plan and changelog. chore(release): is always filtered.
665 skip_patterns:
666 - "[skip release]"
667 - "[skip sr]"
668
669commit:
670 types:
671 minor:
672 - feat
673 patch:
674 - fix
675 - perf
676 - refactor
677 none:
678 - docs
679 - revert
680 - chore
681 - ci
682 - test
683 - build
684 - style
685
686changelog:
687 file: CHANGELOG.md
688 # template: changelog.md.j2
689 groups:
690 - name: breaking
691 content:
692 - breaking
693 - name: features
694 content:
695 - feat
696 - name: bug-fixes
697 content:
698 - fix
699 - name: performance
700 content:
701 - perf
702 - name: misc
703 content:
704 - chore
705 - ci
706 - test
707 - build
708 - style
709
710channels:
711 default: stable
712 branch: main
713 content:
714 - name: stable
715 # - name: rc
716 # prerelease: rc
717 # draft: true
718 # - name: canary
719 # branch: develop
720 # prerelease: canary
721
722# vcs:
723# github:
724# release_name_template: "{{{{ tag_name }}}}"
725
726# Repo-wide lifecycle hooks. Run once per release.
727# hooks:
728# # Runs before any mutation: tests, lints. May abort the release.
729# pre_release:
730# - cargo test
731# # Runs after tag + GitHub release.
732# post_release:
733# - echo "released $SR_VERSION"
734
735packages:
736 - path: .
737{vf} # version_files_strict: false
738 # stage_files: []
739 # artifacts: []
740 # # Build commands produce this package's declared `artifacts`.
741 # # Runs after version bump, before commit.
742 # build:
743 # - cargo build --release
744 # # Per-package publish target for `sr publish`. Idempotent.
745 # publish:
746 # command: cargo publish
747"#
748 )
749}
750
751#[cfg(test)]
756mod tests {
757 use super::*;
758
759 #[test]
760 fn default_values() {
761 let config = Config::default();
762 assert_eq!(config.git.tag_prefix, "v");
763 assert!(config.git.floating_tag);
764 assert!(!config.git.sign_tags);
765 assert_eq!(config.commit.types.minor, vec!["feat"]);
766 assert!(config.commit.types.patch.contains(&"fix".to_string()));
767 assert!(config.commit.types.none.contains(&"chore".to_string()));
768 assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
769 assert!(!config.changelog.groups.is_empty());
770 assert_eq!(config.channels.default, "stable");
771 assert_eq!(config.channels.content.len(), 1);
772 assert_eq!(config.channels.content[0].name, "stable");
773 assert_eq!(config.channels.branch, "main");
774 assert_eq!(config.packages.len(), 1);
775 assert_eq!(config.packages[0].path, ".");
776 }
777
778 #[test]
779 fn load_missing_file() {
780 let dir = tempfile::tempdir().unwrap();
781 let path = dir.path().join("nonexistent.yml");
782 let config = Config::load(&path).unwrap();
783 assert_eq!(config.git.tag_prefix, "v");
784 }
785
786 #[test]
787 fn load_partial_yaml() {
788 let dir = tempfile::tempdir().unwrap();
789 let path = dir.path().join("config.yml");
790 std::fs::write(&path, "git:\n tag_prefix: rel-\n").unwrap();
791
792 let config = Config::load(&path).unwrap();
793 assert_eq!(config.git.tag_prefix, "rel-");
794 assert_eq!(config.channels.default, "stable");
795 }
796
797 #[test]
798 fn load_yaml_with_packages() {
799 let dir = tempfile::tempdir().unwrap();
800 let path = dir.path().join("config.yml");
801 std::fs::write(
802 &path,
803 "packages:\n - path: crates/core\n version_files:\n - crates/core/Cargo.toml\n",
804 )
805 .unwrap();
806
807 let config = Config::load(&path).unwrap();
808 assert_eq!(config.packages.len(), 1);
809 assert_eq!(config.packages[0].path, "crates/core");
810 }
811
812 #[test]
813 fn commit_types_conversion() {
814 let types = CommitTypesConfig::default();
815 let commit_types = types.into_commit_types();
816 let feat = commit_types.iter().find(|t| t.name == "feat").unwrap();
817 assert_eq!(feat.bump, Some(BumpLevel::Minor));
818 let fix = commit_types.iter().find(|t| t.name == "fix").unwrap();
819 assert_eq!(fix.bump, Some(BumpLevel::Patch));
820 let chore = commit_types.iter().find(|t| t.name == "chore").unwrap();
821 assert_eq!(chore.bump, None);
822 }
823
824 #[test]
825 fn all_type_names() {
826 let types = CommitTypesConfig::default();
827 let names = types.all_type_names();
828 assert!(names.contains(&"feat"));
829 assert!(names.contains(&"fix"));
830 assert!(names.contains(&"chore"));
831 }
832
833 #[test]
834 fn resolve_channel() {
835 let config = Config::default();
836 let channel = config.resolve_channel("stable").unwrap();
837 assert!(channel.prerelease.is_none());
838 }
839
840 #[test]
841 fn resolve_channel_not_found() {
842 let config = Config::default();
843 assert!(config.resolve_channel("missing").is_err());
844 }
845
846 #[test]
847 fn validate_duplicate_types() {
848 let config = Config {
849 commit: CommitConfig {
850 types: CommitTypesConfig {
851 minor: vec!["feat".into()],
852 patch: vec!["feat".into()],
853 none: vec![],
854 },
855 },
856 ..Default::default()
857 };
858 assert!(config.validate().is_err());
859 }
860
861 #[test]
862 fn validate_no_bump_types() {
863 let config = Config {
864 commit: CommitConfig {
865 types: CommitTypesConfig {
866 minor: vec![],
867 patch: vec![],
868 none: vec!["chore".into()],
869 },
870 },
871 ..Default::default()
872 };
873 assert!(config.validate().is_err());
874 }
875
876 #[test]
877 fn validate_duplicate_channels() {
878 let config = Config {
879 channels: ChannelsConfig {
880 default: "stable".into(),
881 branch: "main".into(),
882 content: vec![
883 ChannelConfig {
884 name: "stable".into(),
885 prerelease: None,
886 draft: false,
887 },
888 ChannelConfig {
889 name: "stable".into(),
890 prerelease: None,
891 draft: false,
892 },
893 ],
894 },
895 ..Default::default()
896 };
897 assert!(config.validate().is_err());
898 }
899
900 #[test]
901 fn default_template_parses() {
902 let template = default_config_template(&[]);
903 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
904 assert_eq!(config.git.tag_prefix, "v");
905 assert!(config.git.floating_tag);
906 assert_eq!(config.channels.default, "stable");
907 assert!(
908 config
909 .git
910 .skip_patterns
911 .iter()
912 .any(|p| p == "[skip release]")
913 );
914 }
915
916 #[test]
917 fn default_skip_patterns_present() {
918 let config = Config::default();
919 assert_eq!(
920 config.git.skip_patterns,
921 vec!["[skip release]".to_string(), "[skip sr]".to_string()]
922 );
923 }
924
925 #[test]
926 fn git_user_defaults_to_none() {
927 let config = Config::default();
928 assert!(config.git.user.name.is_none());
929 assert!(config.git.user.email.is_none());
930 }
931
932 #[test]
933 fn git_user_loads_from_yaml() {
934 let dir = tempfile::tempdir().unwrap();
935 let path = dir.path().join("config.yml");
936 std::fs::write(
937 &path,
938 "git:\n user:\n name: \"Bot\"\n email: \"bot@example.com\"\n",
939 )
940 .unwrap();
941 let config = Config::load(&path).unwrap();
942 assert_eq!(config.git.user.name.as_deref(), Some("Bot"));
943 assert_eq!(config.git.user.email.as_deref(), Some("bot@example.com"));
944 }
945
946 #[test]
947 fn default_template_with_version_files() {
948 let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
949 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
950 assert_eq!(
951 config.packages[0].version_files,
952 vec!["Cargo.toml", "package.json"]
953 );
954 }
955
956 #[test]
957 fn find_package_by_name_works() {
958 let config = Config {
959 packages: vec![
960 PackageConfig {
961 path: "crates/core".into(),
962 ..Default::default()
963 },
964 PackageConfig {
965 path: "crates/cli".into(),
966 ..Default::default()
967 },
968 ],
969 ..Default::default()
970 };
971 let pkg = config.find_package_by_name("core").unwrap();
972 assert_eq!(pkg.path, "crates/core");
973 }
974
975 #[test]
976 fn collect_all_artifacts() {
977 let config = Config {
978 packages: vec![
979 PackageConfig {
980 path: "crates/core".into(),
981 artifacts: vec!["core-*".into()],
982 ..Default::default()
983 },
984 PackageConfig {
985 path: "crates/cli".into(),
986 artifacts: vec!["cli-*".into()],
987 ..Default::default()
988 },
989 ],
990 ..Default::default()
991 };
992 let artifacts = config.all_artifacts();
993 assert_eq!(artifacts, vec!["core-*", "cli-*"]);
994 }
995}