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    /// Monorepo packages. When non-empty, each package is released independently.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub packages: Vec<PackageConfig>,
56    /// Internal: set when resolving a package config. Commits are filtered to this path.
57    #[serde(skip)]
58    pub path_filter: Option<String>,
59}
60
61impl Default for ReleaseConfig {
62    fn default() -> Self {
63        Self {
64            branches: vec!["main".into(), "master".into()],
65            tag_prefix: "v".into(),
66            commit_pattern: DEFAULT_COMMIT_PATTERN.into(),
67            breaking_section: "Breaking Changes".into(),
68            misc_section: "Miscellaneous".into(),
69            types: default_commit_types(),
70            changelog: ChangelogConfig::default(),
71            version_files: vec![],
72            version_files_strict: false,
73            artifacts: vec![],
74            floating_tags: false,
75            build_command: None,
76            stage_files: vec![],
77            prerelease: None,
78            pre_release_command: None,
79            post_release_command: None,
80            sign_tags: false,
81            draft: false,
82            release_name_template: None,
83            hooks: HooksConfig::with_defaults(),
84            packages: vec![],
85            path_filter: None,
86        }
87    }
88}
89
90/// A package in a monorepo. Each package is released independently with its own
91/// version, tags, and changelog. Commits are filtered by `path`.
92///
93/// ```yaml
94/// packages:
95///   - name: core
96///     path: crates/core
97///     version_files:
98///       - crates/core/Cargo.toml
99///   - name: cli
100///     path: crates/cli
101///     version_files:
102///       - crates/cli/Cargo.toml
103/// ```
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct PackageConfig {
106    /// Package name — used in the default tag prefix (`{name}/v`).
107    pub name: String,
108    /// Directory path relative to the repo root. Only commits touching this path trigger a release.
109    pub path: String,
110    /// Tag prefix override (default: `{name}/v`).
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub tag_prefix: Option<String>,
113    /// Version files override.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub version_files: Vec<String>,
116    /// Changelog override.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub changelog: Option<ChangelogConfig>,
119    /// Build command override.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub build_command: Option<String>,
122    /// Stage files override.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub stage_files: Vec<String>,
125}
126
127/// Git hooks configuration.
128///
129/// Each key is a git hook name (e.g. `commit-msg`, `pre-commit`, `pre-push`)
130/// and the value is a list of shell commands to run sequentially.
131/// Hook scripts in `.githooks/` are generated by `sr init`.
132///
133/// ```yaml
134/// hooks:
135///   commit-msg:
136///     - sr hook commit-msg "$1"
137///   pre-commit:
138///     - cargo fmt -- --check
139///     - cargo clippy --workspace -- -D warnings
140///   pre-push:
141///     - cargo test --workspace
142/// ```
143#[derive(Debug, Clone, Serialize, Deserialize, Default)]
144#[serde(transparent)]
145pub struct HooksConfig {
146    pub hooks: BTreeMap<String, Vec<String>>,
147}
148
149impl HooksConfig {
150    pub fn with_defaults() -> Self {
151        let mut hooks = BTreeMap::new();
152        hooks.insert("commit-msg".into(), vec!["sr hook commit-msg".into()]);
153        Self { hooks }
154    }
155}
156
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158#[serde(default)]
159pub struct ChangelogConfig {
160    pub file: Option<String>,
161    pub template: Option<String>,
162}
163
164impl ReleaseConfig {
165    /// Find the first config file that exists in the given directory.
166    /// Returns `(path, is_legacy)`.
167    pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
168        for &candidate in CONFIG_CANDIDATES {
169            let path = dir.join(candidate);
170            if path.exists() {
171                let is_legacy = candidate == LEGACY_CONFIG_FILE;
172                return Some((path, is_legacy));
173            }
174        }
175        None
176    }
177
178    /// Load config from a YAML file. Falls back to defaults if the file doesn't exist.
179    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
180        if !path.exists() {
181            return Ok(Self::default());
182        }
183
184        let contents =
185            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
186
187        serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))
188    }
189
190    /// Resolve a package into a full release config by merging package overrides with root config.
191    pub fn resolve_package(&self, pkg: &PackageConfig) -> Self {
192        let mut config = self.clone();
193        config.tag_prefix = pkg
194            .tag_prefix
195            .clone()
196            .unwrap_or_else(|| format!("{}/v", pkg.name));
197        config.path_filter = Some(pkg.path.clone());
198        if !pkg.version_files.is_empty() {
199            config.version_files = pkg.version_files.clone();
200        }
201        if let Some(ref cl) = pkg.changelog {
202            config.changelog = cl.clone();
203        }
204        if let Some(ref cmd) = pkg.build_command {
205            config.build_command = Some(cmd.clone());
206        }
207        if !pkg.stage_files.is_empty() {
208            config.stage_files = pkg.stage_files.clone();
209        }
210        // Clear packages to avoid recursion
211        config.packages = vec![];
212        config
213    }
214
215    /// Find a package by name. Returns an error if the package is not found.
216    pub fn find_package(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
217        self.packages
218            .iter()
219            .find(|p| p.name == name)
220            .ok_or_else(|| {
221                let available: Vec<&str> = self.packages.iter().map(|p| p.name.as_str()).collect();
222                ReleaseError::Config(format!(
223                    "package '{name}' not found. Available: {}",
224                    if available.is_empty() {
225                        "(none — no packages configured)".to_string()
226                    } else {
227                        available.join(", ")
228                    }
229                ))
230            })
231    }
232}
233
234// Custom deserialization for BumpLevel so it can appear in YAML config.
235impl<'de> Deserialize<'de> for BumpLevel {
236    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
237    where
238        D: serde::Deserializer<'de>,
239    {
240        let s = String::deserialize(deserializer)?;
241        match s.as_str() {
242            "major" => Ok(BumpLevel::Major),
243            "minor" => Ok(BumpLevel::Minor),
244            "patch" => Ok(BumpLevel::Patch),
245            _ => Err(serde::de::Error::custom(format!("unknown bump level: {s}"))),
246        }
247    }
248}
249
250impl Serialize for BumpLevel {
251    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
252    where
253        S: serde::Serializer,
254    {
255        let s = match self {
256            BumpLevel::Major => "major",
257            BumpLevel::Minor => "minor",
258            BumpLevel::Patch => "patch",
259        };
260        serializer.serialize_str(s)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use std::io::Write;
268
269    #[test]
270    fn default_values() {
271        let config = ReleaseConfig::default();
272        assert_eq!(config.branches, vec!["main", "master"]);
273        assert_eq!(config.tag_prefix, "v");
274        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
275        assert_eq!(config.breaking_section, "Breaking Changes");
276        assert_eq!(config.misc_section, "Miscellaneous");
277        assert!(!config.types.is_empty());
278        assert!(!config.version_files_strict);
279        assert!(config.artifacts.is_empty());
280        assert!(!config.floating_tags);
281    }
282
283    #[test]
284    fn load_missing_file() {
285        let dir = tempfile::tempdir().unwrap();
286        let path = dir.path().join("nonexistent.yml");
287        let config = ReleaseConfig::load(&path).unwrap();
288        assert_eq!(config.tag_prefix, "v");
289    }
290
291    #[test]
292    fn load_valid_yaml() {
293        let dir = tempfile::tempdir().unwrap();
294        let path = dir.path().join("config.yml");
295        let mut f = std::fs::File::create(&path).unwrap();
296        writeln!(f, "branches:\n  - develop\ntag_prefix: release-").unwrap();
297
298        let config = ReleaseConfig::load(&path).unwrap();
299        assert_eq!(config.branches, vec!["develop"]);
300        assert_eq!(config.tag_prefix, "release-");
301    }
302
303    #[test]
304    fn load_partial_yaml() {
305        let dir = tempfile::tempdir().unwrap();
306        let path = dir.path().join("config.yml");
307        std::fs::write(&path, "tag_prefix: rel-\n").unwrap();
308
309        let config = ReleaseConfig::load(&path).unwrap();
310        assert_eq!(config.tag_prefix, "rel-");
311        assert_eq!(config.branches, vec!["main", "master"]);
312        // defaults should still apply for types/pattern/breaking_section
313        assert_eq!(config.commit_pattern, DEFAULT_COMMIT_PATTERN);
314        assert_eq!(config.breaking_section, "Breaking Changes");
315        assert!(!config.types.is_empty());
316    }
317
318    #[test]
319    fn load_yaml_with_artifacts() {
320        let dir = tempfile::tempdir().unwrap();
321        let path = dir.path().join("config.yml");
322        std::fs::write(
323            &path,
324            "artifacts:\n  - \"dist/*.tar.gz\"\n  - \"build/output-*\"\n",
325        )
326        .unwrap();
327
328        let config = ReleaseConfig::load(&path).unwrap();
329        assert_eq!(config.artifacts, vec!["dist/*.tar.gz", "build/output-*"]);
330        // defaults still apply
331        assert_eq!(config.tag_prefix, "v");
332    }
333
334    #[test]
335    fn load_yaml_with_floating_tags() {
336        let dir = tempfile::tempdir().unwrap();
337        let path = dir.path().join("config.yml");
338        std::fs::write(&path, "floating_tags: true\n").unwrap();
339
340        let config = ReleaseConfig::load(&path).unwrap();
341        assert!(config.floating_tags);
342        // defaults still apply
343        assert_eq!(config.tag_prefix, "v");
344    }
345
346    #[test]
347    fn bump_level_roundtrip() {
348        for (level, expected) in [
349            (BumpLevel::Major, "major"),
350            (BumpLevel::Minor, "minor"),
351            (BumpLevel::Patch, "patch"),
352        ] {
353            let yaml = serde_yaml_ng::to_string(&level).unwrap();
354            assert!(yaml.contains(expected));
355            let parsed: BumpLevel = serde_yaml_ng::from_str(&yaml).unwrap();
356            assert_eq!(parsed, level);
357        }
358    }
359
360    #[test]
361    fn types_roundtrip() {
362        let config = ReleaseConfig::default();
363        let yaml = serde_yaml_ng::to_string(&config).unwrap();
364        let parsed: ReleaseConfig = serde_yaml_ng::from_str(&yaml).unwrap();
365        assert_eq!(parsed.types.len(), config.types.len());
366        assert_eq!(parsed.types[0].name, "feat");
367        assert_eq!(parsed.commit_pattern, config.commit_pattern);
368        assert_eq!(parsed.breaking_section, config.breaking_section);
369    }
370
371    #[test]
372    fn load_yaml_with_packages() {
373        let dir = tempfile::tempdir().unwrap();
374        let path = dir.path().join("config.yml");
375        std::fs::write(
376            &path,
377            r#"
378packages:
379  - name: core
380    path: crates/core
381    version_files:
382      - crates/core/Cargo.toml
383  - name: cli
384    path: crates/cli
385    tag_prefix: "cli-v"
386"#,
387        )
388        .unwrap();
389
390        let config = ReleaseConfig::load(&path).unwrap();
391        assert_eq!(config.packages.len(), 2);
392        assert_eq!(config.packages[0].name, "core");
393        assert_eq!(config.packages[0].path, "crates/core");
394        assert_eq!(config.packages[1].tag_prefix.as_deref(), Some("cli-v"));
395    }
396
397    #[test]
398    fn resolve_package_defaults() {
399        let mut config = ReleaseConfig::default();
400        config.packages = vec![PackageConfig {
401            name: "core".into(),
402            path: "crates/core".into(),
403            tag_prefix: None,
404            version_files: vec![],
405            changelog: None,
406            build_command: None,
407            stage_files: vec![],
408        }];
409
410        let resolved = config.resolve_package(&config.packages[0]);
411        assert_eq!(resolved.tag_prefix, "core/v");
412        assert_eq!(resolved.path_filter.as_deref(), Some("crates/core"));
413        // Inherits root config values
414        assert_eq!(resolved.branches, config.branches);
415        assert!(resolved.packages.is_empty());
416    }
417
418    #[test]
419    fn resolve_package_overrides() {
420        let mut config = ReleaseConfig::default();
421        config.version_files = vec!["Cargo.toml".into()];
422        config.packages = vec![PackageConfig {
423            name: "cli".into(),
424            path: "crates/cli".into(),
425            tag_prefix: Some("cli-v".into()),
426            version_files: vec!["crates/cli/Cargo.toml".into()],
427            changelog: Some(ChangelogConfig {
428                file: Some("crates/cli/CHANGELOG.md".into()),
429                template: None,
430            }),
431            build_command: Some("cargo build -p cli".into()),
432            stage_files: vec!["crates/cli/Cargo.lock".into()],
433        }];
434
435        let resolved = config.resolve_package(&config.packages[0]);
436        assert_eq!(resolved.tag_prefix, "cli-v");
437        assert_eq!(resolved.version_files, vec!["crates/cli/Cargo.toml"]);
438        assert_eq!(
439            resolved.changelog.file.as_deref(),
440            Some("crates/cli/CHANGELOG.md")
441        );
442        assert_eq!(
443            resolved.build_command.as_deref(),
444            Some("cargo build -p cli")
445        );
446        assert_eq!(resolved.stage_files, vec!["crates/cli/Cargo.lock"]);
447    }
448
449    #[test]
450    fn find_package_found() {
451        let mut config = ReleaseConfig::default();
452        config.packages = vec![PackageConfig {
453            name: "core".into(),
454            path: "crates/core".into(),
455            tag_prefix: None,
456            version_files: vec![],
457            changelog: None,
458            build_command: None,
459            stage_files: vec![],
460        }];
461
462        let pkg = config.find_package("core").unwrap();
463        assert_eq!(pkg.name, "core");
464    }
465
466    #[test]
467    fn find_package_not_found() {
468        let config = ReleaseConfig::default();
469        let err = config.find_package("nonexistent").unwrap_err();
470        assert!(err.to_string().contains("nonexistent"));
471        assert!(err.to_string().contains("no packages configured"));
472    }
473
474    #[test]
475    fn packages_not_serialized_when_empty() {
476        let config = ReleaseConfig::default();
477        let yaml = serde_yaml_ng::to_string(&config).unwrap();
478        assert!(!yaml.contains("packages"));
479    }
480}