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}
80
81impl Default for GitConfig {
82 fn default() -> Self {
83 Self {
84 tag_prefix: "v".into(),
85 floating_tag: true,
86 sign_tags: false,
87 v0_protection: true,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98#[serde(default)]
99pub struct CommitConfig {
100 pub types: CommitTypesConfig,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(default)]
108pub struct CommitTypesConfig {
109 pub minor: Vec<String>,
111 pub patch: Vec<String>,
113 pub none: Vec<String>,
115}
116
117impl Default for CommitTypesConfig {
118 fn default() -> Self {
119 Self {
120 minor: vec!["feat".into()],
121 patch: vec!["fix".into(), "perf".into(), "refactor".into()],
122 none: vec![
123 "docs".into(),
124 "revert".into(),
125 "chore".into(),
126 "ci".into(),
127 "test".into(),
128 "build".into(),
129 "style".into(),
130 ],
131 }
132 }
133}
134
135impl CommitTypesConfig {
136 pub fn all_type_names(&self) -> Vec<&str> {
138 self.minor
139 .iter()
140 .chain(self.patch.iter())
141 .chain(self.none.iter())
142 .map(|s| s.as_str())
143 .collect()
144 }
145
146 pub fn into_commit_types(&self) -> Vec<CommitType> {
148 let mut types = Vec::new();
149 for name in &self.minor {
150 types.push(CommitType {
151 name: name.clone(),
152 bump: Some(BumpLevel::Minor),
153 });
154 }
155 for name in &self.patch {
156 types.push(CommitType {
157 name: name.clone(),
158 bump: Some(BumpLevel::Patch),
159 });
160 }
161 for name in &self.none {
162 types.push(CommitType {
163 name: name.clone(),
164 bump: None,
165 });
166 }
167 types
168 }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(default)]
178pub struct ChangelogConfig {
179 pub file: Option<String>,
181 pub template: Option<String>,
183 pub groups: Vec<ChangelogGroup>,
185}
186
187impl Default for ChangelogConfig {
188 fn default() -> Self {
189 Self {
190 file: Some("CHANGELOG.md".into()),
191 template: None,
192 groups: default_changelog_groups(),
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ChangelogGroup {
200 pub name: String,
202 pub content: Vec<String>,
204}
205
206pub fn default_changelog_groups() -> Vec<ChangelogGroup> {
207 vec![
208 ChangelogGroup {
209 name: "breaking".into(),
210 content: vec!["breaking".into()],
211 },
212 ChangelogGroup {
213 name: "features".into(),
214 content: vec!["feat".into()],
215 },
216 ChangelogGroup {
217 name: "bug-fixes".into(),
218 content: vec!["fix".into()],
219 },
220 ChangelogGroup {
221 name: "performance".into(),
222 content: vec!["perf".into()],
223 },
224 ChangelogGroup {
225 name: "refactoring".into(),
226 content: vec!["refactor".into()],
227 },
228 ChangelogGroup {
229 name: "misc".into(),
230 content: vec![
231 "docs".into(),
232 "revert".into(),
233 "chore".into(),
234 "ci".into(),
235 "test".into(),
236 "build".into(),
237 "style".into(),
238 ],
239 },
240 ]
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(default)]
252pub struct ChannelsConfig {
253 pub default: String,
255 pub branch: String,
257 pub content: Vec<ChannelConfig>,
259}
260
261impl Default for ChannelsConfig {
262 fn default() -> Self {
263 Self {
264 default: "stable".into(),
265 branch: "main".into(),
266 content: vec![ChannelConfig {
267 name: "stable".into(),
268 prerelease: None,
269 draft: false,
270 }],
271 }
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct ChannelConfig {
278 pub name: String,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub prerelease: Option<String>,
283 #[serde(default)]
285 pub draft: bool,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, Default)]
294#[serde(default)]
295pub struct VcsConfig {
296 pub github: GitHubConfig,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, Default)]
301#[serde(default)]
302pub struct GitHubConfig {
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub release_name_template: Option<String>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
314#[serde(default)]
315pub struct PackageConfig {
316 pub path: String,
318 pub independent: bool,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub tag_prefix: Option<String>,
323 pub version_files: Vec<String>,
325 pub version_files_strict: bool,
327 pub stage_files: Vec<String>,
329 pub artifacts: Vec<String>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub changelog: Option<ChangelogConfig>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub hooks: Option<HooksConfig>,
337}
338
339impl Default for PackageConfig {
340 fn default() -> Self {
341 Self {
342 path: ".".into(),
343 independent: false,
344 tag_prefix: None,
345 version_files: vec![],
346 version_files_strict: false,
347 stage_files: vec![],
348 artifacts: vec![],
349 changelog: None,
350 hooks: None,
351 }
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, Default)]
357#[serde(default)]
358pub struct HooksConfig {
359 pub pre_release: Vec<String>,
361 pub post_release: Vec<String>,
363}
364
365impl Config {
370 pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
372 for &candidate in CONFIG_CANDIDATES {
373 let path = dir.join(candidate);
374 if path.exists() {
375 let is_legacy = candidate == LEGACY_CONFIG_FILE;
376 return Some((path, is_legacy));
377 }
378 }
379 None
380 }
381
382 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
384 if !path.exists() {
385 return Ok(Self::default());
386 }
387 let contents =
388 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
389 let config: Self =
390 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))?;
391 config.validate()?;
392 Ok(config)
393 }
394
395 fn validate(&self) -> Result<(), ReleaseError> {
397 let mut seen = std::collections::HashSet::new();
399 for name in self.commit.types.all_type_names() {
400 if !seen.insert(name) {
401 return Err(ReleaseError::Config(format!(
402 "duplicate commit type: {name}"
403 )));
404 }
405 }
406
407 if self.commit.types.minor.is_empty() && self.commit.types.patch.is_empty() {
409 return Err(ReleaseError::Config(
410 "commit.types must have at least one minor or patch type".into(),
411 ));
412 }
413
414 let mut channel_names = std::collections::HashSet::new();
416 for ch in &self.channels.content {
417 if !channel_names.insert(&ch.name) {
418 return Err(ReleaseError::Config(format!(
419 "duplicate channel name: {}",
420 ch.name
421 )));
422 }
423 }
424
425 Ok(())
426 }
427
428 pub fn resolve_channel(&self, name: &str) -> Result<&ChannelConfig, ReleaseError> {
430 self.channels
431 .content
432 .iter()
433 .find(|ch| ch.name == name)
434 .ok_or_else(|| {
435 let available: Vec<&str> = self
436 .channels
437 .content
438 .iter()
439 .map(|c| c.name.as_str())
440 .collect();
441 ReleaseError::Config(format!(
442 "channel '{name}' not found. Available: {}",
443 if available.is_empty() {
444 "(none)".to_string()
445 } else {
446 available.join(", ")
447 }
448 ))
449 })
450 }
451
452 pub fn default_channel(&self) -> Result<&ChannelConfig, ReleaseError> {
454 self.resolve_channel(&self.channels.default)
455 }
456
457 pub fn find_package(&self, path: &str) -> Result<&PackageConfig, ReleaseError> {
459 self.packages
460 .iter()
461 .find(|p| p.path == path)
462 .ok_or_else(|| {
463 let available: Vec<&str> = self.packages.iter().map(|p| p.path.as_str()).collect();
464 ReleaseError::Config(format!(
465 "package '{path}' not found. Available: {}",
466 if available.is_empty() {
467 "(none)".to_string()
468 } else {
469 available.join(", ")
470 }
471 ))
472 })
473 }
474
475 pub fn find_package_by_name(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
477 self.packages
478 .iter()
479 .find(|p| p.path.rsplit('/').next().unwrap_or(&p.path) == name)
480 .ok_or_else(|| {
481 let available: Vec<&str> = self
482 .packages
483 .iter()
484 .map(|p| p.path.rsplit('/').next().unwrap_or(&p.path))
485 .collect();
486 ReleaseError::Config(format!(
487 "package '{name}' not found. Available: {}",
488 if available.is_empty() {
489 "(none)".to_string()
490 } else {
491 available.join(", ")
492 }
493 ))
494 })
495 }
496
497 pub fn tag_prefix_for(&self, pkg: &PackageConfig) -> String {
499 if let Some(ref prefix) = pkg.tag_prefix {
500 return prefix.clone();
501 }
502 if pkg.path == "." {
503 self.git.tag_prefix.clone()
504 } else {
505 let dir_name = pkg.path.rsplit('/').next().unwrap_or(&pkg.path);
506 format!("{}/v", dir_name)
507 }
508 }
509
510 pub fn changelog_for<'a>(&'a self, pkg: &'a PackageConfig) -> &'a ChangelogConfig {
512 pkg.changelog.as_ref().unwrap_or(&self.changelog)
513 }
514
515 pub fn version_files_for(&self, pkg: &PackageConfig) -> Vec<String> {
517 if !pkg.version_files.is_empty() {
518 return pkg.version_files.clone();
519 }
520 let detected = detect_version_files(Path::new(&pkg.path));
521 if pkg.path == "." {
522 detected
523 } else {
524 detected
525 .into_iter()
526 .map(|f| format!("{}/{f}", pkg.path))
527 .collect()
528 }
529 }
530
531 pub fn fixed_packages(&self) -> Vec<&PackageConfig> {
533 self.packages.iter().filter(|p| !p.independent).collect()
534 }
535
536 pub fn independent_packages(&self) -> Vec<&PackageConfig> {
538 self.packages.iter().filter(|p| p.independent).collect()
539 }
540
541 pub fn all_artifacts(&self) -> Vec<String> {
543 self.packages
544 .iter()
545 .flat_map(|p| p.artifacts.clone())
546 .collect()
547 }
548}
549
550pub fn default_config_template(version_files: &[String]) -> String {
555 let vf = if version_files.is_empty() {
556 " version_files: []\n".to_string()
557 } else {
558 let mut s = " version_files:\n".to_string();
559 for f in version_files {
560 s.push_str(&format!(" - {f}\n"));
561 }
562 s
563 };
564
565 format!(
566 r#"# sr configuration
567# Full reference: https://github.com/urmzd/sr#configuration
568
569git:
570 tag_prefix: "v"
571 floating_tag: true
572 sign_tags: false
573 v0_protection: true
574
575commit:
576 types:
577 minor:
578 - feat
579 patch:
580 - fix
581 - perf
582 - refactor
583 none:
584 - docs
585 - revert
586 - chore
587 - ci
588 - test
589 - build
590 - style
591
592changelog:
593 file: CHANGELOG.md
594 # template: changelog.md.j2
595 groups:
596 - name: breaking
597 content:
598 - breaking
599 - name: features
600 content:
601 - feat
602 - name: bug-fixes
603 content:
604 - fix
605 - name: performance
606 content:
607 - perf
608 - name: misc
609 content:
610 - chore
611 - ci
612 - test
613 - build
614 - style
615
616channels:
617 default: stable
618 branch: main
619 content:
620 - name: stable
621 # - name: rc
622 # prerelease: rc
623 # draft: true
624 # - name: canary
625 # branch: develop
626 # prerelease: canary
627
628# vcs:
629# github:
630# release_name_template: "{{{{ tag_name }}}}"
631
632packages:
633 - path: .
634{vf} # version_files_strict: false
635 # stage_files: []
636 # artifacts: []
637 # hooks:
638 # pre_release:
639 # - cargo build --release
640 # post_release:
641 # - cargo publish
642"#
643 )
644}
645
646#[cfg(test)]
651mod tests {
652 use super::*;
653 use std::io::Write;
654
655 #[test]
656 fn default_values() {
657 let config = Config::default();
658 assert_eq!(config.git.tag_prefix, "v");
659 assert!(config.git.floating_tag);
660 assert!(!config.git.sign_tags);
661 assert_eq!(config.commit.types.minor, vec!["feat"]);
662 assert!(config.commit.types.patch.contains(&"fix".to_string()));
663 assert!(config.commit.types.none.contains(&"chore".to_string()));
664 assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
665 assert!(!config.changelog.groups.is_empty());
666 assert_eq!(config.channels.default, "stable");
667 assert_eq!(config.channels.content.len(), 1);
668 assert_eq!(config.channels.content[0].name, "stable");
669 assert_eq!(config.channels.branch, "main");
670 assert_eq!(config.packages.len(), 1);
671 assert_eq!(config.packages[0].path, ".");
672 }
673
674 #[test]
675 fn load_missing_file() {
676 let dir = tempfile::tempdir().unwrap();
677 let path = dir.path().join("nonexistent.yml");
678 let config = Config::load(&path).unwrap();
679 assert_eq!(config.git.tag_prefix, "v");
680 }
681
682 #[test]
683 fn load_partial_yaml() {
684 let dir = tempfile::tempdir().unwrap();
685 let path = dir.path().join("config.yml");
686 std::fs::write(&path, "git:\n tag_prefix: rel-\n").unwrap();
687
688 let config = Config::load(&path).unwrap();
689 assert_eq!(config.git.tag_prefix, "rel-");
690 assert_eq!(config.channels.default, "stable");
691 }
692
693 #[test]
694 fn load_yaml_with_packages() {
695 let dir = tempfile::tempdir().unwrap();
696 let path = dir.path().join("config.yml");
697 std::fs::write(
698 &path,
699 "packages:\n - path: crates/core\n version_files:\n - crates/core/Cargo.toml\n",
700 )
701 .unwrap();
702
703 let config = Config::load(&path).unwrap();
704 assert_eq!(config.packages.len(), 1);
705 assert_eq!(config.packages[0].path, "crates/core");
706 }
707
708 #[test]
709 fn commit_types_conversion() {
710 let types = CommitTypesConfig::default();
711 let commit_types = types.into_commit_types();
712 let feat = commit_types.iter().find(|t| t.name == "feat").unwrap();
713 assert_eq!(feat.bump, Some(BumpLevel::Minor));
714 let fix = commit_types.iter().find(|t| t.name == "fix").unwrap();
715 assert_eq!(fix.bump, Some(BumpLevel::Patch));
716 let chore = commit_types.iter().find(|t| t.name == "chore").unwrap();
717 assert_eq!(chore.bump, None);
718 }
719
720 #[test]
721 fn all_type_names() {
722 let types = CommitTypesConfig::default();
723 let names = types.all_type_names();
724 assert!(names.contains(&"feat"));
725 assert!(names.contains(&"fix"));
726 assert!(names.contains(&"chore"));
727 }
728
729 #[test]
730 fn resolve_channel() {
731 let config = Config::default();
732 let channel = config.resolve_channel("stable").unwrap();
733 assert!(channel.prerelease.is_none());
734 }
735
736 #[test]
737 fn resolve_channel_not_found() {
738 let config = Config::default();
739 assert!(config.resolve_channel("missing").is_err());
740 }
741
742 #[test]
743 fn tag_prefix_root_package() {
744 let config = Config::default();
745 let pkg = &config.packages[0];
746 assert_eq!(config.tag_prefix_for(pkg), "v");
747 }
748
749 #[test]
750 fn tag_prefix_subpackage() {
751 let config = Config::default();
752 let pkg = PackageConfig {
753 path: "crates/core".into(),
754 ..Default::default()
755 };
756 assert_eq!(config.tag_prefix_for(&pkg), "core/v");
757 }
758
759 #[test]
760 fn tag_prefix_override() {
761 let config = Config::default();
762 let pkg = PackageConfig {
763 path: "crates/cli".into(),
764 tag_prefix: Some("cli-v".into()),
765 ..Default::default()
766 };
767 assert_eq!(config.tag_prefix_for(&pkg), "cli-v");
768 }
769
770 #[test]
771 fn validate_duplicate_types() {
772 let config = Config {
773 commit: CommitConfig {
774 types: CommitTypesConfig {
775 minor: vec!["feat".into()],
776 patch: vec!["feat".into()],
777 none: vec![],
778 },
779 },
780 ..Default::default()
781 };
782 assert!(config.validate().is_err());
783 }
784
785 #[test]
786 fn validate_no_bump_types() {
787 let config = Config {
788 commit: CommitConfig {
789 types: CommitTypesConfig {
790 minor: vec![],
791 patch: vec![],
792 none: vec!["chore".into()],
793 },
794 },
795 ..Default::default()
796 };
797 assert!(config.validate().is_err());
798 }
799
800 #[test]
801 fn validate_duplicate_channels() {
802 let config = Config {
803 channels: ChannelsConfig {
804 default: "stable".into(),
805 branch: "main".into(),
806 content: vec![
807 ChannelConfig {
808 name: "stable".into(),
809 prerelease: None,
810 draft: false,
811 },
812 ChannelConfig {
813 name: "stable".into(),
814 prerelease: None,
815 draft: false,
816 },
817 ],
818 },
819 ..Default::default()
820 };
821 assert!(config.validate().is_err());
822 }
823
824 #[test]
825 fn default_template_parses() {
826 let template = default_config_template(&[]);
827 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
828 assert_eq!(config.git.tag_prefix, "v");
829 assert!(config.git.floating_tag);
830 assert_eq!(config.channels.default, "stable");
831 }
832
833 #[test]
834 fn default_template_with_version_files() {
835 let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
836 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
837 assert_eq!(
838 config.packages[0].version_files,
839 vec!["Cargo.toml", "package.json"]
840 );
841 }
842
843 #[test]
844 fn find_package_by_name_works() {
845 let config = Config {
846 packages: vec![
847 PackageConfig {
848 path: "crates/core".into(),
849 ..Default::default()
850 },
851 PackageConfig {
852 path: "crates/cli".into(),
853 ..Default::default()
854 },
855 ],
856 ..Default::default()
857 };
858 let pkg = config.find_package_by_name("core").unwrap();
859 assert_eq!(pkg.path, "crates/core");
860 }
861
862 #[test]
863 fn collect_all_artifacts() {
864 let config = Config {
865 packages: vec![
866 PackageConfig {
867 path: "crates/core".into(),
868 artifacts: vec!["core-*".into()],
869 ..Default::default()
870 },
871 PackageConfig {
872 path: "crates/cli".into(),
873 artifacts: vec!["cli-*".into()],
874 ..Default::default()
875 },
876 ],
877 ..Default::default()
878 };
879 let artifacts = config.all_artifacts();
880 assert_eq!(artifacts, vec!["core-*", "cli-*"]);
881 }
882}