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;
9use crate::version_files::detect_version_files;
10
11pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
13
14pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
16
17pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47#[serde(default)]
48pub struct Config {
49 pub commit: CommitConfig,
50 pub release: ReleaseConfig,
51 pub hooks: HooksConfig,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub packages: Vec<PackageConfig>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(default)]
64pub struct CommitConfig {
65 pub pattern: String,
67 pub breaking_section: String,
69 pub misc_section: String,
71 pub types: Vec<CommitType>,
73}
74
75impl Default for CommitConfig {
76 fn default() -> Self {
77 Self {
78 pattern: DEFAULT_COMMIT_PATTERN.into(),
79 breaking_section: "Breaking Changes".into(),
80 misc_section: "Miscellaneous".into(),
81 types: default_commit_types(),
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(default)]
93pub struct ReleaseConfig {
94 pub branches: Vec<String>,
96 pub tag_prefix: String,
98 pub changelog: ChangelogConfig,
100 pub version_files: Vec<String>,
102 pub version_files_strict: bool,
104 pub artifacts: Vec<String>,
106 pub floating_tags: bool,
108 pub stage_files: Vec<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub prerelease: Option<String>,
113 pub sign_tags: bool,
115 pub draft: bool,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub release_name_template: Option<String>,
120 #[serde(default)]
122 pub versioning: VersioningMode,
123 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
125 pub channels: BTreeMap<String, ChannelConfig>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub default_channel: Option<String>,
129 #[serde(skip)]
131 pub path_filter: Option<String>,
132}
133
134impl Default for ReleaseConfig {
135 fn default() -> Self {
136 Self {
137 branches: vec!["main".into()],
138 tag_prefix: "v".into(),
139 changelog: ChangelogConfig::default(),
140 version_files: vec![],
141 version_files_strict: false,
142 artifacts: vec![],
143 floating_tags: true,
144 stage_files: vec![],
145 prerelease: None,
146 sign_tags: false,
147 draft: false,
148 release_name_template: None,
149 versioning: VersioningMode::default(),
150 channels: BTreeMap::new(),
151 default_channel: None,
152 path_filter: None,
153 }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
162#[serde(rename_all = "lowercase")]
163pub enum VersioningMode {
164 #[default]
165 Independent,
166 Fixed,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct PackageConfig {
171 pub name: String,
172 pub path: String,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub tag_prefix: Option<String>,
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub version_files: Vec<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub changelog: Option<ChangelogConfig>,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub stage_files: Vec<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, Default)]
184#[serde(default)]
185pub struct ChannelConfig {
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub prerelease: Option<String>,
188 #[serde(default)]
189 pub draft: bool,
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub artifacts: Vec<String>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum HookEvent {
197 PreCommit,
198 PostCommit,
199 PreBranch,
200 PostBranch,
201 PrePr,
202 PostPr,
203 PreReview,
204 PostReview,
205 PreRelease,
206 PostRelease,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, Default)]
210#[serde(transparent)]
211pub struct HooksConfig {
212 pub hooks: BTreeMap<HookEvent, Vec<String>>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(default)]
217pub struct ChangelogConfig {
218 pub file: Option<String>,
219 pub template: Option<String>,
220}
221
222impl Default for ChangelogConfig {
223 fn default() -> Self {
224 Self {
225 file: Some("CHANGELOG.md".into()),
226 template: None,
227 }
228 }
229}
230
231impl Config {
236 pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
238 for &candidate in CONFIG_CANDIDATES {
239 let path = dir.join(candidate);
240 if path.exists() {
241 let is_legacy = candidate == LEGACY_CONFIG_FILE;
242 return Some((path, is_legacy));
243 }
244 }
245 None
246 }
247
248 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
250 if !path.exists() {
251 return Ok(Self::default());
252 }
253 let contents =
254 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
255 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
256 }
257
258 pub fn resolve_package(&self, pkg: &PackageConfig) -> Self {
260 let mut config = self.clone();
261 config.release.tag_prefix = pkg
262 .tag_prefix
263 .clone()
264 .unwrap_or_else(|| format!("{}/v", pkg.name));
265 config.release.path_filter = Some(pkg.path.clone());
266 if !pkg.version_files.is_empty() {
267 config.release.version_files = pkg.version_files.clone();
268 } else if config.release.version_files.is_empty() {
269 let detected = detect_version_files(Path::new(&pkg.path));
270 if !detected.is_empty() {
271 config.release.version_files = detected
272 .into_iter()
273 .map(|f| format!("{}/{f}", pkg.path))
274 .collect();
275 }
276 }
277 if let Some(ref cl) = pkg.changelog {
278 config.release.changelog = cl.clone();
279 }
280 if !pkg.stage_files.is_empty() {
281 config.release.stage_files = pkg.stage_files.clone();
282 }
283 config.packages = vec![];
284 config
285 }
286
287 pub fn resolve_fixed(&self) -> Self {
289 let mut config = self.clone();
290 config.release.path_filter = None;
291
292 let mut version_files: Vec<String> = config.release.version_files.clone();
293 for pkg in &self.packages {
294 if !pkg.version_files.is_empty() {
295 version_files.extend(pkg.version_files.clone());
296 } else {
297 let detected = detect_version_files(Path::new(&pkg.path));
298 version_files.extend(detected.into_iter().map(|f| format!("{}/{f}", pkg.path)));
299 }
300 }
301 version_files.sort();
302 version_files.dedup();
303 config.release.version_files = version_files;
304
305 let mut stage_files = config.release.stage_files.clone();
306 for pkg in &self.packages {
307 stage_files.extend(pkg.stage_files.clone());
308 }
309 stage_files.sort();
310 stage_files.dedup();
311 config.release.stage_files = stage_files;
312
313 config.packages = vec![];
314 config
315 }
316
317 pub fn resolve_channel(&self, name: &str) -> Result<Self, ReleaseError> {
319 let channel = self.release.channels.get(name).ok_or_else(|| {
320 let available: Vec<&str> = self.release.channels.keys().map(|k| k.as_str()).collect();
321 ReleaseError::Config(format!(
322 "channel '{name}' not found. Available: {}",
323 if available.is_empty() {
324 "(none)".to_string()
325 } else {
326 available.join(", ")
327 }
328 ))
329 })?;
330
331 let mut config = self.clone();
332 if channel.prerelease.is_some() {
333 config.release.prerelease = channel.prerelease.clone();
334 }
335 if channel.draft {
336 config.release.draft = true;
337 }
338 if !channel.artifacts.is_empty() {
339 config.release.artifacts.extend(channel.artifacts.clone());
340 }
341 Ok(config)
342 }
343
344 pub fn find_package(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
346 self.packages
347 .iter()
348 .find(|p| p.name == name)
349 .ok_or_else(|| {
350 let available: Vec<&str> = self.packages.iter().map(|p| p.name.as_str()).collect();
351 ReleaseError::Config(format!(
352 "package '{name}' not found. Available: {}",
353 if available.is_empty() {
354 "(none)".to_string()
355 } else {
356 available.join(", ")
357 }
358 ))
359 })
360 }
361}
362
363pub fn default_config_template(version_files: &[String]) -> String {
368 let vf = if version_files.is_empty() {
369 " version_files: []\n".to_string()
370 } else {
371 let mut s = " version_files:\n".to_string();
372 for f in version_files {
373 s.push_str(&format!(" - {f}\n"));
374 }
375 s
376 };
377
378 format!(
379 r#"# sr configuration
380# Full reference: https://github.com/urmzd/sr#configuration
381
382# How commits are parsed and classified.
383commit:
384 # Regex for parsing conventional commits.
385 # Required named groups: type, description. Optional: scope, breaking.
386 pattern: '^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)'
387
388 # Changelog section headings.
389 breaking_section: Breaking Changes
390 misc_section: Miscellaneous
391
392 # Commit type definitions.
393 types:
394 - name: feat
395 bump: minor
396 section: Features
397 - name: fix
398 bump: patch
399 section: Bug Fixes
400 - name: perf
401 bump: patch
402 section: Performance
403 - name: docs
404 section: Documentation
405 - name: refactor
406 bump: patch
407 section: Refactoring
408 - name: revert
409 section: Reverts
410 - name: chore
411 - name: ci
412 - name: test
413 - name: build
414 - name: style
415
416# How releases are cut.
417release:
418 branches:
419 - main
420 tag_prefix: "v"
421 changelog:
422 file: CHANGELOG.md
423{vf} version_files_strict: false
424 artifacts: []
425 floating_tags: true
426 stage_files: []
427 sign_tags: false
428 draft: false
429 # prerelease: alpha
430 # release_name_template: "{{{{ tag_name }}}}"
431
432 # Release channels for trunk-based promotion.
433 # channels:
434 # canary:
435 # prerelease: canary
436 # rc:
437 # prerelease: rc
438 # draft: true
439 # stable: {{}}
440 # default_channel: stable
441
442# Lifecycle hooks — shell commands keyed by event.
443# Available events: pre_commit, post_commit, pre_branch, post_branch,
444# pre_pr, post_pr, pre_review, post_review, pre_release, post_release.
445# Release hooks receive SR_VERSION and SR_TAG env vars.
446# hooks:
447# pre_commit:
448# - "cargo fmt --check"
449# - "cargo clippy -- -D warnings"
450# pre_release:
451# - "cargo test --workspace"
452# post_release:
453# - "./scripts/notify-slack.sh"
454
455# Monorepo packages (uncomment and configure if needed).
456# packages:
457# - name: core
458# path: crates/core
459# tag_prefix: "core/v"
460# version_files:
461# - crates/core/Cargo.toml
462# stage_files:
463# - crates/core/Cargo.lock
464"#
465 )
466}
467
468pub fn merge_config_yaml(existing_yaml: &str) -> Result<String, ReleaseError> {
470 let mut existing: serde_yaml_ng::Value = serde_yaml_ng::from_str(existing_yaml)
471 .map_err(|e| ReleaseError::Config(format!("failed to parse existing config: {e}")))?;
472
473 let default_config = Config::default();
474 let default_yaml = serde_yaml_ng::to_string(&default_config)
475 .map_err(|e| ReleaseError::Config(e.to_string()))?;
476 let defaults: serde_yaml_ng::Value =
477 serde_yaml_ng::from_str(&default_yaml).map_err(|e| ReleaseError::Config(e.to_string()))?;
478
479 deep_merge_value(&mut existing, &defaults);
480
481 let merged =
482 serde_yaml_ng::to_string(&existing).map_err(|e| ReleaseError::Config(e.to_string()))?;
483
484 Ok(format!(
485 "# sr configuration — merged with new defaults\n\
486 # Run 'sr init --force' for a fully-commented template.\n\n\
487 {merged}"
488 ))
489}
490
491fn deep_merge_value(base: &mut serde_yaml_ng::Value, defaults: &serde_yaml_ng::Value) {
492 use serde_yaml_ng::Value;
493 if let (Value::Mapping(base_map), Value::Mapping(default_map)) = (base, defaults) {
494 for (key, default_val) in default_map {
495 match base_map.get_mut(key) {
496 Some(existing_val) => {
497 if matches!(default_val, Value::Mapping(_)) {
498 deep_merge_value(existing_val, default_val);
499 }
500 }
501 None => {
502 base_map.insert(key.clone(), default_val.clone());
503 }
504 }
505 }
506 }
507}
508
509impl<'de> Deserialize<'de> for BumpLevel {
514 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
515 where
516 D: serde::Deserializer<'de>,
517 {
518 let s = String::deserialize(deserializer)?;
519 match s.as_str() {
520 "major" => Ok(BumpLevel::Major),
521 "minor" => Ok(BumpLevel::Minor),
522 "patch" => Ok(BumpLevel::Patch),
523 _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
524 }
525 }
526}
527
528impl Serialize for BumpLevel {
529 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
530 where
531 S: serde::Serializer,
532 {
533 let s = match self {
534 BumpLevel::Major => "major",
535 BumpLevel::Minor => "minor",
536 BumpLevel::Patch => "patch",
537 };
538 serializer.serialize_str(s)
539 }
540}
541
542#[cfg(test)]
547mod tests {
548 use super::*;
549 use std::io::Write;
550
551 #[test]
552 fn default_values() {
553 let config = Config::default();
554 assert_eq!(config.release.branches, vec!["main"]);
555 assert_eq!(config.release.tag_prefix, "v");
556 assert_eq!(config.commit.pattern, DEFAULT_COMMIT_PATTERN);
557 assert_eq!(config.commit.breaking_section, "Breaking Changes");
558 assert_eq!(config.commit.misc_section, "Miscellaneous");
559 assert!(!config.commit.types.is_empty());
560 assert!(!config.release.version_files_strict);
561 assert!(config.release.artifacts.is_empty());
562 assert!(config.release.floating_tags);
563 assert_eq!(
564 config.release.changelog.file.as_deref(),
565 Some("CHANGELOG.md")
566 );
567 let refactor = config
568 .commit
569 .types
570 .iter()
571 .find(|t| t.name == "refactor")
572 .unwrap();
573 assert_eq!(refactor.bump, Some(BumpLevel::Patch));
574 }
575
576 #[test]
577 fn load_missing_file() {
578 let dir = tempfile::tempdir().unwrap();
579 let path = dir.path().join("nonexistent.yml");
580 let config = Config::load(&path).unwrap();
581 assert_eq!(config.release.tag_prefix, "v");
582 }
583
584 #[test]
585 fn load_nested_yaml() {
586 let dir = tempfile::tempdir().unwrap();
587 let path = dir.path().join("config.yml");
588 let mut f = std::fs::File::create(&path).unwrap();
589 writeln!(
590 f,
591 "commit:\n pattern: custom\nrelease:\n branches:\n - develop\n tag_prefix: release-"
592 )
593 .unwrap();
594
595 let config = Config::load(&path).unwrap();
596 assert_eq!(config.release.branches, vec!["develop"]);
597 assert_eq!(config.release.tag_prefix, "release-");
598 assert_eq!(config.commit.pattern, "custom");
599 }
600
601 #[test]
602 fn load_partial_yaml() {
603 let dir = tempfile::tempdir().unwrap();
604 let path = dir.path().join("config.yml");
605 std::fs::write(&path, "release:\n tag_prefix: rel-\n").unwrap();
606
607 let config = Config::load(&path).unwrap();
608 assert_eq!(config.release.tag_prefix, "rel-");
609 assert_eq!(config.release.branches, vec!["main"]);
610 assert_eq!(config.commit.pattern, DEFAULT_COMMIT_PATTERN);
611 }
612
613 #[test]
614 fn load_yaml_with_packages() {
615 let dir = tempfile::tempdir().unwrap();
616 let path = dir.path().join("config.yml");
617 std::fs::write(
618 &path,
619 "packages:\n - name: core\n path: crates/core\n version_files:\n - crates/core/Cargo.toml\n",
620 )
621 .unwrap();
622
623 let config = Config::load(&path).unwrap();
624 assert_eq!(config.packages.len(), 1);
625 assert_eq!(config.packages[0].name, "core");
626 }
627
628 #[test]
629 fn resolve_package_defaults() {
630 let config = Config {
631 packages: vec![PackageConfig {
632 name: "core".into(),
633 path: "crates/core".into(),
634 tag_prefix: None,
635 version_files: vec![],
636 changelog: None,
637 stage_files: vec![],
638 }],
639 ..Default::default()
640 };
641
642 let resolved = config.resolve_package(&config.packages[0]);
643 assert_eq!(resolved.release.tag_prefix, "core/v");
644 assert_eq!(resolved.release.path_filter.as_deref(), Some("crates/core"));
645 assert!(resolved.packages.is_empty());
646 }
647
648 #[test]
649 fn resolve_package_overrides() {
650 let mut config = Config::default();
651 config.release.version_files = vec!["Cargo.toml".into()];
652 config.packages = vec![PackageConfig {
653 name: "cli".into(),
654 path: "crates/cli".into(),
655 tag_prefix: Some("cli-v".into()),
656 version_files: vec!["crates/cli/Cargo.toml".into()],
657 changelog: Some(ChangelogConfig {
658 file: Some("crates/cli/CHANGELOG.md".into()),
659 template: None,
660 }),
661 stage_files: vec!["crates/cli/Cargo.lock".into()],
662 }];
663
664 let resolved = config.resolve_package(&config.packages[0]);
665 assert_eq!(resolved.release.tag_prefix, "cli-v");
666 assert_eq!(
667 resolved.release.version_files,
668 vec!["crates/cli/Cargo.toml"]
669 );
670 assert_eq!(resolved.release.stage_files, vec!["crates/cli/Cargo.lock"]);
671 }
672
673 #[test]
674 fn find_package_not_found() {
675 let config = Config::default();
676 let err = config.find_package("nonexistent").unwrap_err();
677 assert!(err.to_string().contains("nonexistent"));
678 }
679
680 #[test]
681 fn resolve_channel() {
682 let mut config = Config::default();
683 config.release.channels.insert(
684 "canary".into(),
685 ChannelConfig {
686 prerelease: Some("canary".into()),
687 ..Default::default()
688 },
689 );
690
691 let resolved = config.resolve_channel("canary").unwrap();
692 assert_eq!(resolved.release.prerelease.as_deref(), Some("canary"));
693 }
694
695 #[test]
696 fn resolve_channel_not_found() {
697 let config = Config::default();
698 assert!(config.resolve_channel("missing").is_err());
699 }
700
701 #[test]
702 fn hook_event_roundtrip() {
703 let mut hooks = BTreeMap::new();
704 hooks.insert(HookEvent::PreRelease, vec!["cargo test".to_string()]);
705 let config = HooksConfig { hooks };
706 let yaml = serde_yaml_ng::to_string(&config).unwrap();
707 assert!(yaml.contains("pre_release"));
708 let parsed: HooksConfig = serde_yaml_ng::from_str(&yaml).unwrap();
709 assert!(parsed.hooks.contains_key(&HookEvent::PreRelease));
710 }
711
712 #[test]
713 fn default_template_parses() {
714 let template = default_config_template(&[]);
715 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
716 assert_eq!(config.release.branches, vec!["main"]);
717 assert_eq!(config.release.tag_prefix, "v");
718 assert!(config.release.floating_tags);
719 }
720
721 #[test]
722 fn default_template_with_version_files() {
723 let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
724 let config: Config = serde_yaml_ng::from_str(&template).unwrap();
725 assert_eq!(
726 config.release.version_files,
727 vec!["Cargo.toml", "package.json"]
728 );
729 }
730
731 #[test]
732 fn bump_level_roundtrip() {
733 for (level, expected) in [
734 (BumpLevel::Major, "major"),
735 (BumpLevel::Minor, "minor"),
736 (BumpLevel::Patch, "patch"),
737 ] {
738 let yaml = serde_yaml_ng::to_string(&level).unwrap();
739 assert!(yaml.contains(expected));
740 let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
741 assert_eq!(parsed, level);
742 }
743 }
744
745 #[test]
746 fn versioning_mode_roundtrip() {
747 for (mode, label) in [
748 (VersioningMode::Independent, "independent"),
749 (VersioningMode::Fixed, "fixed"),
750 ] {
751 let yaml = serde_yaml_ng::to_string(&mode).unwrap();
752 assert!(yaml.contains(label));
753 let parsed: VersioningMode = serde_yaml_ng::from_str(&yaml).unwrap();
754 assert_eq!(parsed, mode);
755 }
756 }
757}