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