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