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