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