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