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