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
9pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
11
12pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
14
15pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(default)]
20pub struct ReleaseConfig {
21 pub branches: Vec<String>,
22 pub tag_prefix: String,
23 pub commit_pattern: String,
24 pub breaking_section: String,
25 pub misc_section: String,
26 pub types: Vec<CommitType>,
27 pub changelog: ChangelogConfig,
28 pub version_files: Vec<String>,
29 pub version_files_strict: bool,
30 pub artifacts: Vec<String>,
31 pub floating_tags: bool,
32 pub build_command: Option<String>,
33 pub stage_files: Vec<String>,
35 pub prerelease: Option<String>,
38 pub pre_release_command: Option<String>,
40 pub post_release_command: Option<String>,
42 pub sign_tags: bool,
44 pub draft: bool,
46 pub release_name_template: Option<String>,
50}
51
52impl Default for ReleaseConfig {
53 fn default() -> Self {
54 Self {
55 branches: vec!["main".into(), "master".into()],
56 tag_prefix: "v".into(),
57 commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
58 breaking_section: "Breaking Changes".into(),
59 misc_section: "Miscellaneous".into(),
60 types: default_commit_types(),
61 changelog: ChangelogConfig::default(),
62 version_files: vec![],
63 version_files_strict: false,
64 artifacts: vec![],
65 floating_tags: false,
66 build_command: None,
67 stage_files: vec![],
68 prerelease: None,
69 pre_release_command: None,
70 post_release_command: None,
71 sign_tags: false,
72 draft: false,
73 release_name_template: None,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79#[serde(default)]
80pub struct ChangelogConfig {
81 pub file: Option<String>,
82 pub template: Option<String>,
83}
84
85impl ReleaseConfig {
86 pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
89 for &candidate in CONFIG_CANDIDATES {
90 let path = dir.join(candidate);
91 if path.exists() {
92 let is_legacy = candidate == LEGACY_CONFIG_FILE;
93 return Some((path, is_legacy));
94 }
95 }
96 None
97 }
98
99 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
101 if !path.exists() {
102 return Ok(Self::default());
103 }
104
105 let contents =
106 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
107
108 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
109 }
110}
111
112impl<'de> Deserialize<'de> for BumpLevel {
114 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
115 where
116 D: serde::Deserializer<'de>,
117 {
118 let s = String::deserialize(deserializer)?;
119 match s.as_str() {
120 "major" => Ok(BumpLevel::Major),
121 "minor" => Ok(BumpLevel::Minor),
122 "patch" => Ok(BumpLevel::Patch),
123 _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
124 }
125 }
126}
127
128impl Serialize for BumpLevel {
129 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
130 where
131 S: serde::Serializer,
132 {
133 let s = match self {
134 BumpLevel::Major => "major",
135 BumpLevel::Minor => "minor",
136 BumpLevel::Patch => "patch",
137 };
138 serializer.serialize_str(s)
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use std::io::Write;
146
147 #[test]
148 fn default_values() {
149 let config = ReleaseConfig::default();
150 assert_eq!(config.branches, vec!["main", "master"]);
151 assert_eq!(config.tag_prefix, "v");
152 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
153 assert_eq!(config.breaking_section, "Breaking Changes");
154 assert_eq!(config.misc_section, "Miscellaneous");
155 assert!(!config.types.is_empty());
156 assert!(!config.version_files_strict);
157 assert!(config.artifacts.is_empty());
158 assert!(!config.floating_tags);
159 }
160
161 #[test]
162 fn load_missing_file() {
163 let dir = tempfile::tempdir().unwrap();
164 let path = dir.path().join("nonexistent.yml");
165 let config = ReleaseConfig::load(&path).unwrap();
166 assert_eq!(config.tag_prefix, "v");
167 }
168
169 #[test]
170 fn load_valid_yaml() {
171 let dir = tempfile::tempdir().unwrap();
172 let path = dir.path().join("config.yml");
173 let mut f = std::fs::File::create(&path).unwrap();
174 writeln!(f, "branches:\n - develop\ntag_prefix: release-").unwrap();
175
176 let config = ReleaseConfig::load(&path).unwrap();
177 assert_eq!(config.branches, vec!["develop"]);
178 assert_eq!(config.tag_prefix, "release-");
179 }
180
181 #[test]
182 fn load_partial_yaml() {
183 let dir = tempfile::tempdir().unwrap();
184 let path = dir.path().join("config.yml");
185 std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
186
187 let config = ReleaseConfig::load(&path).unwrap();
188 assert_eq!(config.tag_prefix, "rel-");
189 assert_eq!(config.branches, vec!["main", "master"]);
190 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
192 assert_eq!(config.breaking_section, "Breaking Changes");
193 assert!(!config.types.is_empty());
194 }
195
196 #[test]
197 fn load_yaml_with_artifacts() {
198 let dir = tempfile::tempdir().unwrap();
199 let path = dir.path().join("config.yml");
200 std::fs::write(
201 &path,
202 "artifacts:\n - \"dist/*.tar.gz\"\n - \"build/output-*\"\n",
203 )
204 .unwrap();
205
206 let config = ReleaseConfig::load(&path).unwrap();
207 assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
208 assert_eq!(config.tag_prefix, "v");
210 }
211
212 #[test]
213 fn load_yaml_with_floating_tags() {
214 let dir = tempfile::tempdir().unwrap();
215 let path = dir.path().join("config.yml");
216 std::fs::write(&path, "floating_tags: true\n").unwrap();
217
218 let config = ReleaseConfig::load(&path).unwrap();
219 assert!(config.floating_tags);
220 assert_eq!(config.tag_prefix, "v");
222 }
223
224 #[test]
225 fn bump_level_roundtrip() {
226 for (level, expected) in [
227 (BumpLevel::Major, "major"),
228 (BumpLevel::Minor, "minor"),
229 (BumpLevel::Patch, "patch"),
230 ] {
231 let yaml = serde_yaml_ng::to_string(&level).unwrap();
232 assert!(yaml.contains(expected));
233 let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
234 assert_eq!(parsed, level);
235 }
236 }
237
238 #[test]
239 fn types_roundtrip() {
240 let config = ReleaseConfig::default();
241 let yaml = serde_yaml_ng::to_string(&config).unwrap();
242 let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
243 assert_eq!(parsed.types.len(), config.types.len());
244 assert_eq!(parsed.types[0].name, "feat");
245 assert_eq!(parsed.commit_pattern, config.commit_pattern);
246 assert_eq!(parsed.breaking_section, config.breaking_section);
247 }
248}