Skip to main content

sr_core/
config.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5use crate::commit::{CommitType, DEFAULT_COMMIT_PATTERN, default_commit_types};
6use crate::error::ReleaseError;
7use crate::version::BumpLevel;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct ReleaseConfig {
12    pub branches: Vec<String>,
13    pub tag_prefix: String,
14    pub commit_pattern: String,
15    pub breaking_section: String,
16    pub misc_section: String,
17    pub types: Vec<CommitType>,
18    pub changelog: ChangelogConfig,
19    pub hooks: HooksConfig,
20    pub version_files: Vec<String>,
21    pub version_files_strict: bool,
22    pub artifacts: Vec<String>,
23    pub floating_tags: bool,
24}
25
26impl Default for ReleaseConfig {
27    fn default() -> Self {
28        Self {
29            branches: vec!["main".into(), "master".into()],
30            tag_prefix: "v".into(),
31            commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
32            breaking_section: "Breaking Changes".into(),
33            misc_section: "Miscellaneous".into(),
34            types: default_commit_types(),
35            changelog: ChangelogConfig::default(),
36            hooks: HooksConfig::default(),
37            version_files: vec![],
38            version_files_strict: false,
39            artifacts: vec![],
40            floating_tags: false,
41        }
42    }
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46#[serde(default)]
47pub struct ChangelogConfig {
48    pub file: Option<String>,
49    pub template: Option<String>,
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53#[serde(default)]
54pub struct HooksConfig {
55    pub pre_release: Vec<String>,
56    pub post_tag: Vec<String>,
57    pub post_release: Vec<String>,
58    pub on_failure: Vec<String>,
59}
60
61impl ReleaseConfig {
62    /// Load config from a YAML file, falling back to defaults if the file doesn't exist.
63    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
64        if !path.exists() {
65            return Ok(Self::default());
66        }
67
68        let contents =
69            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
70
71        serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
72    }
73}
74
75// Custom deserialization for BumpLevel so it can appear in YAML config.
76impl<'de> Deserialize<'de> for BumpLevel {
77    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
78    where
79        D: serde::Deserializer<'de>,
80    {
81        let s = String::deserialize(deserializer)?;
82        match s.as_str() {
83            "major" => Ok(BumpLevel::Major),
84            "minor" => Ok(BumpLevel::Minor),
85            "patch" => Ok(BumpLevel::Patch),
86            _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
87        }
88    }
89}
90
91impl Serialize for BumpLevel {
92    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
93    where
94        S: serde::Serializer,
95    {
96        let s = match self {
97            BumpLevel::Major => "major",
98            BumpLevel::Minor => "minor",
99            BumpLevel::Patch => "patch",
100        };
101        serializer.serialize_str(s)
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::io::Write;
109
110    #[test]
111    fn default_values() {
112        let config = ReleaseConfig::default();
113        assert_eq!(config.branches, vec!["main", "master"]);
114        assert_eq!(config.tag_prefix, "v");
115        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
116        assert_eq!(config.breaking_section, "Breaking Changes");
117        assert_eq!(config.misc_section, "Miscellaneous");
118        assert!(!config.types.is_empty());
119        assert!(config.hooks.pre_release.is_empty());
120        assert!(config.hooks.post_tag.is_empty());
121        assert!(config.hooks.post_release.is_empty());
122        assert!(config.hooks.on_failure.is_empty());
123        assert!(!config.version_files_strict);
124        assert!(config.artifacts.is_empty());
125        assert!(!config.floating_tags);
126    }
127
128    #[test]
129    fn load_missing_file() {
130        let dir = tempfile::tempdir().unwrap();
131        let path = dir.path().join("nonexistent.yml");
132        let config = ReleaseConfig::load(&path).unwrap();
133        assert_eq!(config.tag_prefix, "v");
134    }
135
136    #[test]
137    fn load_valid_yaml() {
138        let dir = tempfile::tempdir().unwrap();
139        let path = dir.path().join("config.yml");
140        let mut f = std::fs::File::create(&path).unwrap();
141        writeln!(
142            f,
143            "branches:\n  - develop\ntag_prefix: release-\nhooks:\n  pre_release:\n    - echo hi"
144        )
145        .unwrap();
146
147        let config = ReleaseConfig::load(&path).unwrap();
148        assert_eq!(config.branches, vec!["develop"]);
149        assert_eq!(config.tag_prefix, "release-");
150        assert_eq!(config.hooks.pre_release, vec!["echo hi"]);
151    }
152
153    #[test]
154    fn load_partial_yaml() {
155        let dir = tempfile::tempdir().unwrap();
156        let path = dir.path().join("config.yml");
157        std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
158
159        let config = ReleaseConfig::load(&path).unwrap();
160        assert_eq!(config.tag_prefix, "rel-");
161        assert_eq!(config.branches, vec!["main", "master"]);
162        // defaults should still apply for types/pattern/breaking_section
163        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
164        assert_eq!(config.breaking_section, "Breaking Changes");
165        assert!(!config.types.is_empty());
166    }
167
168    #[test]
169    fn load_yaml_with_artifacts() {
170        let dir = tempfile::tempdir().unwrap();
171        let path = dir.path().join("config.yml");
172        std::fs::write(
173            &path,
174            "artifacts:\n  - \"dist/*.tar.gz\"\n  - \"build/output-*\"\n",
175        )
176        .unwrap();
177
178        let config = ReleaseConfig::load(&path).unwrap();
179        assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
180        // defaults still apply
181        assert_eq!(config.tag_prefix, "v");
182    }
183
184    #[test]
185    fn load_yaml_with_floating_tags() {
186        let dir = tempfile::tempdir().unwrap();
187        let path = dir.path().join("config.yml");
188        std::fs::write(&path, "floating_tags: true\n").unwrap();
189
190        let config = ReleaseConfig::load(&path).unwrap();
191        assert!(config.floating_tags);
192        // defaults still apply
193        assert_eq!(config.tag_prefix, "v");
194    }
195
196    #[test]
197    fn bump_level_roundtrip() {
198        for (level, expected) in [
199            (BumpLevel::Major, "major"),
200            (BumpLevel::Minor, "minor"),
201            (BumpLevel::Patch, "patch"),
202        ] {
203            let yaml = serde_yaml_ng::to_string(&level).unwrap();
204            assert!(yaml.contains(expected));
205            let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
206            assert_eq!(parsed, level);
207        }
208    }
209
210    #[test]
211    fn types_roundtrip() {
212        let config = ReleaseConfig::default();
213        let yaml = serde_yaml_ng::to_string(&config).unwrap();
214        let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
215        assert_eq!(parsed.types.len(), config.types.len());
216        assert_eq!(parsed.types[0].name, "feat");
217        assert_eq!(parsed.commit_pattern, config.commit_pattern);
218        assert_eq!(parsed.breaking_section, config.breaking_section);
219    }
220}