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