1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::commit::{CommitType, DEFAULT_COMMIT_PATTERN, default_commit_types};
7use crate::error::ReleaseError;
8use crate::version::BumpLevel;
9
10pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
12
13pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
15
16pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(default)]
21pub struct ReleaseConfig {
22 pub branches: Vec<String>,
23 pub tag_prefix: String,
24 pub commit_pattern: String,
25 pub breaking_section: String,
26 pub misc_section: String,
27 pub types: Vec<CommitType>,
28 pub changelog: ChangelogConfig,
29 pub version_files: Vec<String>,
30 pub version_files_strict: bool,
31 pub artifacts: Vec<String>,
32 pub floating_tags: bool,
33 pub build_command: Option<String>,
34 pub stage_files: Vec<String>,
36 pub prerelease: Option<String>,
39 pub pre_release_command: Option<String>,
41 pub post_release_command: Option<String>,
43 pub sign_tags: bool,
45 pub draft: bool,
47 pub release_name_template: Option<String>,
51 pub hooks: HooksConfig,
53}
54
55impl Default for ReleaseConfig {
56 fn default() -> Self {
57 Self {
58 branches: vec!["main".into(), "master".into()],
59 tag_prefix: "v".into(),
60 commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
61 breaking_section: "Breaking Changes".into(),
62 misc_section: "Miscellaneous".into(),
63 types: default_commit_types(),
64 changelog: ChangelogConfig::default(),
65 version_files: vec![],
66 version_files_strict: false,
67 artifacts: vec![],
68 floating_tags: false,
69 build_command: None,
70 stage_files: vec![],
71 prerelease: None,
72 pre_release_command: None,
73 post_release_command: None,
74 sign_tags: false,
75 draft: false,
76 release_name_template: None,
77 hooks: HooksConfig::with_defaults(),
78 }
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, Default)]
99#[serde(transparent)]
100pub struct HooksConfig {
101 pub hooks: BTreeMap<String, Vec<String>>,
102}
103
104impl HooksConfig {
105 pub fn with_defaults() -> Self {
106 let mut hooks = BTreeMap::new();
107 hooks.insert("commit-msg".into(), vec!["sr hook commit-msg".into()]);
108 Self { hooks }
109 }
110}
111
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113#[serde(default)]
114pub struct ChangelogConfig {
115 pub file: Option<String>,
116 pub template: Option<String>,
117}
118
119impl ReleaseConfig {
120 pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
123 for &candidate in CONFIG_CANDIDATES {
124 let path = dir.join(candidate);
125 if path.exists() {
126 let is_legacy = candidate == LEGACY_CONFIG_FILE;
127 return Some((path, is_legacy));
128 }
129 }
130 None
131 }
132
133 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
135 if !path.exists() {
136 return Ok(Self::default());
137 }
138
139 let contents =
140 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
141
142 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
143 }
144}
145
146impl<'de> Deserialize<'de> for BumpLevel {
148 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149 where
150 D: serde::Deserializer<'de>,
151 {
152 let s = String::deserialize(deserializer)?;
153 match s.as_str() {
154 "major" => Ok(BumpLevel::Major),
155 "minor" => Ok(BumpLevel::Minor),
156 "patch" => Ok(BumpLevel::Patch),
157 _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
158 }
159 }
160}
161
162impl Serialize for BumpLevel {
163 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
164 where
165 S: serde::Serializer,
166 {
167 let s = match self {
168 BumpLevel::Major => "major",
169 BumpLevel::Minor => "minor",
170 BumpLevel::Patch => "patch",
171 };
172 serializer.serialize_str(s)
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use std::io::Write;
180
181 #[test]
182 fn default_values() {
183 let config = ReleaseConfig::default();
184 assert_eq!(config.branches, vec!["main", "master"]);
185 assert_eq!(config.tag_prefix, "v");
186 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
187 assert_eq!(config.breaking_section, "Breaking Changes");
188 assert_eq!(config.misc_section, "Miscellaneous");
189 assert!(!config.types.is_empty());
190 assert!(!config.version_files_strict);
191 assert!(config.artifacts.is_empty());
192 assert!(!config.floating_tags);
193 }
194
195 #[test]
196 fn load_missing_file() {
197 let dir = tempfile::tempdir().unwrap();
198 let path = dir.path().join("nonexistent.yml");
199 let config = ReleaseConfig::load(&path).unwrap();
200 assert_eq!(config.tag_prefix, "v");
201 }
202
203 #[test]
204 fn load_valid_yaml() {
205 let dir = tempfile::tempdir().unwrap();
206 let path = dir.path().join("config.yml");
207 let mut f = std::fs::File::create(&path).unwrap();
208 writeln!(f, "branches:\n - develop\ntag_prefix: release-").unwrap();
209
210 let config = ReleaseConfig::load(&path).unwrap();
211 assert_eq!(config.branches, vec!["develop"]);
212 assert_eq!(config.tag_prefix, "release-");
213 }
214
215 #[test]
216 fn load_partial_yaml() {
217 let dir = tempfile::tempdir().unwrap();
218 let path = dir.path().join("config.yml");
219 std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
220
221 let config = ReleaseConfig::load(&path).unwrap();
222 assert_eq!(config.tag_prefix, "rel-");
223 assert_eq!(config.branches, vec!["main", "master"]);
224 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
226 assert_eq!(config.breaking_section, "Breaking Changes");
227 assert!(!config.types.is_empty());
228 }
229
230 #[test]
231 fn load_yaml_with_artifacts() {
232 let dir = tempfile::tempdir().unwrap();
233 let path = dir.path().join("config.yml");
234 std::fs::write(
235 &path,
236 "artifacts:\n - \"dist/*.tar.gz\"\n - \"build/output-*\"\n",
237 )
238 .unwrap();
239
240 let config = ReleaseConfig::load(&path).unwrap();
241 assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
242 assert_eq!(config.tag_prefix, "v");
244 }
245
246 #[test]
247 fn load_yaml_with_floating_tags() {
248 let dir = tempfile::tempdir().unwrap();
249 let path = dir.path().join("config.yml");
250 std::fs::write(&path, "floating_tags: true\n").unwrap();
251
252 let config = ReleaseConfig::load(&path).unwrap();
253 assert!(config.floating_tags);
254 assert_eq!(config.tag_prefix, "v");
256 }
257
258 #[test]
259 fn bump_level_roundtrip() {
260 for (level, expected) in [
261 (BumpLevel::Major, "major"),
262 (BumpLevel::Minor, "minor"),
263 (BumpLevel::Patch, "patch"),
264 ] {
265 let yaml = serde_yaml_ng::to_string(&level).unwrap();
266 assert!(yaml.contains(expected));
267 let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
268 assert_eq!(parsed, level);
269 }
270 }
271
272 #[test]
273 fn types_roundtrip() {
274 let config = ReleaseConfig::default();
275 let yaml = serde_yaml_ng::to_string(&config).unwrap();
276 let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
277 assert_eq!(parsed.types.len(), config.types.len());
278 assert_eq!(parsed.types[0].name, "feat");
279 assert_eq!(parsed.commit_pattern, config.commit_pattern);
280 assert_eq!(parsed.breaking_section, config.breaking_section);
281 }
282}