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, Serialize, Deserialize)]
21#[serde(default)]
22pub struct ReleaseConfig {
23 pub branches: Vec<String>,
24 pub tag_prefix: String,
25 pub commit_pattern: String,
26 pub breaking_section: String,
27 pub misc_section: String,
28 pub types: Vec<CommitType>,
29 pub changelog: ChangelogConfig,
30 pub version_files: Vec<String>,
31 pub version_files_strict: bool,
32 pub artifacts: Vec<String>,
33 pub floating_tags: bool,
34 pub build_command: Option<String>,
35 pub stage_files: Vec<String>,
37 pub prerelease: Option<String>,
40 pub pre_release_command: Option<String>,
42 pub post_release_command: Option<String>,
44 pub sign_tags: bool,
46 pub draft: bool,
48 pub release_name_template: Option<String>,
52 pub hooks: HooksConfig,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub packages: Vec<PackageConfig>,
57 #[serde(skip)]
59 pub path_filter: Option<String>,
60}
61
62impl Default for ReleaseConfig {
63 fn default() -> Self {
64 Self {
65 branches: vec!["main".into()],
66 tag_prefix: "v".into(),
67 commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
68 breaking_section: "Breaking Changes".into(),
69 misc_section: "Miscellaneous".into(),
70 types: default_commit_types(),
71 changelog: ChangelogConfig::default(),
72 version_files: vec![],
73 version_files_strict: false,
74 artifacts: vec![],
75 floating_tags: true,
76 build_command: None,
77 stage_files: vec![],
78 prerelease: None,
79 pre_release_command: None,
80 post_release_command: None,
81 sign_tags: false,
82 draft: false,
83 release_name_template: None,
84 hooks: HooksConfig::with_defaults(),
85 packages: vec![],
86 path_filter: None,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PackageConfig {
107 pub name: String,
109 pub path: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub tag_prefix: Option<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub version_files: Vec<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub changelog: Option<ChangelogConfig>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub build_command: Option<String>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub stage_files: Vec<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(untagged)]
146pub enum HookEntry {
147 Step {
148 step: String,
149 patterns: Vec<String>,
150 rules: Vec<String>,
151 },
152 Simple(String),
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default)]
163#[serde(transparent)]
164pub struct HooksConfig {
165 pub hooks: BTreeMap<String, Vec<HookEntry>>,
166}
167
168impl HooksConfig {
169 pub fn with_defaults() -> Self {
170 let mut hooks = BTreeMap::new();
171 hooks.insert(
172 "commit-msg".into(),
173 vec![HookEntry::Simple("sr hook commit-msg".into())],
174 );
175 Self { hooks }
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(default)]
181pub struct ChangelogConfig {
182 pub file: Option<String>,
183 pub template: Option<String>,
184}
185
186impl Default for ChangelogConfig {
187 fn default() -> Self {
188 Self {
189 file: Some("CHANGELOG.md".into()),
190 template: None,
191 }
192 }
193}
194
195impl ReleaseConfig {
196 pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
199 for &candidate in CONFIG_CANDIDATES {
200 let path = dir.join(candidate);
201 if path.exists() {
202 let is_legacy = candidate == LEGACY_CONFIG_FILE;
203 return Some((path, is_legacy));
204 }
205 }
206 None
207 }
208
209 pub fn load(path: &Path) -> Result<Self, ReleaseError> {
211 if !path.exists() {
212 return Ok(Self::default());
213 }
214
215 let contents =
216 std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
217
218 serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
219 }
220
221 pub fn resolve_package(&self, pkg: &PackageConfig) -> Self {
223 let mut config = self.clone();
224 config.tag_prefix = pkg
225 .tag_prefix
226 .clone()
227 .unwrap_or_else(|| format!("{}/v", pkg.name));
228 config.path_filter = Some(pkg.path.clone());
229 if !pkg.version_files.is_empty() {
230 config.version_files = pkg.version_files.clone();
231 } else if config.version_files.is_empty() {
232 let detected = detect_version_files(Path::new(&pkg.path));
234 if !detected.is_empty() {
235 config.version_files = detected
236 .into_iter()
237 .map(|f| format!("{}/{f}", pkg.path))
238 .collect();
239 }
240 }
241 if let Some(ref cl) = pkg.changelog {
242 config.changelog = cl.clone();
243 }
244 if let Some(ref cmd) = pkg.build_command {
245 config.build_command = Some(cmd.clone());
246 }
247 if !pkg.stage_files.is_empty() {
248 config.stage_files = pkg.stage_files.clone();
249 }
250 config.packages = vec![];
252 config
253 }
254
255 pub fn find_package(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
257 self.packages
258 .iter()
259 .find(|p| p.name == name)
260 .ok_or_else(|| {
261 let available: Vec<&str> = self.packages.iter().map(|p| p.name.as_str()).collect();
262 ReleaseError::Config(format!(
263 "package '{name}' not found. Available: {}",
264 if available.is_empty() {
265 "(none — no packages configured)".to_string()
266 } else {
267 available.join(", ")
268 }
269 ))
270 })
271 }
272}
273
274pub fn default_config_template(version_files: &[String]) -> String {
279 let vf = if version_files.is_empty() {
280 "version_files: []\n".to_string()
281 } else {
282 let mut s = "version_files:\n".to_string();
283 for f in version_files {
284 s.push_str(&format!(" - {f}\n"));
285 }
286 s
287 };
288
289 format!(
290 r#"# sr configuration
291# Full reference: https://github.com/urmzd/sr#configuration
292
293# Branches that trigger releases when commits are pushed.
294branches:
295 - main
296
297# Prefix prepended to version tags (e.g. "v1.2.0").
298tag_prefix: "v"
299
300# Regex for parsing conventional commits.
301# Required named groups: type, description.
302# Optional named groups: scope, breaking.
303commit_pattern: '^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)'
304
305# Changelog section heading for breaking changes.
306breaking_section: Breaking Changes
307
308# Fallback changelog section for unrecognised commit types.
309misc_section: Miscellaneous
310
311# Commit type definitions.
312# name: commit type prefix (e.g. "feat", "fix")
313# bump: version bump level — major, minor, patch, or omit for no bump
314# section: changelog section heading, or omit to exclude from changelog
315types:
316 - name: feat
317 bump: minor
318 section: Features
319 - name: fix
320 bump: patch
321 section: Bug Fixes
322 - name: perf
323 bump: patch
324 section: Performance
325 - name: docs
326 section: Documentation
327 - name: refactor
328 bump: patch
329 section: Refactoring
330 - name: revert
331 section: Reverts
332 - name: chore
333 - name: ci
334 - name: test
335 - name: build
336 - name: style
337
338# Changelog configuration.
339# file: path to the changelog file (e.g. CHANGELOG.md), or omit to skip writing
340# template: custom Minijinja template string for changelog rendering
341changelog:
342 file: CHANGELOG.md
343 template:
344
345# Manifest files to bump on release (e.g. Cargo.toml, package.json, pyproject.toml).
346# Auto-detected if empty.
347{vf}
348# Fail if a version file uses an unsupported format (default: skip unknown files).
349version_files_strict: false
350
351# Glob patterns for release assets to upload to GitHub (e.g. "dist/*.tar.gz").
352artifacts: []
353
354# Create floating major version tags (e.g. "v3" pointing to latest v3.x.x).
355floating_tags: true
356
357# Shell command to run after version files are bumped (e.g. "cargo build --release").
358build_command:
359
360# Additional files/globs to stage after build_command runs (e.g. Cargo.lock).
361stage_files: []
362
363# Pre-release identifier (e.g. "alpha", "beta", "rc").
364# When set, versions are formatted as X.Y.Z-<id>.N where N auto-increments.
365prerelease:
366
367# Shell command to run before the release starts (validation, checks).
368pre_release_command:
369
370# Shell command to run after the release completes (notifications, deployments).
371post_release_command:
372
373# Sign annotated tags with GPG/SSH (git tag -s).
374sign_tags: false
375
376# Create GitHub releases as drafts (requires manual publishing).
377draft: false
378
379# Minijinja template for the GitHub release name.
380# Available variables: version, tag_name, tag_prefix.
381# Default: uses the tag name (e.g. "v1.2.0").
382release_name_template:
383
384# Git hooks configuration.
385# Each key is a git hook name. Values can be simple commands or structured steps.
386# Steps with patterns only run when staged files match the globs.
387# Rules containing {{files}} receive the matched file list.
388# Hook scripts are generated in .githooks/ by "sr init".
389hooks:
390 commit-msg:
391 - sr hook commit-msg
392 # pre-commit:
393 # - step: format
394 # patterns:
395 # - "*.rs"
396 # rules:
397 # - "rustfmt --check --edition 2024 {{files}}"
398 # - step: lint
399 # patterns:
400 # - "*.rs"
401 # rules:
402 # - "cargo clippy --workspace -- -D warnings"
403
404# Monorepo packages (uncomment and configure if needed).
405# Each package is released independently with its own version, tags, and changelog.
406# packages:
407# - name: core
408# path: crates/core
409# tag_prefix: "core/v" # default: "<name>/v"
410# version_files:
411# - crates/core/Cargo.toml
412# changelog:
413# file: crates/core/CHANGELOG.md
414# build_command: cargo build -p core
415# stage_files:
416# - crates/core/Cargo.lock
417"#
418 )
419}
420
421pub fn merge_config_yaml(existing_yaml: &str) -> Result<String, ReleaseError> {
427 let mut existing: serde_yaml_ng::Value = serde_yaml_ng::from_str(existing_yaml)
428 .map_err(|e| ReleaseError::Config(format!("failed to parse existing config: {e}")))?;
429
430 let default_config = ReleaseConfig::default();
431 let default_yaml = serde_yaml_ng::to_string(&default_config)
432 .map_err(|e| ReleaseError::Config(e.to_string()))?;
433 let defaults: serde_yaml_ng::Value =
434 serde_yaml_ng::from_str(&default_yaml).map_err(|e| ReleaseError::Config(e.to_string()))?;
435
436 deep_merge_value(&mut existing, &defaults);
437
438 let merged =
439 serde_yaml_ng::to_string(&existing).map_err(|e| ReleaseError::Config(e.to_string()))?;
440
441 Ok(format!(
442 "# sr configuration — merged with new defaults\n\
443 # Run 'sr init --force' for a fully-commented template.\n\n\
444 {merged}"
445 ))
446}
447
448fn deep_merge_value(base: &mut serde_yaml_ng::Value, defaults: &serde_yaml_ng::Value) {
451 use serde_yaml_ng::Value;
452 if let (Value::Mapping(base_map), Value::Mapping(default_map)) = (base, defaults) {
453 for (key, default_val) in default_map {
454 match base_map.get_mut(key) {
455 Some(existing_val) => {
456 if matches!(default_val, Value::Mapping(_)) {
458 deep_merge_value(existing_val, default_val);
459 }
460 }
461 None => {
462 base_map.insert(key.clone(), default_val.clone());
463 }
464 }
465 }
466 }
467}
468
469impl<'de> Deserialize<'de> for BumpLevel {
471 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
472 where
473 D: serde::Deserializer<'de>,
474 {
475 let s = String::deserialize(deserializer)?;
476 match s.as_str() {
477 "major" => Ok(BumpLevel::Major),
478 "minor" => Ok(BumpLevel::Minor),
479 "patch" => Ok(BumpLevel::Patch),
480 _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
481 }
482 }
483}
484
485impl Serialize for BumpLevel {
486 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
487 where
488 S: serde::Serializer,
489 {
490 let s = match self {
491 BumpLevel::Major => "major",
492 BumpLevel::Minor => "minor",
493 BumpLevel::Patch => "patch",
494 };
495 serializer.serialize_str(s)
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use std::io::Write;
503
504 #[test]
505 fn default_values() {
506 let config = ReleaseConfig::default();
507 assert_eq!(config.branches, vec!["main"]);
508 assert_eq!(config.tag_prefix, "v");
509 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
510 assert_eq!(config.breaking_section, "Breaking Changes");
511 assert_eq!(config.misc_section, "Miscellaneous");
512 assert!(!config.types.is_empty());
513 assert!(!config.version_files_strict);
514 assert!(config.artifacts.is_empty());
515 assert!(config.floating_tags);
516 assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
517 }
518
519 #[test]
520 fn load_missing_file() {
521 let dir = tempfile::tempdir().unwrap();
522 let path = dir.path().join("nonexistent.yml");
523 let config = ReleaseConfig::load(&path).unwrap();
524 assert_eq!(config.tag_prefix, "v");
525 }
526
527 #[test]
528 fn load_valid_yaml() {
529 let dir = tempfile::tempdir().unwrap();
530 let path = dir.path().join("config.yml");
531 let mut f = std::fs::File::create(&path).unwrap();
532 writeln!(f, "branches:\n - develop\ntag_prefix: release-").unwrap();
533
534 let config = ReleaseConfig::load(&path).unwrap();
535 assert_eq!(config.branches, vec!["develop"]);
536 assert_eq!(config.tag_prefix, "release-");
537 }
538
539 #[test]
540 fn load_partial_yaml() {
541 let dir = tempfile::tempdir().unwrap();
542 let path = dir.path().join("config.yml");
543 std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
544
545 let config = ReleaseConfig::load(&path).unwrap();
546 assert_eq!(config.tag_prefix, "rel-");
547 assert_eq!(config.branches, vec!["main"]);
548 assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
550 assert_eq!(config.breaking_section, "Breaking Changes");
551 assert!(!config.types.is_empty());
552 }
553
554 #[test]
555 fn load_yaml_with_artifacts() {
556 let dir = tempfile::tempdir().unwrap();
557 let path = dir.path().join("config.yml");
558 std::fs::write(
559 &path,
560 "artifacts:\n - \"dist/*.tar.gz\"\n - \"build/output-*\"\n",
561 )
562 .unwrap();
563
564 let config = ReleaseConfig::load(&path).unwrap();
565 assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
566 assert_eq!(config.tag_prefix, "v");
568 }
569
570 #[test]
571 fn load_yaml_with_floating_tags() {
572 let dir = tempfile::tempdir().unwrap();
573 let path = dir.path().join("config.yml");
574 std::fs::write(&path, "floating_tags: true\n").unwrap();
575
576 let config = ReleaseConfig::load(&path).unwrap();
577 assert!(config.floating_tags);
578 assert_eq!(config.tag_prefix, "v");
580 }
581
582 #[test]
583 fn bump_level_roundtrip() {
584 for (level, expected) in [
585 (BumpLevel::Major, "major"),
586 (BumpLevel::Minor, "minor"),
587 (BumpLevel::Patch, "patch"),
588 ] {
589 let yaml = serde_yaml_ng::to_string(&level).unwrap();
590 assert!(yaml.contains(expected));
591 let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
592 assert_eq!(parsed, level);
593 }
594 }
595
596 #[test]
597 fn types_roundtrip() {
598 let config = ReleaseConfig::default();
599 let yaml = serde_yaml_ng::to_string(&config).unwrap();
600 let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
601 assert_eq!(parsed.types.len(), config.types.len());
602 assert_eq!(parsed.types[0].name, "feat");
603 assert_eq!(parsed.commit_pattern, config.commit_pattern);
604 assert_eq!(parsed.breaking_section, config.breaking_section);
605 }
606
607 #[test]
608 fn load_yaml_with_packages() {
609 let dir = tempfile::tempdir().unwrap();
610 let path = dir.path().join("config.yml");
611 std::fs::write(
612 &path,
613 r#"
614packages:
615 - name: core
616 path: crates/core
617 version_files:
618 - crates/core/Cargo.toml
619 - name: cli
620 path: crates/cli
621 tag_prefix: "cli-v"
622"#,
623 )
624 .unwrap();
625
626 let config = ReleaseConfig::load(&path).unwrap();
627 assert_eq!(config.packages.len(), 2);
628 assert_eq!(config.packages[0].name, "core");
629 assert_eq!(config.packages[0].path, "crates/core");
630 assert_eq!(config.packages[1].tag_prefix.as_deref(), Some("cli-v"));
631 }
632
633 #[test]
634 fn resolve_package_defaults() {
635 let config = ReleaseConfig {
636 packages: vec![PackageConfig {
637 name: "core".into(),
638 path: "crates/core".into(),
639 tag_prefix: None,
640 version_files: vec![],
641 changelog: None,
642 build_command: None,
643 stage_files: vec![],
644 }],
645 ..Default::default()
646 };
647
648 let resolved = config.resolve_package(&config.packages[0]);
649 assert_eq!(resolved.tag_prefix, "core/v");
650 assert_eq!(resolved.path_filter.as_deref(), Some("crates/core"));
651 assert_eq!(resolved.branches, config.branches);
653 assert!(resolved.packages.is_empty());
654 }
655
656 #[test]
657 fn resolve_package_overrides() {
658 let config = ReleaseConfig {
659 version_files: vec!["Cargo.toml".into()],
660 packages: vec![PackageConfig {
661 name: "cli".into(),
662 path: "crates/cli".into(),
663 tag_prefix: Some("cli-v".into()),
664 version_files: vec!["crates/cli/Cargo.toml".into()],
665 changelog: Some(ChangelogConfig {
666 file: Some("crates/cli/CHANGELOG.md".into()),
667 template: None,
668 }),
669 build_command: Some("cargo build -p cli".into()),
670 stage_files: vec!["crates/cli/Cargo.lock".into()],
671 }],
672 ..Default::default()
673 };
674
675 let resolved = config.resolve_package(&config.packages[0]);
676 assert_eq!(resolved.tag_prefix, "cli-v");
677 assert_eq!(resolved.version_files, vec!["crates/cli/Cargo.toml"]);
678 assert_eq!(
679 resolved.changelog.file.as_deref(),
680 Some("crates/cli/CHANGELOG.md")
681 );
682 assert_eq!(
683 resolved.build_command.as_deref(),
684 Some("cargo build -p cli")
685 );
686 assert_eq!(resolved.stage_files, vec!["crates/cli/Cargo.lock"]);
687 }
688
689 #[test]
690 fn find_package_found() {
691 let config = ReleaseConfig {
692 packages: vec![PackageConfig {
693 name: "core".into(),
694 path: "crates/core".into(),
695 tag_prefix: None,
696 version_files: vec![],
697 changelog: None,
698 build_command: None,
699 stage_files: vec![],
700 }],
701 ..Default::default()
702 };
703
704 let pkg = config.find_package("core").unwrap();
705 assert_eq!(pkg.name, "core");
706 }
707
708 #[test]
709 fn find_package_not_found() {
710 let config = ReleaseConfig::default();
711 let err = config.find_package("nonexistent").unwrap_err();
712 assert!(err.to_string().contains("nonexistent"));
713 assert!(err.to_string().contains("no packages configured"));
714 }
715
716 #[test]
717 fn packages_not_serialized_when_empty() {
718 let config = ReleaseConfig::default();
719 let yaml = serde_yaml_ng::to_string(&config).unwrap();
720 assert!(!yaml.contains("packages"));
721 }
722
723 #[test]
724 fn default_template_parses() {
725 let template = default_config_template(&[]);
726 let config: ReleaseConfig = serde_yaml_ng::from_str(&template).unwrap();
727 let default = ReleaseConfig::default();
728 assert_eq!(config.branches, default.branches);
729 assert_eq!(config.tag_prefix, default.tag_prefix);
730 assert_eq!(config.commit_pattern, default.commit_pattern);
731 assert_eq!(config.breaking_section, default.breaking_section);
732 assert_eq!(config.types.len(), default.types.len());
733 assert!(config.floating_tags);
734 assert!(!config.sign_tags);
735 assert!(!config.draft);
736 }
737
738 #[test]
739 fn default_template_with_version_files() {
740 let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
741 let config: ReleaseConfig = serde_yaml_ng::from_str(&template).unwrap();
742 assert_eq!(config.version_files, vec!["Cargo.toml", "package.json"]);
743 }
744
745 #[test]
746 fn default_template_contains_all_fields() {
747 let template = default_config_template(&[]);
748 for field in [
749 "branches",
750 "tag_prefix",
751 "commit_pattern",
752 "breaking_section",
753 "misc_section",
754 "types",
755 "changelog",
756 "version_files",
757 "version_files_strict",
758 "artifacts",
759 "floating_tags",
760 "build_command",
761 "stage_files",
762 "prerelease",
763 "pre_release_command",
764 "post_release_command",
765 "sign_tags",
766 "draft",
767 "release_name_template",
768 "hooks",
769 "packages",
770 ] {
771 assert!(template.contains(field), "template missing field: {field}");
772 }
773 }
774
775 #[test]
776 fn merge_adds_missing_fields() {
777 let existing = "tag_prefix: rel-\n";
778 let merged = merge_config_yaml(existing).unwrap();
779 let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
780 assert_eq!(config.tag_prefix, "rel-");
782 assert_eq!(config.branches, vec!["main"]);
784 assert_eq!(config.breaking_section, "Breaking Changes");
785 assert!(!config.types.is_empty());
786 }
787
788 #[test]
789 fn merge_preserves_user_values() {
790 let existing = "branches:\n - develop\ntag_prefix: release-\nfloating_tags: true\n";
791 let merged = merge_config_yaml(existing).unwrap();
792 let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
793 assert_eq!(config.branches, vec!["develop"]);
794 assert_eq!(config.tag_prefix, "release-");
795 assert!(config.floating_tags);
796 }
797
798 #[test]
799 fn merge_nested_changelog() {
800 let existing = "changelog:\n file: CHANGELOG.md\n";
801 let merged = merge_config_yaml(existing).unwrap();
802 let config: ReleaseConfig = serde_yaml_ng::from_str(&merged).unwrap();
803 assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
804 assert!(config.changelog.template.is_none());
806 }
807}