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 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
66impl<'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 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 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 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}