Skip to main content

sr_core/
config.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5use crate::commit::CommitType;
6use crate::error::ReleaseError;
7use crate::version::BumpLevel;
8use crate::version_files::detect_version_files;
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// ---------------------------------------------------------------------------
20// Top-level config
21// ---------------------------------------------------------------------------
22
23/// Root configuration. Six top-level concerns:
24/// - `git` — tag prefix, floating tags, signing
25/// - `commit` — type→bump classification
26/// - `changelog` — file, template, groups
27/// - `channels` — branch→release mapping
28/// - `vcs` — provider-specific config
29/// - `packages` — version files, artifacts, hooks
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct Config {
33    pub git: GitConfig,
34    pub commit: CommitConfig,
35    pub changelog: ChangelogConfig,
36    pub channels: ChannelsConfig,
37    pub vcs: VcsConfig,
38    #[serde(default = "default_packages")]
39    pub packages: Vec<PackageConfig>,
40}
41
42impl Default for Config {
43    fn default() -> Self {
44        Self {
45            git: GitConfig::default(),
46            commit: CommitConfig::default(),
47            changelog: ChangelogConfig::default(),
48            channels: ChannelsConfig::default(),
49            vcs: VcsConfig::default(),
50            packages: default_packages(),
51        }
52    }
53}
54
55fn default_packages() -> Vec<PackageConfig> {
56    vec![PackageConfig {
57        path: ".".into(),
58        ..Default::default()
59    }]
60}
61
62// ---------------------------------------------------------------------------
63// Git config
64// ---------------------------------------------------------------------------
65
66/// Git-level settings — tags, signing, identity, commit filtering.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(default)]
69pub struct GitConfig {
70    /// Prefix for git tags (e.g. "v" → "v1.2.0").
71    pub tag_prefix: String,
72    /// Create floating major version tags (e.g. "v3" → latest v3.x.x).
73    pub floating_tag: bool,
74    /// Sign tags with GPG/SSH.
75    pub sign_tags: bool,
76    /// Prevent breaking changes from bumping 0.x.y to 1.0.0.
77    /// When true, major bumps at v0 are downshifted to minor.
78    pub v0_protection: bool,
79    /// Override the git identity used for release commits and tags.
80    /// When unset, sr leaves it to the repo's git config / environment.
81    pub user: GitUserConfig,
82    /// Substrings that, when present in a commit message, exclude that
83    /// commit from release planning and changelog. Matched against the full
84    /// commit message. `chore(release):` is always filtered regardless.
85    pub skip_patterns: Vec<String>,
86}
87
88impl Default for GitConfig {
89    fn default() -> Self {
90        Self {
91            tag_prefix: "v".into(),
92            floating_tag: true,
93            sign_tags: false,
94            v0_protection: true,
95            user: GitUserConfig::default(),
96            skip_patterns: default_skip_patterns(),
97        }
98    }
99}
100
101/// Git author/committer identity for release operations.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[serde(default)]
104pub struct GitUserConfig {
105    /// Author/committer name. None = inherit from git config / env.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub name: Option<String>,
108    /// Author/committer email. None = inherit from git config / env.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub email: Option<String>,
111}
112
113/// Default skip tokens. `[skip release]` and `[skip sr]` are recognized out
114/// of the box so users can opt a commit out of the release without touching
115/// config.
116pub fn default_skip_patterns() -> Vec<String> {
117    vec!["[skip release]".into(), "[skip sr]".into()]
118}
119
120// ---------------------------------------------------------------------------
121// Commit config
122// ---------------------------------------------------------------------------
123
124/// How commits are classified by semver bump level.
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
126#[serde(default)]
127pub struct CommitConfig {
128    /// Commit types grouped by bump level.
129    pub types: CommitTypesConfig,
130}
131
132/// Commit type names grouped by the semver bump level they trigger.
133/// Breaking changes always bump major regardless of configured level.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(default)]
136pub struct CommitTypesConfig {
137    /// Types that trigger a minor version bump.
138    pub minor: Vec<String>,
139    /// Types that trigger a patch version bump.
140    pub patch: Vec<String>,
141    /// Types that do not trigger a release on their own.
142    pub none: Vec<String>,
143}
144
145impl Default for CommitTypesConfig {
146    fn default() -> Self {
147        Self {
148            minor: vec!["feat".into()],
149            patch: vec!["fix".into(), "perf".into(), "refactor".into()],
150            none: vec![
151                "docs".into(),
152                "revert".into(),
153                "chore".into(),
154                "ci".into(),
155                "test".into(),
156                "build".into(),
157                "style".into(),
158            ],
159        }
160    }
161}
162
163impl CommitTypesConfig {
164    /// All type names across all bump levels.
165    pub fn all_type_names(&self) -> Vec<&str> {
166        self.minor
167            .iter()
168            .chain(self.patch.iter())
169            .chain(self.none.iter())
170            .map(|s| s.as_str())
171            .collect()
172    }
173
174    /// Convert to internal `Vec<CommitType>` representation.
175    pub fn into_commit_types(&self) -> Vec<CommitType> {
176        let mut types = Vec::new();
177        for name in &self.minor {
178            types.push(CommitType {
179                name: name.clone(),
180                bump: Some(BumpLevel::Minor),
181            });
182        }
183        for name in &self.patch {
184            types.push(CommitType {
185                name: name.clone(),
186                bump: Some(BumpLevel::Patch),
187            });
188        }
189        for name in &self.none {
190            types.push(CommitType {
191                name: name.clone(),
192                bump: None,
193            });
194        }
195        types
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Changelog config
201// ---------------------------------------------------------------------------
202
203/// Changelog generation — file, template, and commit grouping.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(default)]
206pub struct ChangelogConfig {
207    /// Path to the changelog file. None = skip changelog generation.
208    pub file: Option<String>,
209    /// Jinja template — path to file or inline string. None = built-in default.
210    pub template: Option<String>,
211    /// Ordered groups for organizing commits in the changelog.
212    pub groups: Vec<ChangelogGroup>,
213}
214
215impl Default for ChangelogConfig {
216    fn default() -> Self {
217        Self {
218            file: Some("CHANGELOG.md".into()),
219            template: None,
220            groups: default_changelog_groups(),
221        }
222    }
223}
224
225/// A named group of commit types for changelog rendering.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ChangelogGroup {
228    /// Machine-readable name (e.g. "breaking", "features").
229    pub name: String,
230    /// Commit types included in this group (e.g. ["feat"]).
231    pub content: Vec<String>,
232}
233
234pub fn default_changelog_groups() -> Vec<ChangelogGroup> {
235    vec![
236        ChangelogGroup {
237            name: "breaking".into(),
238            content: vec!["breaking".into()],
239        },
240        ChangelogGroup {
241            name: "features".into(),
242            content: vec!["feat".into()],
243        },
244        ChangelogGroup {
245            name: "bug-fixes".into(),
246            content: vec!["fix".into()],
247        },
248        ChangelogGroup {
249            name: "performance".into(),
250            content: vec!["perf".into()],
251        },
252        ChangelogGroup {
253            name: "refactoring".into(),
254            content: vec!["refactor".into()],
255        },
256        ChangelogGroup {
257            name: "misc".into(),
258            content: vec![
259                "docs".into(),
260                "revert".into(),
261                "chore".into(),
262                "ci".into(),
263                "test".into(),
264                "build".into(),
265                "style".into(),
266            ],
267        },
268    ]
269}
270
271// ---------------------------------------------------------------------------
272// Channels config
273// ---------------------------------------------------------------------------
274
275/// Release channels for trunk-based promotion.
276/// All channels release from the same branch — channels control the release
277/// strategy (stable vs prerelease vs draft), not the branch.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(default)]
280pub struct ChannelsConfig {
281    /// Default channel when no --channel flag given.
282    pub default: String,
283    /// The trunk branch that triggers releases.
284    pub branch: String,
285    /// Channel definitions.
286    pub content: Vec<ChannelConfig>,
287}
288
289impl Default for ChannelsConfig {
290    fn default() -> Self {
291        Self {
292            default: "stable".into(),
293            branch: "main".into(),
294            content: vec![ChannelConfig {
295                name: "stable".into(),
296                prerelease: None,
297                draft: false,
298            }],
299        }
300    }
301}
302
303/// A named release channel.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct ChannelConfig {
306    /// Channel name (e.g. "stable", "rc", "canary").
307    pub name: String,
308    /// Pre-release identifier (e.g. "rc", "canary"). None = stable release.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub prerelease: Option<String>,
311    /// Create GitHub release as a draft.
312    #[serde(default)]
313    pub draft: bool,
314}
315
316// ---------------------------------------------------------------------------
317// VCS config
318// ---------------------------------------------------------------------------
319
320/// VCS provider-specific configuration.
321#[derive(Debug, Clone, Serialize, Deserialize, Default)]
322#[serde(default)]
323pub struct VcsConfig {
324    pub github: GitHubConfig,
325}
326
327/// GitHub-specific release settings.
328#[derive(Debug, Clone, Serialize, Deserialize, Default)]
329#[serde(default)]
330pub struct GitHubConfig {
331    /// Minijinja template for the GitHub release name.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub release_name_template: Option<String>,
334}
335
336// ---------------------------------------------------------------------------
337// Package config
338// ---------------------------------------------------------------------------
339
340/// A releasable package — version files, artifacts, build/publish hooks.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(default)]
343pub struct PackageConfig {
344    /// Directory path relative to repo root.
345    pub path: String,
346    /// Whether this package versions independently in a monorepo.
347    pub independent: bool,
348    /// Tag prefix override (default: derived from git.tag_prefix or "{dir}/v").
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub tag_prefix: Option<String>,
351    /// Manifest files to bump.
352    pub version_files: Vec<String>,
353    /// Fail on unsupported version file formats.
354    pub version_files_strict: bool,
355    /// Additional files to stage in the release commit.
356    pub stage_files: Vec<String>,
357    /// Glob patterns for artifact files to upload to the release.
358    pub artifacts: Vec<String>,
359    /// Changelog config override for this package.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub changelog: Option<ChangelogConfig>,
362    /// Package-level lifecycle hooks.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub hooks: Option<HooksConfig>,
365}
366
367impl Default for PackageConfig {
368    fn default() -> Self {
369        Self {
370            path: ".".into(),
371            independent: false,
372            tag_prefix: None,
373            version_files: vec![],
374            version_files_strict: false,
375            stage_files: vec![],
376            artifacts: vec![],
377            changelog: None,
378            hooks: None,
379        }
380    }
381}
382
383/// Package lifecycle hooks — shell commands at release events.
384#[derive(Debug, Clone, Serialize, Deserialize, Default)]
385#[serde(default)]
386pub struct HooksConfig {
387    /// Runs before any mutation — tests, lints, validations that may abort the release.
388    pub pre_release: Vec<String>,
389    /// Runs after version files are bumped on disk, before the release is
390    /// committed or tagged. Commands here read the new version from the
391    /// manifest and produce the declared `artifacts`. A failure leaves the
392    /// workspace dirty but no commit/tag/push — `git checkout .` heals it.
393    ///
394    /// When set, sr enforces that every declared artifact glob resolves to
395    /// ≥1 file before the tag is created.
396    pub build: Vec<String>,
397    /// Runs after tag + GitHub release (publish to registries).
398    pub post_release: Vec<String>,
399}
400
401// ---------------------------------------------------------------------------
402// Config methods
403// ---------------------------------------------------------------------------
404
405impl Config {
406    /// Find the first config file that exists in the given directory.
407    pub fn find_config(dir: &Path) -> Option<(std::path::PathBuf, bool)> {
408        for &candidate in CONFIG_CANDIDATES {
409            let path = dir.join(candidate);
410            if path.exists() {
411                let is_legacy = candidate == LEGACY_CONFIG_FILE;
412                return Some((path, is_legacy));
413            }
414        }
415        None
416    }
417
418    /// Load config from a YAML file. Falls back to defaults if the file doesn't exist.
419    pub fn load(path: &Path) -> Result<Self, ReleaseError> {
420        if !path.exists() {
421            return Ok(Self::default());
422        }
423        let contents =
424            std::fs::read_to_string(path).map_err(|e| ReleaseError::Config(e.to_string()))?;
425        let config: Self =
426            serde_yaml_ng::from_str(&contents).map_err(|e| ReleaseError::Config(e.to_string()))?;
427        config.validate()?;
428        Ok(config)
429    }
430
431    /// Validate config consistency.
432    fn validate(&self) -> Result<(), ReleaseError> {
433        // Check for duplicate type names across bump levels.
434        let mut seen = std::collections::HashSet::new();
435        for name in self.commit.types.all_type_names() {
436            if !seen.insert(name) {
437                return Err(ReleaseError::Config(format!(
438                    "duplicate commit type: {name}"
439                )));
440            }
441        }
442
443        // Need at least one type with a bump level.
444        if self.commit.types.minor.is_empty() && self.commit.types.patch.is_empty() {
445            return Err(ReleaseError::Config(
446                "commit.types must have at least one minor or patch type".into(),
447            ));
448        }
449
450        // Check for duplicate channel names.
451        let mut channel_names = std::collections::HashSet::new();
452        for ch in &self.channels.content {
453            if !channel_names.insert(&ch.name) {
454                return Err(ReleaseError::Config(format!(
455                    "duplicate channel name: {}",
456                    ch.name
457                )));
458            }
459        }
460
461        Ok(())
462    }
463
464    /// Resolve a named release channel, returning the channel config.
465    pub fn resolve_channel(&self, name: &str) -> Result<&ChannelConfig, ReleaseError> {
466        self.channels
467            .content
468            .iter()
469            .find(|ch| ch.name == name)
470            .ok_or_else(|| {
471                let available: Vec<&str> = self
472                    .channels
473                    .content
474                    .iter()
475                    .map(|c| c.name.as_str())
476                    .collect();
477                ReleaseError::Config(format!(
478                    "channel '{name}' not found. Available: {}",
479                    if available.is_empty() {
480                        "(none)".to_string()
481                    } else {
482                        available.join(", ")
483                    }
484                ))
485            })
486    }
487
488    /// Resolve the default channel.
489    pub fn default_channel(&self) -> Result<&ChannelConfig, ReleaseError> {
490        self.resolve_channel(&self.channels.default)
491    }
492
493    /// Find a package by path.
494    pub fn find_package(&self, path: &str) -> Result<&PackageConfig, ReleaseError> {
495        self.packages
496            .iter()
497            .find(|p| p.path == path)
498            .ok_or_else(|| {
499                let available: Vec<&str> = self.packages.iter().map(|p| p.path.as_str()).collect();
500                ReleaseError::Config(format!(
501                    "package '{path}' not found. Available: {}",
502                    if available.is_empty() {
503                        "(none)".to_string()
504                    } else {
505                        available.join(", ")
506                    }
507                ))
508            })
509    }
510
511    /// Find a package by name (last component of path).
512    pub fn find_package_by_name(&self, name: &str) -> Result<&PackageConfig, ReleaseError> {
513        self.packages
514            .iter()
515            .find(|p| p.path.rsplit('/').next().unwrap_or(&p.path) == name)
516            .ok_or_else(|| {
517                let available: Vec<&str> = self
518                    .packages
519                    .iter()
520                    .map(|p| p.path.rsplit('/').next().unwrap_or(&p.path))
521                    .collect();
522                ReleaseError::Config(format!(
523                    "package '{name}' not found. Available: {}",
524                    if available.is_empty() {
525                        "(none)".to_string()
526                    } else {
527                        available.join(", ")
528                    }
529                ))
530            })
531    }
532
533    /// Resolve effective tag prefix for a package.
534    pub fn tag_prefix_for(&self, pkg: &PackageConfig) -> String {
535        if let Some(ref prefix) = pkg.tag_prefix {
536            return prefix.clone();
537        }
538        if pkg.path == "." {
539            self.git.tag_prefix.clone()
540        } else {
541            let dir_name = pkg.path.rsplit('/').next().unwrap_or(&pkg.path);
542            format!("{}/v", dir_name)
543        }
544    }
545
546    /// Resolve effective changelog config for a package.
547    pub fn changelog_for<'a>(&'a self, pkg: &'a PackageConfig) -> &'a ChangelogConfig {
548        pkg.changelog.as_ref().unwrap_or(&self.changelog)
549    }
550
551    /// Resolve effective version files for a package, with auto-detection.
552    pub fn version_files_for(&self, pkg: &PackageConfig) -> Vec<String> {
553        if !pkg.version_files.is_empty() {
554            return pkg.version_files.clone();
555        }
556        let detected = detect_version_files(Path::new(&pkg.path));
557        if pkg.path == "." {
558            detected
559        } else {
560            detected
561                .into_iter()
562                .map(|f| format!("{}/{f}", pkg.path))
563                .collect()
564        }
565    }
566
567    /// Get all non-independent packages (for fixed versioning).
568    pub fn fixed_packages(&self) -> Vec<&PackageConfig> {
569        self.packages.iter().filter(|p| !p.independent).collect()
570    }
571
572    /// Get all independent packages.
573    pub fn independent_packages(&self) -> Vec<&PackageConfig> {
574        self.packages.iter().filter(|p| p.independent).collect()
575    }
576
577    /// Collect all artifacts glob patterns from all packages.
578    pub fn all_artifacts(&self) -> Vec<String> {
579        self.packages
580            .iter()
581            .flat_map(|p| p.artifacts.clone())
582            .collect()
583    }
584}
585
586// ---------------------------------------------------------------------------
587// Template generation
588// ---------------------------------------------------------------------------
589
590pub fn default_config_template(version_files: &[String]) -> String {
591    let vf = if version_files.is_empty() {
592        "    version_files: []\n".to_string()
593    } else {
594        let mut s = "    version_files:\n".to_string();
595        for f in version_files {
596            s.push_str(&format!("      - {f}\n"));
597        }
598        s
599    };
600
601    format!(
602        r#"# sr configuration
603# Full reference: https://github.com/urmzd/sr#configuration
604
605git:
606  tag_prefix: "v"
607  floating_tag: true
608  sign_tags: false
609  v0_protection: true
610  # user:
611  #   name: "sr-releaser[bot]"
612  #   email: "sr-releaser[bot]@users.noreply.github.com"
613  # Commits whose message contains any of these substrings are excluded from
614  # the release plan and changelog. chore(release): is always filtered.
615  skip_patterns:
616    - "[skip release]"
617    - "[skip sr]"
618
619commit:
620  types:
621    minor:
622      - feat
623    patch:
624      - fix
625      - perf
626      - refactor
627    none:
628      - docs
629      - revert
630      - chore
631      - ci
632      - test
633      - build
634      - style
635
636changelog:
637  file: CHANGELOG.md
638  # template: changelog.md.j2
639  groups:
640    - name: breaking
641      content:
642        - breaking
643    - name: features
644      content:
645        - feat
646    - name: bug-fixes
647      content:
648        - fix
649    - name: performance
650      content:
651        - perf
652    - name: misc
653      content:
654        - chore
655        - ci
656        - test
657        - build
658        - style
659
660channels:
661  default: stable
662  branch: main
663  content:
664    - name: stable
665  # - name: rc
666  #   prerelease: rc
667  #   draft: true
668  # - name: canary
669  #   branch: develop
670  #   prerelease: canary
671
672# vcs:
673#   github:
674#     release_name_template: "{{{{ tag_name }}}}"
675
676packages:
677  - path: .
678{vf}    # version_files_strict: false
679    # stage_files: []
680    # artifacts: []
681    # hooks:
682    #   # Runs before any mutation: tests, lints. May abort the release.
683    #   pre_release:
684    #     - cargo test
685    #   # Runs after version bump, before tag/commit. Produces the declared
686    #   # `artifacts` with the new version embedded. sr verifies every
687    #   # artifact glob resolves to >=1 file before tagging.
688    #   build:
689    #     - cargo build --release
690    #   # Runs after tag + GitHub release. Must be idempotent.
691    #   post_release:
692    #     - cargo publish
693"#
694    )
695}
696
697// ---------------------------------------------------------------------------
698// Tests
699// ---------------------------------------------------------------------------
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    #[test]
706    fn default_values() {
707        let config = Config::default();
708        assert_eq!(config.git.tag_prefix, "v");
709        assert!(config.git.floating_tag);
710        assert!(!config.git.sign_tags);
711        assert_eq!(config.commit.types.minor, vec!["feat"]);
712        assert!(config.commit.types.patch.contains(&"fix".to_string()));
713        assert!(config.commit.types.none.contains(&"chore".to_string()));
714        assert_eq!(config.changelog.file.as_deref(), Some("CHANGELOG.md"));
715        assert!(!config.changelog.groups.is_empty());
716        assert_eq!(config.channels.default, "stable");
717        assert_eq!(config.channels.content.len(), 1);
718        assert_eq!(config.channels.content[0].name, "stable");
719        assert_eq!(config.channels.branch, "main");
720        assert_eq!(config.packages.len(), 1);
721        assert_eq!(config.packages[0].path, ".");
722    }
723
724    #[test]
725    fn load_missing_file() {
726        let dir = tempfile::tempdir().unwrap();
727        let path = dir.path().join("nonexistent.yml");
728        let config = Config::load(&path).unwrap();
729        assert_eq!(config.git.tag_prefix, "v");
730    }
731
732    #[test]
733    fn load_partial_yaml() {
734        let dir = tempfile::tempdir().unwrap();
735        let path = dir.path().join("config.yml");
736        std::fs::write(&path, "git:\n  tag_prefix: rel-\n").unwrap();
737
738        let config = Config::load(&path).unwrap();
739        assert_eq!(config.git.tag_prefix, "rel-");
740        assert_eq!(config.channels.default, "stable");
741    }
742
743    #[test]
744    fn load_yaml_with_packages() {
745        let dir = tempfile::tempdir().unwrap();
746        let path = dir.path().join("config.yml");
747        std::fs::write(
748            &path,
749            "packages:\n  - path: crates/core\n    version_files:\n      - crates/core/Cargo.toml\n",
750        )
751        .unwrap();
752
753        let config = Config::load(&path).unwrap();
754        assert_eq!(config.packages.len(), 1);
755        assert_eq!(config.packages[0].path, "crates/core");
756    }
757
758    #[test]
759    fn commit_types_conversion() {
760        let types = CommitTypesConfig::default();
761        let commit_types = types.into_commit_types();
762        let feat = commit_types.iter().find(|t| t.name == "feat").unwrap();
763        assert_eq!(feat.bump, Some(BumpLevel::Minor));
764        let fix = commit_types.iter().find(|t| t.name == "fix").unwrap();
765        assert_eq!(fix.bump, Some(BumpLevel::Patch));
766        let chore = commit_types.iter().find(|t| t.name == "chore").unwrap();
767        assert_eq!(chore.bump, None);
768    }
769
770    #[test]
771    fn all_type_names() {
772        let types = CommitTypesConfig::default();
773        let names = types.all_type_names();
774        assert!(names.contains(&"feat"));
775        assert!(names.contains(&"fix"));
776        assert!(names.contains(&"chore"));
777    }
778
779    #[test]
780    fn resolve_channel() {
781        let config = Config::default();
782        let channel = config.resolve_channel("stable").unwrap();
783        assert!(channel.prerelease.is_none());
784    }
785
786    #[test]
787    fn resolve_channel_not_found() {
788        let config = Config::default();
789        assert!(config.resolve_channel("missing").is_err());
790    }
791
792    #[test]
793    fn tag_prefix_root_package() {
794        let config = Config::default();
795        let pkg = &config.packages[0];
796        assert_eq!(config.tag_prefix_for(pkg), "v");
797    }
798
799    #[test]
800    fn tag_prefix_subpackage() {
801        let config = Config::default();
802        let pkg = PackageConfig {
803            path: "crates/core".into(),
804            ..Default::default()
805        };
806        assert_eq!(config.tag_prefix_for(&pkg), "core/v");
807    }
808
809    #[test]
810    fn tag_prefix_override() {
811        let config = Config::default();
812        let pkg = PackageConfig {
813            path: "crates/cli".into(),
814            tag_prefix: Some("cli-v".into()),
815            ..Default::default()
816        };
817        assert_eq!(config.tag_prefix_for(&pkg), "cli-v");
818    }
819
820    #[test]
821    fn validate_duplicate_types() {
822        let config = Config {
823            commit: CommitConfig {
824                types: CommitTypesConfig {
825                    minor: vec!["feat".into()],
826                    patch: vec!["feat".into()],
827                    none: vec![],
828                },
829            },
830            ..Default::default()
831        };
832        assert!(config.validate().is_err());
833    }
834
835    #[test]
836    fn validate_no_bump_types() {
837        let config = Config {
838            commit: CommitConfig {
839                types: CommitTypesConfig {
840                    minor: vec![],
841                    patch: vec![],
842                    none: vec!["chore".into()],
843                },
844            },
845            ..Default::default()
846        };
847        assert!(config.validate().is_err());
848    }
849
850    #[test]
851    fn validate_duplicate_channels() {
852        let config = Config {
853            channels: ChannelsConfig {
854                default: "stable".into(),
855                branch: "main".into(),
856                content: vec![
857                    ChannelConfig {
858                        name: "stable".into(),
859                        prerelease: None,
860                        draft: false,
861                    },
862                    ChannelConfig {
863                        name: "stable".into(),
864                        prerelease: None,
865                        draft: false,
866                    },
867                ],
868            },
869            ..Default::default()
870        };
871        assert!(config.validate().is_err());
872    }
873
874    #[test]
875    fn default_template_parses() {
876        let template = default_config_template(&[]);
877        let config: Config = serde_yaml_ng::from_str(&template).unwrap();
878        assert_eq!(config.git.tag_prefix, "v");
879        assert!(config.git.floating_tag);
880        assert_eq!(config.channels.default, "stable");
881        assert!(
882            config
883                .git
884                .skip_patterns
885                .iter()
886                .any(|p| p == "[skip release]")
887        );
888    }
889
890    #[test]
891    fn default_skip_patterns_present() {
892        let config = Config::default();
893        assert_eq!(
894            config.git.skip_patterns,
895            vec!["[skip release]".to_string(), "[skip sr]".to_string()]
896        );
897    }
898
899    #[test]
900    fn git_user_defaults_to_none() {
901        let config = Config::default();
902        assert!(config.git.user.name.is_none());
903        assert!(config.git.user.email.is_none());
904    }
905
906    #[test]
907    fn git_user_loads_from_yaml() {
908        let dir = tempfile::tempdir().unwrap();
909        let path = dir.path().join("config.yml");
910        std::fs::write(
911            &path,
912            "git:\n  user:\n    name: \"Bot\"\n    email: \"bot@example.com\"\n",
913        )
914        .unwrap();
915        let config = Config::load(&path).unwrap();
916        assert_eq!(config.git.user.name.as_deref(), Some("Bot"));
917        assert_eq!(config.git.user.email.as_deref(), Some("bot@example.com"));
918    }
919
920    #[test]
921    fn default_template_with_version_files() {
922        let template = default_config_template(&["Cargo.toml".into(), "package.json".into()]);
923        let config: Config = serde_yaml_ng::from_str(&template).unwrap();
924        assert_eq!(
925            config.packages[0].version_files,
926            vec!["Cargo.toml", "package.json"]
927        );
928    }
929
930    #[test]
931    fn find_package_by_name_works() {
932        let config = Config {
933            packages: vec![
934                PackageConfig {
935                    path: "crates/core".into(),
936                    ..Default::default()
937                },
938                PackageConfig {
939                    path: "crates/cli".into(),
940                    ..Default::default()
941                },
942            ],
943            ..Default::default()
944        };
945        let pkg = config.find_package_by_name("core").unwrap();
946        assert_eq!(pkg.path, "crates/core");
947    }
948
949    #[test]
950    fn collect_all_artifacts() {
951        let config = Config {
952            packages: vec![
953                PackageConfig {
954                    path: "crates/core".into(),
955                    artifacts: vec!["core-*".into()],
956                    ..Default::default()
957                },
958                PackageConfig {
959                    path: "crates/cli".into(),
960                    artifacts: vec!["cli-*".into()],
961                    ..Default::default()
962                },
963            ],
964            ..Default::default()
965        };
966        let artifacts = config.all_artifacts();
967        assert_eq!(artifacts, vec!["core-*", "cli-*"]);
968    }
969}