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