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;
9
10/// Preferred config file name for new projects.
11pub const DEFAULT_CONFIG_FILE: &str = "sr.yaml";
12
13/// Legacy config file name (deprecated, will be removed in a future release).
14pub const LEGACY_CONFIG_FILE: &str = ".urmzd.sr.yml";
15
16/// Config file candidates, checked in priority order.
17pub 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    /// Additional files/globs to stage after `build_command` runs (e.g. `Cargo.lock`).
35    pub stage_files: Vec<String>,
36    /// Pre-release identifier (e.g. "alpha", "beta", "rc"). When set, versions are
37    /// formatted as X.Y.Z-<id>.N where N auto-increments.
38    pub prerelease: Option<String>,
39    /// Shell command to run before the release starts (validation, checks).
40    pub pre_release_command: Option<String>,
41    /// Shell command to run after the release completes (notifications, deployments).
42    pub post_release_command: Option<String>,
43    /// Sign annotated tags with GPG/SSH (git tag -s).
44    pub sign_tags: bool,
45    /// Create GitHub releases as drafts (requires manual publishing).
46    pub draft: bool,
47    /// Minijinja template for the GitHub release name.
48    /// Available variables: `version`, `tag_name`, `tag_prefix`.
49    /// Default when None: uses the tag name (e.g. "v1.2.0").
50    pub release_name_template: Option<String>,
51    /// Git hooks configuration.
52    pub hooks: HooksConfig,
53}
54
55impl Default for ReleaseConfig {
56    fn default() -> Self {
57        Self {
58            branches: vec!["main".into(), "master".into()],
59            tag_prefix: "v".into(),
60            commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
61            breaking_section: "Breaking Changes".into(),
62            misc_section: "Miscellaneous".into(),
63            types: default_commit_types(),
64            changelog: ChangelogConfig::default(),
65            version_files: vec![],
66            version_files_strict: false,
67            artifacts: vec![],
68            floating_tags: false,
69            build_command: None,
70            stage_files: vec![],
71            prerelease: None,
72            pre_release_command: None,
73            post_release_command: None,
74            sign_tags: false,
75            draft: false,
76            release_name_template: None,
77            hooks: HooksConfig::with_defaults(),
78        }
79    }
80}
81
82/// Git hooks configuration.
83///
84/// Each key is a git hook name (e.g. `commit-msg`, `pre-commit`, `pre-push`)
85/// and the value is a list of shell commands to run sequentially.
86/// Hook scripts in `.githooks/` are generated by `sr init`.
87///
88/// ```yaml
89/// hooks:
90///   commit-msg:
91///     - sr hook commit-msg "$1"
92///   pre-commit:
93///     - cargo fmt -- --check
94///     - cargo clippy --workspace -- -D warnings
95///   pre-push:
96///     - cargo test --workspace
97/// ```
98#[derive(Debug, Clone, Serialize, Deserialize, Default)]
99#[serde(transparent)]
100pub struct HooksConfig {
101    pub hooks: BTreeMap<String, Vec<String>>,
102}
103
104impl HooksConfig {
105    pub fn with_defaults() -> Self {
106        let mut hooks = BTreeMap::new();
107        hooks.insert("commit-msg".into(), vec!["sr hook commit-msg".into()]);
108        Self { hooks }
109    }
110}
111
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113#[serde(default)]
114pub struct ChangelogConfig {
115    pub file: Option<String>,
116    pub template: Option<String>,
117}
118
119impl ReleaseConfig {
120    /// Find the first config file that exists in the given directory.
121    /// Returns `(path, is_legacy)`.
122    pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
123        for &candidate in CONFIG_CANDIDATES {
124            let path = dir.join(candidate);
125            if path.exists() {
126                let is_legacy = candidate == LEGACY_CONFIG_FILE;
127                return Some((path, is_legacy));
128            }
129        }
130        None
131    }
132
133    /// Load config from a YAML file. Falls back to defaults if the file doesn't exist.
134    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
135        if !path.exists() {
136            return Ok(Self::default());
137        }
138
139        let contents =
140            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
141
142        serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
143    }
144}
145
146// Custom deserialization for BumpLevel so it can appear in YAML config.
147impl<'de> Deserialize<'de> for BumpLevel {
148    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149    where
150        D: serde::Deserializer<'de>,
151    {
152        let s = String::deserialize(deserializer)?;
153        match s.as_str() {
154            "major" => Ok(BumpLevel::Major),
155            "minor" => Ok(BumpLevel::Minor),
156            "patch" => Ok(BumpLevel::Patch),
157            _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
158        }
159    }
160}
161
162impl Serialize for BumpLevel {
163    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
164    where
165        S: serde::Serializer,
166    {
167        let s = match self {
168            BumpLevel::Major => "major",
169            BumpLevel::Minor => "minor",
170            BumpLevel::Patch => "patch",
171        };
172        serializer.serialize_str(s)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::io::Write;
180
181    #[test]
182    fn default_values() {
183        let config = ReleaseConfig::default();
184        assert_eq!(config.branches, vec!["main", "master"]);
185        assert_eq!(config.tag_prefix, "v");
186        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
187        assert_eq!(config.breaking_section, "Breaking Changes");
188        assert_eq!(config.misc_section, "Miscellaneous");
189        assert!(!config.types.is_empty());
190        assert!(!config.version_files_strict);
191        assert!(config.artifacts.is_empty());
192        assert!(!config.floating_tags);
193    }
194
195    #[test]
196    fn load_missing_file() {
197        let dir = tempfile::tempdir().unwrap();
198        let path = dir.path().join("nonexistent.yml");
199        let config = ReleaseConfig::load(&path).unwrap();
200        assert_eq!(config.tag_prefix, "v");
201    }
202
203    #[test]
204    fn load_valid_yaml() {
205        let dir = tempfile::tempdir().unwrap();
206        let path = dir.path().join("config.yml");
207        let mut f = std::fs::File::create(&path).unwrap();
208        writeln!(f, "branches:\n  - develop\ntag_prefix: release-").unwrap();
209
210        let config = ReleaseConfig::load(&path).unwrap();
211        assert_eq!(config.branches, vec!["develop"]);
212        assert_eq!(config.tag_prefix, "release-");
213    }
214
215    #[test]
216    fn load_partial_yaml() {
217        let dir = tempfile::tempdir().unwrap();
218        let path = dir.path().join("config.yml");
219        std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
220
221        let config = ReleaseConfig::load(&path).unwrap();
222        assert_eq!(config.tag_prefix, "rel-");
223        assert_eq!(config.branches, vec!["main", "master"]);
224        // defaults should still apply for types/pattern/breaking_section
225        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
226        assert_eq!(config.breaking_section, "Breaking Changes");
227        assert!(!config.types.is_empty());
228    }
229
230    #[test]
231    fn load_yaml_with_artifacts() {
232        let dir = tempfile::tempdir().unwrap();
233        let path = dir.path().join("config.yml");
234        std::fs::write(
235            &path,
236            "artifacts:\n  - \"dist/*.tar.gz\"\n  - \"build/output-*\"\n",
237        )
238        .unwrap();
239
240        let config = ReleaseConfig::load(&path).unwrap();
241        assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
242        // defaults still apply
243        assert_eq!(config.tag_prefix, "v");
244    }
245
246    #[test]
247    fn load_yaml_with_floating_tags() {
248        let dir = tempfile::tempdir().unwrap();
249        let path = dir.path().join("config.yml");
250        std::fs::write(&path, "floating_tags: true\n").unwrap();
251
252        let config = ReleaseConfig::load(&path).unwrap();
253        assert!(config.floating_tags);
254        // defaults still apply
255        assert_eq!(config.tag_prefix, "v");
256    }
257
258    #[test]
259    fn bump_level_roundtrip() {
260        for (level, expected) in [
261            (BumpLevel::Major, "major"),
262            (BumpLevel::Minor, "minor"),
263            (BumpLevel::Patch, "patch"),
264        ] {
265            let yaml = serde_yaml_ng::to_string(&level).unwrap();
266            assert!(yaml.contains(expected));
267            let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
268            assert_eq!(parsed, level);
269        }
270    }
271
272    #[test]
273    fn types_roundtrip() {
274        let config = ReleaseConfig::default();
275        let yaml = serde_yaml_ng::to_string(&config).unwrap();
276        let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
277        assert_eq!(parsed.types.len(), config.types.len());
278        assert_eq!(parsed.types[0].name, "feat");
279        assert_eq!(parsed.commit_pattern, config.commit_pattern);
280        assert_eq!(parsed.breaking_section, config.breaking_section);
281    }
282}