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 version_files: Vec<String>,
20    pub version_files_strict: bool,
21    pub artifacts: Vec<String>,
22    pub floating_tags: bool,
23    pub build_command: Option<String>,
24    /// Additional files/globs to stage after `build_command` runs (e.g. `Cargo.lock`).
25    pub stage_files: Vec<String>,
26    /// Pre-release identifier (e.g. "alpha", "beta", "rc"). When set, versions are
27    /// formatted as X.Y.Z-<id>.N where N auto-increments.
28    pub prerelease: Option<String>,
29    /// Shell command to run before the release starts (validation, checks).
30    pub pre_release_command: Option<String>,
31    /// Shell command to run after the release completes (notifications, deployments).
32    pub post_release_command: Option<String>,
33    /// Sign annotated tags with GPG/SSH (git tag -s).
34    pub sign_tags: bool,
35    /// Create GitHub releases as drafts (requires manual publishing).
36    pub draft: bool,
37    /// Minijinja template for the GitHub release name.
38    /// Available variables: `version`, `tag_name`, `tag_prefix`.
39    /// Default when None: uses the tag name (e.g. "v1.2.0").
40    pub release_name_template: Option<String>,
41}
42
43impl Default for ReleaseConfig {
44    fn default() -> Self {
45        Self {
46            branches: vec!["main".into(), "master".into()],
47            tag_prefix: "v".into(),
48            commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
49            breaking_section: "Breaking Changes".into(),
50            misc_section: "Miscellaneous".into(),
51            types: default_commit_types(),
52            changelog: ChangelogConfig::default(),
53            version_files: vec![],
54            version_files_strict: false,
55            artifacts: vec![],
56            floating_tags: false,
57            build_command: None,
58            stage_files: vec![],
59            prerelease: None,
60            pre_release_command: None,
61            post_release_command: None,
62            sign_tags: false,
63            draft: false,
64            release_name_template: None,
65        }
66    }
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70#[serde(default)]
71pub struct ChangelogConfig {
72    pub file: Option<String>,
73    pub template: Option<String>,
74}
75
76impl ReleaseConfig {
77    /// Load config from a YAML file, falling back to defaults if the file doesn't exist.
78    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
79        if !path.exists() {
80            return Ok(Self::default());
81        }
82
83        let contents =
84            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
85
86        serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
87    }
88}
89
90// Custom deserialization for BumpLevel so it can appear in YAML config.
91impl<'de> Deserialize<'de> for BumpLevel {
92    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
93    where
94        D: serde::Deserializer<'de>,
95    {
96        let s = String::deserialize(deserializer)?;
97        match s.as_str() {
98            "major" => Ok(BumpLevel::Major),
99            "minor" => Ok(BumpLevel::Minor),
100            "patch" => Ok(BumpLevel::Patch),
101            _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
102        }
103    }
104}
105
106impl Serialize for BumpLevel {
107    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
108    where
109        S: serde::Serializer,
110    {
111        let s = match self {
112            BumpLevel::Major => "major",
113            BumpLevel::Minor => "minor",
114            BumpLevel::Patch => "patch",
115        };
116        serializer.serialize_str(s)
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::io::Write;
124
125    #[test]
126    fn default_values() {
127        let config = ReleaseConfig::default();
128        assert_eq!(config.branches, vec!["main", "master"]);
129        assert_eq!(config.tag_prefix, "v");
130        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
131        assert_eq!(config.breaking_section, "Breaking Changes");
132        assert_eq!(config.misc_section, "Miscellaneous");
133        assert!(!config.types.is_empty());
134        assert!(!config.version_files_strict);
135        assert!(config.artifacts.is_empty());
136        assert!(!config.floating_tags);
137    }
138
139    #[test]
140    fn load_missing_file() {
141        let dir = tempfile::tempdir().unwrap();
142        let path = dir.path().join("nonexistent.yml");
143        let config = ReleaseConfig::load(&path).unwrap();
144        assert_eq!(config.tag_prefix, "v");
145    }
146
147    #[test]
148    fn load_valid_yaml() {
149        let dir = tempfile::tempdir().unwrap();
150        let path = dir.path().join("config.yml");
151        let mut f = std::fs::File::create(&path).unwrap();
152        writeln!(f, "branches:\n  - develop\ntag_prefix: release-").unwrap();
153
154        let config = ReleaseConfig::load(&path).unwrap();
155        assert_eq!(config.branches, vec!["develop"]);
156        assert_eq!(config.tag_prefix, "release-");
157    }
158
159    #[test]
160    fn load_partial_yaml() {
161        let dir = tempfile::tempdir().unwrap();
162        let path = dir.path().join("config.yml");
163        std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
164
165        let config = ReleaseConfig::load(&path).unwrap();
166        assert_eq!(config.tag_prefix, "rel-");
167        assert_eq!(config.branches, vec!["main", "master"]);
168        // defaults should still apply for types/pattern/breaking_section
169        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
170        assert_eq!(config.breaking_section, "Breaking Changes");
171        assert!(!config.types.is_empty());
172    }
173
174    #[test]
175    fn load_yaml_with_artifacts() {
176        let dir = tempfile::tempdir().unwrap();
177        let path = dir.path().join("config.yml");
178        std::fs::write(
179            &path,
180            "artifacts:\n  - \"dist/*.tar.gz\"\n  - \"build/output-*\"\n",
181        )
182        .unwrap();
183
184        let config = ReleaseConfig::load(&path).unwrap();
185        assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
186        // defaults still apply
187        assert_eq!(config.tag_prefix, "v");
188    }
189
190    #[test]
191    fn load_yaml_with_floating_tags() {
192        let dir = tempfile::tempdir().unwrap();
193        let path = dir.path().join("config.yml");
194        std::fs::write(&path, "floating_tags: true\n").unwrap();
195
196        let config = ReleaseConfig::load(&path).unwrap();
197        assert!(config.floating_tags);
198        // defaults still apply
199        assert_eq!(config.tag_prefix, "v");
200    }
201
202    #[test]
203    fn bump_level_roundtrip() {
204        for (level, expected) in [
205            (BumpLevel::Major, "major"),
206            (BumpLevel::Minor, "minor"),
207            (BumpLevel::Patch, "patch"),
208        ] {
209            let yaml = serde_yaml_ng::to_string(&level).unwrap();
210            assert!(yaml.contains(expected));
211            let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
212            assert_eq!(parsed, level);
213        }
214    }
215
216    #[test]
217    fn types_roundtrip() {
218        let config = ReleaseConfig::default();
219        let yaml = serde_yaml_ng::to_string(&config).unwrap();
220        let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
221        assert_eq!(parsed.types.len(), config.types.len());
222        assert_eq!(parsed.types[0].name, "feat");
223        assert_eq!(parsed.commit_pattern, config.commit_pattern);
224        assert_eq!(parsed.breaking_section, config.breaking_section);
225    }
226}