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}
34
35impl Default for ReleaseConfig {
36    fn default() -> Self {
37        Self {
38            branches: vec!["main".into(), "master".into()],
39            tag_prefix: "v".into(),
40            commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
41            breaking_section: "Breaking Changes".into(),
42            misc_section: "Miscellaneous".into(),
43            types: default_commit_types(),
44            changelog: ChangelogConfig::default(),
45            version_files: vec![],
46            version_files_strict: false,
47            artifacts: vec![],
48            floating_tags: false,
49            build_command: None,
50            stage_files: vec![],
51            prerelease: None,
52            pre_release_command: None,
53            post_release_command: None,
54        }
55    }
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59#[serde(default)]
60pub struct ChangelogConfig {
61    pub file: Option<String>,
62    pub template: Option<String>,
63}
64
65impl ReleaseConfig {
66    /// Load config from a YAML file, falling back to defaults if the file doesn't exist.
67    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
68        if !path.exists() {
69            return Ok(Self::default());
70        }
71
72        let contents =
73            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
74
75        serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
76    }
77}
78
79// Custom deserialization for BumpLevel so it can appear in YAML config.
80impl<'de> Deserialize<'de> for BumpLevel {
81    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82    where
83        D: serde::Deserializer<'de>,
84    {
85        let s = String::deserialize(deserializer)?;
86        match s.as_str() {
87            "major" => Ok(BumpLevel::Major),
88            "minor" => Ok(BumpLevel::Minor),
89            "patch" => Ok(BumpLevel::Patch),
90            _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
91        }
92    }
93}
94
95impl Serialize for BumpLevel {
96    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
97    where
98        S: serde::Serializer,
99    {
100        let s = match self {
101            BumpLevel::Major => "major",
102            BumpLevel::Minor => "minor",
103            BumpLevel::Patch => "patch",
104        };
105        serializer.serialize_str(s)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use std::io::Write;
113
114    #[test]
115    fn default_values() {
116        let config = ReleaseConfig::default();
117        assert_eq!(config.branches, vec!["main", "master"]);
118        assert_eq!(config.tag_prefix, "v");
119        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
120        assert_eq!(config.breaking_section, "Breaking Changes");
121        assert_eq!(config.misc_section, "Miscellaneous");
122        assert!(!config.types.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!(f, "branches:\n  - develop\ntag_prefix: release-").unwrap();
142
143        let config = ReleaseConfig::load(&path).unwrap();
144        assert_eq!(config.branches, vec!["develop"]);
145        assert_eq!(config.tag_prefix, "release-");
146    }
147
148    #[test]
149    fn load_partial_yaml() {
150        let dir = tempfile::tempdir().unwrap();
151        let path = dir.path().join("config.yml");
152        std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
153
154        let config = ReleaseConfig::load(&path).unwrap();
155        assert_eq!(config.tag_prefix, "rel-");
156        assert_eq!(config.branches, vec!["main", "master"]);
157        // defaults should still apply for types/pattern/breaking_section
158        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
159        assert_eq!(config.breaking_section, "Breaking Changes");
160        assert!(!config.types.is_empty());
161    }
162
163    #[test]
164    fn load_yaml_with_artifacts() {
165        let dir = tempfile::tempdir().unwrap();
166        let path = dir.path().join("config.yml");
167        std::fs::write(
168            &path,
169            "artifacts:\n  - \"dist/*.tar.gz\"\n  - \"build/output-*\"\n",
170        )
171        .unwrap();
172
173        let config = ReleaseConfig::load(&path).unwrap();
174        assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
175        // defaults still apply
176        assert_eq!(config.tag_prefix, "v");
177    }
178
179    #[test]
180    fn load_yaml_with_floating_tags() {
181        let dir = tempfile::tempdir().unwrap();
182        let path = dir.path().join("config.yml");
183        std::fs::write(&path, "floating_tags: true\n").unwrap();
184
185        let config = ReleaseConfig::load(&path).unwrap();
186        assert!(config.floating_tags);
187        // defaults still apply
188        assert_eq!(config.tag_prefix, "v");
189    }
190
191    #[test]
192    fn bump_level_roundtrip() {
193        for (level, expected) in [
194            (BumpLevel::Major, "major"),
195            (BumpLevel::Minor, "minor"),
196            (BumpLevel::Patch, "patch"),
197        ] {
198            let yaml = serde_yaml_ng::to_string(&level).unwrap();
199            assert!(yaml.contains(expected));
200            let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
201            assert_eq!(parsed, level);
202        }
203    }
204
205    #[test]
206    fn types_roundtrip() {
207        let config = ReleaseConfig::default();
208        let yaml = serde_yaml_ng::to_string(&config).unwrap();
209        let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
210        assert_eq!(parsed.types.len(), config.types.len());
211        assert_eq!(parsed.types[0].name, "feat");
212        assert_eq!(parsed.commit_pattern, config.commit_pattern);
213        assert_eq!(parsed.breaking_section, config.breaking_section);
214    }
215}