1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::commit::{CommitType, DEFAULT_COMMIT_PATTERN, default_commit_types};
7use crate::error::ReleaseError;
8use crate::version::BumpLevel;
9
10pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
12
13pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
15
16pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(default)]
21pub struct ReleaseConfig {
22 pub branches: Vec<String>,
23 pub tag_prefix: String,
24 pub commit_pattern: String,
25 pub breaking_section: String,
26 pub misc_section: String,
27 pub types: Vec<CommitType>,
28 pub changelog: ChangelogConfig,
29 pub version_files: Vec<String>,
30 pub version_files_strict: bool,
31 pub artifacts: Vec<String>,
32 pub floating_tags: bool,
33 pub build_command: Option<String>,
34 pub stage_files: Vec<String>,
36 pub prerelease: Option<String>,
39 pub pre_release_command: Option<String>,
41 pub post_release_command: Option<String>,
43 pub sign_tags: bool,
45 pub draft: bool,
47 pub release_name_template: Option<String>,
51 pub hooks: HooksConfig,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub packages: Vec<PackageConfig>,
56 #[serde(skip)]
58 pub path_filter: Option<String>,
59}
60
61impl Default for ReleaseConfig {
62 fn default() -> Self {
63 Self {
64 branches: vec!["main".into(), "master".into()],
65 tag_prefix: "v".into(),
66 commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
67 breaking_section: "Breaking Changes".into(),
68 misc_section: "Miscellaneous".into(),
69 types: default_commit_types(),
70 changelog: ChangelogConfig::default(),
71 version_files: vec![],
72 version_files_strict: false,
73 artifacts: vec![],
74 floating_tags: false,
75 build_command: None,
76 stage_files: vec![],
77 prerelease: None,
78 pre_release_command: None,
79 post_release_command: None,
80 sign_tags: false,
81 draft: false,
82 release_name_template: None,
83 hooks: HooksConfig::with_defaults(),
84 packages: vec![],
85 path_filter: None,
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct PackageConfig {
106 pub name: String,
108 pub path: String,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub tag_prefix: Option<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub version_files: Vec<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub changelog: Option<ChangelogConfig>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub build_command: Option<String>,
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub stage_files: Vec<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, Default)]
144#[serde(transparent)]
145pub struct HooksConfig {
146 pub hooks: BTreeMap<String, Vec<String>>,
147}
148
149impl HooksConfig {
150 pub fn with_defaults() -> Self {
151 let mut hooks = BTreeMap::new();
152 hooks.insert("commit-msg".into(), vec!["sr hook commit-msg".into()]);
153 Self { hooks }
154 }
155}
156
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158#[serde(default)]
159pub struct ChangelogConfig {
160 pub file: Option<String>,
161 pub template: Option<String>,
162}
163
164impl ReleaseConfig {
165 pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
168 for &candidate in CONFIG_CANDIDATES {
169 let path = dir.join(candidate);
170 if path.exists() {
171 let is_legacy = candidate == LEGACY_CONFIG_FILE;
172 return Some((path, is_legacy));
173 }
174 }
175 None
176 }
177
178 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
180 if !path.exists() {
181 return Ok(Self::default());
182 }
183
184 let contents =
185 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
186
187 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
188 }
189
190 pub fn resolve_package(&self, pkg: &PackageConfig) -> Self {
192 let mut config = self.clone();
193 config.tag_prefix = pkg
194 .tag_prefix
195 .clone()
196 .unwrap_or_else(|| format!("{}/v", pkg.name));
197 config.path_filter = Some(pkg.path.clone());
198 if !pkg.version_files.is_empty() {
199 config.version_files = pkg.version_files.clone();
200 }
201 if let Some(ref cl) = pkg.changelog {
202 config.changelog = cl.clone();
203 }
204 if let Some(ref cmd) = pkg.build_command {
205 config.build_command = Some(cmd.clone());
206 }
207 if !pkg.stage_files.is_empty() {
208 config.stage_files = pkg.stage_files.clone();
209 }
210 config.packages = vec![];
212 config
213 }
214
215 pub fn find_package(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
217 self.packages
218 .iter()
219 .find(|p| p.name == name)
220 .ok_or_else(|| {
221 let available: Vec<&str> = self.packages.iter().map(|p| p.name.as_str()).collect();
222 ReleaseError::Config(format!(
223 "package '{name}' not found. Available: {}",
224 if available.is_empty() {
225 "(none — no packages configured)".to_string()
226 } else {
227 available.join(", ")
228 }
229 ))
230 })
231 }
232}
233
234impl<'de> Deserialize<'de> for BumpLevel {
236 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
237 where
238 D: serde::Deserializer<'de>,
239 {
240 let s = String::deserialize(deserializer)?;
241 match s.as_str() {
242 "major" => Ok(BumpLevel::Major),
243 "minor" => Ok(BumpLevel::Minor),
244 "patch" => Ok(BumpLevel::Patch),
245 _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
246 }
247 }
248}
249
250impl Serialize for BumpLevel {
251 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
252 where
253 S: serde::Serializer,
254 {
255 let s = match self {
256 BumpLevel::Major => "major",
257 BumpLevel::Minor => "minor",
258 BumpLevel::Patch => "patch",
259 };
260 serializer.serialize_str(s)
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use std::io::Write;
268
269 #[test]
270 fn default_values() {
271 let config = ReleaseConfig::default();
272 assert_eq!(config.branches, vec!["main", "master"]);
273 assert_eq!(config.tag_prefix, "v");
274 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
275 assert_eq!(config.breaking_section, "Breaking Changes");
276 assert_eq!(config.misc_section, "Miscellaneous");
277 assert!(!config.types.is_empty());
278 assert!(!config.version_files_strict);
279 assert!(config.artifacts.is_empty());
280 assert!(!config.floating_tags);
281 }
282
283 #[test]
284 fn load_missing_file() {
285 let dir = tempfile::tempdir().unwrap();
286 let path = dir.path().join("nonexistent.yml");
287 let config = ReleaseConfig::load(&path).unwrap();
288 assert_eq!(config.tag_prefix, "v");
289 }
290
291 #[test]
292 fn load_valid_yaml() {
293 let dir = tempfile::tempdir().unwrap();
294 let path = dir.path().join("config.yml");
295 let mut f = std::fs::File::create(&path).unwrap();
296 writeln!(f, "branches:\n - develop\ntag_prefix: release-").unwrap();
297
298 let config = ReleaseConfig::load(&path).unwrap();
299 assert_eq!(config.branches, vec!["develop"]);
300 assert_eq!(config.tag_prefix, "release-");
301 }
302
303 #[test]
304 fn load_partial_yaml() {
305 let dir = tempfile::tempdir().unwrap();
306 let path = dir.path().join("config.yml");
307 std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
308
309 let config = ReleaseConfig::load(&path).unwrap();
310 assert_eq!(config.tag_prefix, "rel-");
311 assert_eq!(config.branches, vec!["main", "master"]);
312 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
314 assert_eq!(config.breaking_section, "Breaking Changes");
315 assert!(!config.types.is_empty());
316 }
317
318 #[test]
319 fn load_yaml_with_artifacts() {
320 let dir = tempfile::tempdir().unwrap();
321 let path = dir.path().join("config.yml");
322 std::fs::write(
323 &path,
324 "artifacts:\n - \"dist/*.tar.gz\"\n - \"build/output-*\"\n",
325 )
326 .unwrap();
327
328 let config = ReleaseConfig::load(&path).unwrap();
329 assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
330 assert_eq!(config.tag_prefix, "v");
332 }
333
334 #[test]
335 fn load_yaml_with_floating_tags() {
336 let dir = tempfile::tempdir().unwrap();
337 let path = dir.path().join("config.yml");
338 std::fs::write(&path, "floating_tags: true\n").unwrap();
339
340 let config = ReleaseConfig::load(&path).unwrap();
341 assert!(config.floating_tags);
342 assert_eq!(config.tag_prefix, "v");
344 }
345
346 #[test]
347 fn bump_level_roundtrip() {
348 for (level, expected) in [
349 (BumpLevel::Major, "major"),
350 (BumpLevel::Minor, "minor"),
351 (BumpLevel::Patch, "patch"),
352 ] {
353 let yaml = serde_yaml_ng::to_string(&level).unwrap();
354 assert!(yaml.contains(expected));
355 let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
356 assert_eq!(parsed, level);
357 }
358 }
359
360 #[test]
361 fn types_roundtrip() {
362 let config = ReleaseConfig::default();
363 let yaml = serde_yaml_ng::to_string(&config).unwrap();
364 let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
365 assert_eq!(parsed.types.len(), config.types.len());
366 assert_eq!(parsed.types[0].name, "feat");
367 assert_eq!(parsed.commit_pattern, config.commit_pattern);
368 assert_eq!(parsed.breaking_section, config.breaking_section);
369 }
370
371 #[test]
372 fn load_yaml_with_packages() {
373 let dir = tempfile::tempdir().unwrap();
374 let path = dir.path().join("config.yml");
375 std::fs::write(
376 &path,
377 r#"
378packages:
379 - name: core
380 path: crates/core
381 version_files:
382 - crates/core/Cargo.toml
383 - name: cli
384 path: crates/cli
385 tag_prefix: "cli-v"
386"#,
387 )
388 .unwrap();
389
390 let config = ReleaseConfig::load(&path).unwrap();
391 assert_eq!(config.packages.len(), 2);
392 assert_eq!(config.packages[0].name, "core");
393 assert_eq!(config.packages[0].path, "crates/core");
394 assert_eq!(config.packages[1].tag_prefix.as_deref(), Some("cli-v"));
395 }
396
397 #[test]
398 fn resolve_package_defaults() {
399 let mut config = ReleaseConfig::default();
400 config.packages = vec![PackageConfig {
401 name: "core".into(),
402 path: "crates/core".into(),
403 tag_prefix: None,
404 version_files: vec![],
405 changelog: None,
406 build_command: None,
407 stage_files: vec![],
408 }];
409
410 let resolved = config.resolve_package(&config.packages[0]);
411 assert_eq!(resolved.tag_prefix, "core/v");
412 assert_eq!(resolved.path_filter.as_deref(), Some("crates/core"));
413 assert_eq!(resolved.branches, config.branches);
415 assert!(resolved.packages.is_empty());
416 }
417
418 #[test]
419 fn resolve_package_overrides() {
420 let mut config = ReleaseConfig::default();
421 config.version_files = vec!["Cargo.toml".into()];
422 config.packages = vec![PackageConfig {
423 name: "cli".into(),
424 path: "crates/cli".into(),
425 tag_prefix: Some("cli-v".into()),
426 version_files: vec!["crates/cli/Cargo.toml".into()],
427 changelog: Some(ChangelogConfig {
428 file: Some("crates/cli/CHANGELOG.md".into()),
429 template: None,
430 }),
431 build_command: Some("cargo build -p cli".into()),
432 stage_files: vec!["crates/cli/Cargo.lock".into()],
433 }];
434
435 let resolved = config.resolve_package(&config.packages[0]);
436 assert_eq!(resolved.tag_prefix, "cli-v");
437 assert_eq!(resolved.version_files, vec!["crates/cli/Cargo.toml"]);
438 assert_eq!(
439 resolved.changelog.file.as_deref(),
440 Some("crates/cli/CHANGELOG.md")
441 );
442 assert_eq!(
443 resolved.build_command.as_deref(),
444 Some("cargo build -p cli")
445 );
446 assert_eq!(resolved.stage_files, vec!["crates/cli/Cargo.lock"]);
447 }
448
449 #[test]
450 fn find_package_found() {
451 let mut config = ReleaseConfig::default();
452 config.packages = vec![PackageConfig {
453 name: "core".into(),
454 path: "crates/core".into(),
455 tag_prefix: None,
456 version_files: vec![],
457 changelog: None,
458 build_command: None,
459 stage_files: vec![],
460 }];
461
462 let pkg = config.find_package("core").unwrap();
463 assert_eq!(pkg.name, "core");
464 }
465
466 #[test]
467 fn find_package_not_found() {
468 let config = ReleaseConfig::default();
469 let err = config.find_package("nonexistent").unwrap_err();
470 assert!(err.to_string().contains("nonexistent"));
471 assert!(err.to_string().contains("no packages configured"));
472 }
473
474 #[test]
475 fn packages_not_serialized_when_empty() {
476 let config = ReleaseConfig::default();
477 let yaml = serde_yaml_ng::to_string(&config).unwrap();
478 assert!(!yaml.contains("packages"));
479 }
480}