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