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