Skip to main content

sr_core/
config.rs

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
11/// Preferred config file name for new projects.
12pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
13
14/// Legacy config file name (deprecated, will be removed in a future release).
15pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
16
17/// Config file candidates, checked in priority order.
18pub const CONFIG_CANDIDATES: &[&str] = &["sr.yaml", "sr.yml", LEGACY_CONFIG_FILE];
19
20// ---------------------------------------------------------------------------
21// Top-level config
22// ---------------------------------------------------------------------------
23
24/// Root configuration. Three top-level concerns:
25/// - `commit` — how commits are parsed
26/// - `release` — how releases are cut
27/// - `hooks` — what runs at each lifecycle event
28///
29/// ```yaml
30/// commit:
31///   types: [...]
32///   pattern: '...'
33///
34/// release:
35///   branches: [main]
36///   tag_prefix: "v"
37///   version_files: [Cargo.toml]
38///   channels:
39///     canary: { prerelease: canary }
40///     stable: {}
41///
42/// hooks:
43///   pre_commit: ["cargo fmt --check"]
44///   pre_release: ["cargo test --workspace"]
45/// ```
46#[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    /// Monorepo packages.
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub packages: Vec<PackageConfig>,
55}
56
57// ---------------------------------------------------------------------------
58// Commit config
59// ---------------------------------------------------------------------------
60
61/// How commits are parsed and classified.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(default)]
64pub struct CommitConfig {
65    /// Regex for parsing conventional commits.
66    pub pattern: String,
67    /// Changelog section heading for breaking changes.
68    pub breaking_section: String,
69    /// Fallback changelog section for unrecognised commit types.
70    pub misc_section: String,
71    /// Commit type definitions.
72    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// ---------------------------------------------------------------------------
87// Release config
88// ---------------------------------------------------------------------------
89
90/// How releases are cut — versioning, changelog, tags, artifacts.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(default)]
93pub struct ReleaseConfig {
94    /// Branches that trigger releases.
95    pub branches: Vec<String>,
96    /// Prefix for git tags (e.g. "v" → "v1.2.0").
97    pub tag_prefix: String,
98    /// Changelog configuration.
99    pub changelog: ChangelogConfig,
100    /// Manifest files to bump (auto-detected if empty).
101    pub version_files: Vec<String>,
102    /// Fail on unsupported version file formats.
103    pub version_files_strict: bool,
104    /// Glob patterns for release artifacts.
105    pub artifacts: Vec<String>,
106    /// Create floating major version tags (e.g. "v3" → latest v3.x.x).
107    pub floating_tags: bool,
108    /// Additional files to stage in the release commit.
109    pub stage_files: Vec<String>,
110    /// Pre-release identifier (e.g. "alpha", "rc").
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub prerelease: Option<String>,
113    /// Sign tags with GPG/SSH.
114    pub sign_tags: bool,
115    /// Create GitHub releases as drafts.
116    pub draft: bool,
117    /// Minijinja template for release name.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub release_name_template: Option<String>,
120    /// Versioning strategy for monorepo packages.
121    #[serde(default)]
122    pub versioning: VersioningMode,
123    /// Named release channels for trunk-based promotion.
124    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
125    pub channels: BTreeMap<String, ChannelConfig>,
126    /// Default channel when no --channel flag given.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub default_channel: Option<String>,
129    /// Internal: commits filtered to this path (set by resolve_package).
130    #[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// ---------------------------------------------------------------------------
158// Supporting types
159// ---------------------------------------------------------------------------
160
161#[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
231// ---------------------------------------------------------------------------
232// Config methods
233// ---------------------------------------------------------------------------
234
235impl Config {
236    /// Find the first config file that exists in the given directory.
237    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    /// Load config from a YAML file. Falls back to defaults if the file doesn't exist.
249    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    /// Resolve a package into a full config by merging package overrides.
259    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    /// Resolve all packages for fixed versioning mode.
288    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    /// Resolve a named release channel.
318    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    /// Find a package by name.
345    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
363// ---------------------------------------------------------------------------
364// Template generation
365// ---------------------------------------------------------------------------
366
367pub 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
468// ---------------------------------------------------------------------------
469// Serde for BumpLevel
470// ---------------------------------------------------------------------------
471
472impl<'de> Deserialize<'de> for BumpLevel {
473    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
474    where
475        D: serde::Deserializer<'de>,
476    {
477        let s = String::deserialize(deserializer)?;
478        match s.as_str() {
479            "major" => Ok(BumpLevel::Major),
480            "minor" => Ok(BumpLevel::Minor),
481            "patch" => Ok(BumpLevel::Patch),
482            _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
483        }
484    }
485}
486
487impl Serialize for BumpLevel {
488    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
489    where
490        S: serde::Serializer,
491    {
492        let s = match self {
493            BumpLevel::Major => "major",
494            BumpLevel::Minor => "minor",
495            BumpLevel::Patch => "patch",
496        };
497        serializer.serialize_str(s)
498    }
499}
500
501// ---------------------------------------------------------------------------
502// Tests
503// ---------------------------------------------------------------------------
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use std::io::Write;
509
510    #[test]
511    fn default_values() {
512        let config = Config::default();
513        assert_eq!(config.release.branches, vec!["main"]);
514        assert_eq!(config.release.tag_prefix, "v");
515        assert_eq!(config.commit.pattern, DEFAULT_COMMIT_PATTERN);
516        assert_eq!(config.commit.breaking_section, "Breaking Changes");
517        assert_eq!(config.commit.misc_section, "Miscellaneous");
518        assert!(!config.commit.types.is_empty());
519        assert!(!config.release.version_files_strict);
520        assert!(config.release.artifacts.is_empty());
521        assert!(config.release.floating_tags);
522        assert_eq!(
523            config.release.changelog.file.as_deref(),
524            Some("CHANGELOG.md")
525        );
526        let refactor = config
527            .commit
528            .types
529            .iter()
530            .find(|t| t.name == "refactor")
531            .unwrap();
532        assert_eq!(refactor.bump, Some(BumpLevel::Patch));
533    }
534
535    #[test]
536    fn load_missing_file() {
537        let dir = tempfile::tempdir().unwrap();
538        let path = dir.path().join("nonexistent.yml");
539        let config = Config::load(&path).unwrap();
540        assert_eq!(config.release.tag_prefix, "v");
541    }
542
543    #[test]
544    fn load_nested_yaml() {
545        let dir = tempfile::tempdir().unwrap();
546        let path = dir.path().join("config.yml");
547        let mut f = std::fs::File::create(&path).unwrap();
548        writeln!(
549            f,
550            "commit:\n  pattern: custom\nrelease:\n  branches:\n    - develop\n  tag_prefix: release-"
551        )
552        .unwrap();
553
554        let config = Config::load(&path).unwrap();
555        assert_eq!(config.release.branches, vec!["develop"]);
556        assert_eq!(config.release.tag_prefix, "release-");
557        assert_eq!(config.commit.pattern, "custom");
558    }
559
560    #[test]
561    fn load_partial_yaml() {
562        let dir = tempfile::tempdir().unwrap();
563        let path = dir.path().join("config.yml");
564        std::fs::write(&path, "release:\n  tag_prefix: rel-\n").unwrap();
565
566        let config = Config::load(&path).unwrap();
567        assert_eq!(config.release.tag_prefix, "rel-");
568        assert_eq!(config.release.branches, vec!["main"]);
569        assert_eq!(config.commit.pattern, DEFAULT_COMMIT_PATTERN);
570    }
571
572    #[test]
573    fn load_yaml_with_packages() {
574        let dir = tempfile::tempdir().unwrap();
575        let path = dir.path().join("config.yml");
576        std::fs::write(
577            &path,
578            "packages:\n  - name: core\n    path: crates/core\n    version_files:\n      - crates/core/Cargo.toml\n",
579        )
580        .unwrap();
581
582        let config = Config::load(&path).unwrap();
583        assert_eq!(config.packages.len(), 1);
584        assert_eq!(config.packages[0].name, "core");
585    }
586
587    #[test]
588    fn resolve_package_defaults() {
589        let config = Config {
590            packages: vec![PackageConfig {
591                name: "core".into(),
592                path: "crates/core".into(),
593                tag_prefix: None,
594                version_files: vec![],
595                changelog: None,
596                stage_files: vec![],
597            }],
598            ..Default::default()
599        };
600
601        let resolved = config.resolve_package(&config.packages[0]);
602        assert_eq!(resolved.release.tag_prefix, "core/v");
603        assert_eq!(resolved.release.path_filter.as_deref(), Some("crates/core"));
604        assert!(resolved.packages.is_empty());
605    }
606
607    #[test]
608    fn resolve_package_overrides() {
609        let mut config = Config::default();
610        config.release.version_files = vec!["Cargo.toml".into()];
611        config.packages = vec![PackageConfig {
612            name: "cli".into(),
613            path: "crates/cli".into(),
614            tag_prefix: Some("cli-v".into()),
615            version_files: vec!["crates/cli/Cargo.toml".into()],
616            changelog: Some(ChangelogConfig {
617                file: Some("crates/cli/CHANGELOG.md".into()),
618                template: None,
619            }),
620            stage_files: vec!["crates/cli/Cargo.lock".into()],
621        }];
622
623        let resolved = config.resolve_package(&config.packages[0]);
624        assert_eq!(resolved.release.tag_prefix, "cli-v");
625        assert_eq!(
626            resolved.release.version_files,
627            vec!["crates/cli/Cargo.toml"]
628        );
629        assert_eq!(resolved.release.stage_files, vec!["crates/cli/Cargo.lock"]);
630    }
631
632    #[test]
633    fn find_package_not_found() {
634        let config = Config::default();
635        let err = config.find_package("nonexistent").unwrap_err();
636        assert!(err.to_string().contains("nonexistent"));
637    }
638
639    #[test]
640    fn resolve_channel() {
641        let mut config = Config::default();
642        config.release.channels.insert(
643            "canary".into(),
644            ChannelConfig {
645                prerelease: Some("canary".into()),
646                ..Default::default()
647            },
648        );
649
650        let resolved = config.resolve_channel("canary").unwrap();
651        assert_eq!(resolved.release.prerelease.as_deref(), Some("canary"));
652    }
653
654    #[test]
655    fn resolve_channel_not_found() {
656        let config = Config::default();
657        assert!(config.resolve_channel("missing").is_err());
658    }
659
660    #[test]
661    fn hook_event_roundtrip() {
662        let mut hooks = BTreeMap::new();
663        hooks.insert(HookEvent::PreRelease, vec!["cargo test".to_string()]);
664        let config = HooksConfig { hooks };
665        let yaml = serde_yaml_ng::to_string(&config).unwrap();
666        assert!(yaml.contains("pre_release"));
667        let parsed: HooksConfig = serde_yaml_ng::from_str(&yaml).unwrap();
668        assert!(parsed.hooks.contains_key(&HookEvent::PreRelease));
669    }
670
671    #[test]
672    fn default_template_parses() {
673        let template = default_config_template(&[]);
674        let config: Config = serde_yaml_ng::from_str(&template).unwrap();
675        assert_eq!(config.release.branches, vec!["main"]);
676        assert_eq!(config.release.tag_prefix, "v");
677        assert!(config.release.floating_tags);
678    }
679
680    #[test]
681    fn default_template_with_version_files() {
682        let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
683        let config: Config = serde_yaml_ng::from_str(&template).unwrap();
684        assert_eq!(
685            config.release.version_files,
686            vec!["Cargo.toml", "package.json"]
687        );
688    }
689
690    #[test]
691    fn bump_level_roundtrip() {
692        for (level, expected) in [
693            (BumpLevel::Major, "major"),
694            (BumpLevel::Minor, "minor"),
695            (BumpLevel::Patch, "patch"),
696        ] {
697            let yaml = serde_yaml_ng::to_string(&level).unwrap();
698            assert!(yaml.contains(expected));
699            let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
700            assert_eq!(parsed, level);
701        }
702    }
703
704    #[test]
705    fn versioning_mode_roundtrip() {
706        for (mode, label) in [
707            (VersioningMode::Independent, "independent"),
708            (VersioningMode::Fixed, "fixed"),
709        ] {
710            let yaml = serde_yaml_ng::to_string(&mode).unwrap();
711            assert!(yaml.contains(label));
712            let parsed: VersioningMode = serde_yaml_ng::from_str(&yaml).unwrap();
713            assert_eq!(parsed, mode);
714        }
715    }
716}