Skip to main content

changeset_project/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use changeset_changelog::{ChangelogConfig, ChangelogLocation, ComparisonLinksSetting};
5use changeset_core::ZeroVersionBehavior;
6use globset::{Glob, GlobSet, GlobSetBuilder};
7
8use crate::error::ProjectError;
9use crate::manifest::{ChangesetMetadata, TagFormatValue, read_manifest};
10use crate::project::{CargoProject, ProjectKind};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum TagFormat {
14    #[default]
15    VersionOnly,
16    CratePrefixed,
17}
18
19#[derive(Debug, Clone)]
20#[allow(clippy::struct_excessive_bools)]
21pub struct GitConfig {
22    commit: bool,
23    tags: bool,
24    keep_changesets: bool,
25    tag_format: TagFormat,
26    commit_title_template: String,
27    changes_in_body: bool,
28}
29
30impl Default for GitConfig {
31    fn default() -> Self {
32        Self {
33            commit: true,
34            tags: true,
35            keep_changesets: false,
36            tag_format: TagFormat::default(),
37            commit_title_template: String::from("{new-version}"),
38            changes_in_body: true,
39        }
40    }
41}
42
43impl GitConfig {
44    #[must_use]
45    pub fn commit(&self) -> bool {
46        self.commit
47    }
48
49    #[must_use]
50    pub fn tags(&self) -> bool {
51        self.tags
52    }
53
54    #[must_use]
55    pub fn keep_changesets(&self) -> bool {
56        self.keep_changesets
57    }
58
59    #[must_use]
60    pub fn tag_format(&self) -> TagFormat {
61        self.tag_format
62    }
63
64    #[must_use]
65    pub fn commit_title_template(&self) -> &str {
66        &self.commit_title_template
67    }
68
69    #[must_use]
70    pub fn changes_in_body(&self) -> bool {
71        self.changes_in_body
72    }
73
74    #[cfg(any(test, feature = "testing"))]
75    #[must_use]
76    pub fn with_changes_in_body(mut self, changes_in_body: bool) -> Self {
77        self.changes_in_body = changes_in_body;
78        self
79    }
80}
81
82#[derive(Debug, Clone)]
83pub struct RootChangesetConfig {
84    ignored_files: GlobSet,
85    changeset_dir: PathBuf,
86    changelog_config: ChangelogConfig,
87    git_config: GitConfig,
88    zero_version_behavior: ZeroVersionBehavior,
89}
90
91impl Default for RootChangesetConfig {
92    fn default() -> Self {
93        Self {
94            ignored_files: GlobSet::empty(),
95            changeset_dir: PathBuf::from(crate::DEFAULT_CHANGESET_DIR),
96            changelog_config: ChangelogConfig::default(),
97            git_config: GitConfig::default(),
98            zero_version_behavior: ZeroVersionBehavior::default(),
99        }
100    }
101}
102
103impl RootChangesetConfig {
104    #[must_use]
105    pub fn ignored_files(&self) -> &GlobSet {
106        &self.ignored_files
107    }
108
109    #[must_use]
110    pub fn is_ignored(&self, path: &Path) -> bool {
111        self.ignored_files.is_match(path)
112    }
113
114    #[must_use]
115    pub fn changeset_dir(&self) -> &Path {
116        &self.changeset_dir
117    }
118
119    #[must_use]
120    pub fn changelog_config(&self) -> &ChangelogConfig {
121        &self.changelog_config
122    }
123
124    #[must_use]
125    pub fn git_config(&self) -> &GitConfig {
126        &self.git_config
127    }
128
129    #[must_use]
130    pub fn zero_version_behavior(&self) -> ZeroVersionBehavior {
131        self.zero_version_behavior
132    }
133
134    #[cfg(any(test, feature = "testing"))]
135    #[must_use]
136    pub fn with_git_config(mut self, git_config: GitConfig) -> Self {
137        self.git_config = git_config;
138        self
139    }
140}
141
142#[derive(Debug, Default)]
143pub struct PackageChangesetConfig {
144    ignored_files: GlobSet,
145}
146
147impl PackageChangesetConfig {
148    #[must_use]
149    pub fn ignored_files(&self) -> &GlobSet {
150        &self.ignored_files
151    }
152
153    #[must_use]
154    pub fn is_ignored(&self, path: &Path) -> bool {
155        self.ignored_files.is_match(path)
156    }
157}
158
159fn build_glob_set(patterns: &[String]) -> Result<GlobSet, ProjectError> {
160    let mut builder = GlobSetBuilder::new();
161    for pattern in patterns {
162        let glob = Glob::new(pattern).map_err(|source| ProjectError::GlobPattern {
163            pattern: pattern.clone(),
164            source,
165        })?;
166        builder.add(glob);
167    }
168    builder.build().map_err(|source| ProjectError::GlobPattern {
169        pattern: patterns.join(", "),
170        source,
171    })
172}
173
174fn build_changelog_config(
175    changelog: Option<ChangelogLocation>,
176    comparison_links: Option<ComparisonLinksSetting>,
177    comparison_links_template: Option<String>,
178) -> ChangelogConfig {
179    ChangelogConfig::new(
180        changelog.unwrap_or_default(),
181        comparison_links.unwrap_or_default(),
182        comparison_links_template,
183    )
184}
185
186fn build_git_config(metadata: Option<&ChangesetMetadata>) -> GitConfig {
187    let defaults = GitConfig::default();
188    match metadata {
189        None => defaults,
190        Some(cs) => GitConfig {
191            commit: cs.commit.unwrap_or(defaults.commit),
192            tags: cs.tags.unwrap_or(defaults.tags),
193            keep_changesets: cs.keep_changesets.unwrap_or(defaults.keep_changesets),
194            tag_format: cs.tag_format.map_or(defaults.tag_format, |tf| match tf {
195                TagFormatValue::VersionOnly => TagFormat::VersionOnly,
196                TagFormatValue::CratePrefixed => TagFormat::CratePrefixed,
197            }),
198            commit_title_template: cs
199                .commit_title_template
200                .clone()
201                .unwrap_or(defaults.commit_title_template),
202            changes_in_body: cs.changes_in_body.unwrap_or(defaults.changes_in_body),
203        },
204    }
205}
206
207/// Parses root configuration from workspace metadata.
208///
209/// # Errors
210///
211/// Returns an error if the manifest cannot be read or parsed, or if glob patterns are invalid.
212fn parse_workspace_root_config(project_root: &Path) -> Result<RootChangesetConfig, ProjectError> {
213    let manifest_path = project_root.join("Cargo.toml");
214    let manifest = read_manifest(&manifest_path)?;
215
216    let changeset_metadata = manifest
217        .workspace
218        .and_then(|ws| ws.metadata)
219        .and_then(|meta| meta.changeset);
220
221    let patterns = changeset_metadata
222        .as_ref()
223        .map(|cs| cs.ignored_files.clone())
224        .unwrap_or_default();
225
226    let changeset_dir = changeset_metadata
227        .as_ref()
228        .and_then(|cs| cs.changeset_dir.clone())
229        .unwrap_or_else(|| crate::DEFAULT_CHANGESET_DIR.to_string());
230
231    let ignored_files = build_glob_set(&patterns)?;
232
233    let changelog_config = build_changelog_config(
234        changeset_metadata.as_ref().and_then(|cs| cs.changelog),
235        changeset_metadata
236            .as_ref()
237            .and_then(|cs| cs.comparison_links),
238        changeset_metadata
239            .as_ref()
240            .and_then(|cs| cs.comparison_links_template.clone()),
241    );
242
243    let git_config = build_git_config(changeset_metadata.as_ref());
244
245    let zero_version_behavior = changeset_metadata
246        .as_ref()
247        .and_then(|cs| cs.zero_version_behavior)
248        .unwrap_or_default();
249
250    Ok(RootChangesetConfig {
251        ignored_files,
252        changeset_dir: PathBuf::from(changeset_dir),
253        changelog_config,
254        git_config,
255        zero_version_behavior,
256    })
257}
258
259/// Parses root configuration from package metadata (for single-package projects).
260///
261/// # Errors
262///
263/// Returns an error if the manifest cannot be read or parsed, or if glob patterns are invalid.
264fn parse_package_root_config(project_root: &Path) -> Result<RootChangesetConfig, ProjectError> {
265    let manifest_path = project_root.join("Cargo.toml");
266    let manifest = read_manifest(&manifest_path)?;
267
268    let changeset_metadata = manifest
269        .package
270        .and_then(|pkg| pkg.metadata)
271        .and_then(|meta| meta.changeset);
272
273    let patterns = changeset_metadata
274        .as_ref()
275        .map(|cs| cs.ignored_files.clone())
276        .unwrap_or_default();
277
278    let changeset_dir = changeset_metadata
279        .as_ref()
280        .and_then(|cs| cs.changeset_dir.clone())
281        .unwrap_or_else(|| crate::DEFAULT_CHANGESET_DIR.to_string());
282
283    let ignored_files = build_glob_set(&patterns)?;
284
285    let changelog_config = build_changelog_config(
286        changeset_metadata.as_ref().and_then(|cs| cs.changelog),
287        changeset_metadata
288            .as_ref()
289            .and_then(|cs| cs.comparison_links),
290        changeset_metadata
291            .as_ref()
292            .and_then(|cs| cs.comparison_links_template.clone()),
293    );
294
295    let git_config = build_git_config(changeset_metadata.as_ref());
296
297    let zero_version_behavior = changeset_metadata
298        .as_ref()
299        .and_then(|cs| cs.zero_version_behavior)
300        .unwrap_or_default();
301
302    Ok(RootChangesetConfig {
303        ignored_files,
304        changeset_dir: PathBuf::from(changeset_dir),
305        changelog_config,
306        git_config,
307        zero_version_behavior,
308    })
309}
310
311/// Parses the root changeset configuration based on project kind.
312///
313/// For single-package projects, reads from `[package.metadata.changeset]`.
314/// For workspaces, reads from `[workspace.metadata.changeset]`.
315///
316/// # Errors
317///
318/// Returns an error if the manifest cannot be read or parsed, or if glob patterns are invalid.
319pub fn parse_root_config(project: &CargoProject) -> Result<RootChangesetConfig, ProjectError> {
320    match project.kind() {
321        ProjectKind::SinglePackage => parse_package_root_config(project.root()),
322        ProjectKind::VirtualWorkspace | ProjectKind::WorkspaceWithRoot => {
323            parse_workspace_root_config(project.root())
324        }
325    }
326}
327
328/// # Errors
329///
330/// Returns an error if the manifest cannot be read or parsed, or if glob patterns are invalid.
331pub fn parse_package_config(package_path: &Path) -> Result<PackageChangesetConfig, ProjectError> {
332    let manifest_path = package_path.join("Cargo.toml");
333    let manifest = read_manifest(&manifest_path)?;
334
335    let patterns = manifest
336        .package
337        .and_then(|pkg| pkg.metadata)
338        .and_then(|meta| meta.changeset)
339        .map(|cs| cs.ignored_files)
340        .unwrap_or_default();
341
342    let ignored_files = build_glob_set(&patterns)?;
343
344    Ok(PackageChangesetConfig { ignored_files })
345}
346
347/// # Errors
348///
349/// Returns an error if any manifest cannot be read or parsed, or if glob patterns are invalid.
350pub fn load_changeset_configs(
351    project: &CargoProject,
352) -> Result<(RootChangesetConfig, HashMap<String, PackageChangesetConfig>), ProjectError> {
353    let root_config = parse_root_config(project)?;
354
355    let mut package_configs = HashMap::new();
356    for package in project.packages() {
357        let config = parse_package_config(&package.path)?;
358        package_configs.insert(package.name.clone(), config);
359    }
360
361    Ok((root_config, package_configs))
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use std::fs;
368    use tempfile::TempDir;
369
370    fn setup_with_config(toml_content: &str) -> anyhow::Result<TempDir> {
371        let dir = TempDir::new()?;
372        fs::write(dir.path().join("Cargo.toml"), toml_content)?;
373        Ok(dir)
374    }
375
376    #[test]
377    fn parse_workspace_root_config_with_ignored_files() -> anyhow::Result<()> {
378        let toml = r#"
379[workspace]
380members = ["crates/*"]
381
382[workspace.metadata.changeset]
383ignored-files = ["*.md", "docs/**"]
384"#;
385        let dir = setup_with_config(toml)?;
386
387        let config = parse_workspace_root_config(dir.path())?;
388
389        assert!(config.is_ignored(Path::new("README.md")));
390        assert!(config.is_ignored(Path::new("docs/guide.md")));
391        assert!(!config.is_ignored(Path::new("src/lib.rs")));
392
393        Ok(())
394    }
395
396    #[test]
397    fn parse_workspace_root_config_without_metadata() -> anyhow::Result<()> {
398        let toml = r#"
399[workspace]
400members = ["crates/*"]
401"#;
402        let dir = setup_with_config(toml)?;
403
404        let config = parse_workspace_root_config(dir.path())?;
405
406        assert!(!config.is_ignored(Path::new("README.md")));
407        assert!(!config.is_ignored(Path::new("src/lib.rs")));
408
409        Ok(())
410    }
411
412    #[test]
413    fn parse_workspace_root_config_with_custom_changeset_dir() -> anyhow::Result<()> {
414        let toml = r#"
415[workspace]
416members = ["crates/*"]
417
418[workspace.metadata.changeset]
419changeset-dir = "changes"
420"#;
421        let dir = setup_with_config(toml)?;
422
423        let config = parse_workspace_root_config(dir.path())?;
424
425        assert_eq!(config.changeset_dir(), Path::new("changes"));
426
427        Ok(())
428    }
429
430    #[test]
431    fn parse_workspace_root_config_default_changeset_dir() -> anyhow::Result<()> {
432        let toml = r#"
433[workspace]
434members = ["crates/*"]
435"#;
436        let dir = setup_with_config(toml)?;
437
438        let config = parse_workspace_root_config(dir.path())?;
439
440        assert_eq!(config.changeset_dir(), Path::new(".changeset"));
441
442        Ok(())
443    }
444
445    #[test]
446    fn parse_package_config_with_ignored_files() -> anyhow::Result<()> {
447        let toml = r#"
448[package]
449name = "my-crate"
450version = "0.1.0"
451
452[package.metadata.changeset]
453ignored-files = ["benches/**", "examples/**"]
454"#;
455        let dir = setup_with_config(toml)?;
456
457        let config = parse_package_config(dir.path())?;
458
459        assert!(config.is_ignored(Path::new("benches/bench.rs")));
460        assert!(config.is_ignored(Path::new("examples/demo.rs")));
461        assert!(!config.is_ignored(Path::new("src/lib.rs")));
462
463        Ok(())
464    }
465
466    #[test]
467    fn parse_package_config_without_metadata() -> anyhow::Result<()> {
468        let toml = r#"
469[package]
470name = "my-crate"
471version = "0.1.0"
472"#;
473        let dir = setup_with_config(toml)?;
474
475        let config = parse_package_config(dir.path())?;
476
477        assert!(!config.is_ignored(Path::new("benches/bench.rs")));
478
479        Ok(())
480    }
481
482    #[test]
483    fn parse_single_package_root_config() -> anyhow::Result<()> {
484        let toml = r#"
485[package]
486name = "my-crate"
487version = "0.1.0"
488
489[package.metadata.changeset]
490ignored-files = ["*.md"]
491changeset-dir = "my-changesets"
492"#;
493        let dir = setup_with_config(toml)?;
494
495        let config = parse_package_root_config(dir.path())?;
496
497        assert!(config.is_ignored(Path::new("README.md")));
498        assert_eq!(config.changeset_dir(), Path::new("my-changesets"));
499
500        Ok(())
501    }
502
503    #[test]
504    fn invalid_glob_pattern_returns_error() -> anyhow::Result<()> {
505        let toml = r#"
506[workspace]
507members = ["crates/*"]
508
509[workspace.metadata.changeset]
510ignored-files = ["[invalid"]
511"#;
512        let dir = setup_with_config(toml)?;
513
514        let result = parse_workspace_root_config(dir.path());
515
516        assert!(result.is_err());
517        let err = result.expect_err("should fail on invalid glob");
518        assert!(matches!(err, ProjectError::GlobPattern { .. }));
519
520        Ok(())
521    }
522
523    #[test]
524    fn empty_ignored_files_list() -> anyhow::Result<()> {
525        let toml = r#"
526[workspace]
527members = ["crates/*"]
528
529[workspace.metadata.changeset]
530ignored-files = []
531"#;
532        let dir = setup_with_config(toml)?;
533
534        let config = parse_workspace_root_config(dir.path())?;
535
536        assert!(!config.is_ignored(Path::new("anything.txt")));
537
538        Ok(())
539    }
540
541    #[test]
542    fn parse_workspace_changelog_config() -> anyhow::Result<()> {
543        let toml = r#"
544[workspace]
545members = ["crates/*"]
546
547[workspace.metadata.changeset]
548changelog = "per-package"
549comparison-links = "enabled"
550comparison-links-template = "https://example.com/{repository}/compare/{base}...{target}"
551"#;
552        let dir = setup_with_config(toml)?;
553
554        let config = parse_workspace_root_config(dir.path())?;
555        let changelog_config = config.changelog_config();
556
557        assert_eq!(changelog_config.changelog(), ChangelogLocation::PerPackage);
558        assert_eq!(
559            changelog_config.comparison_links(),
560            ComparisonLinksSetting::Enabled
561        );
562        assert_eq!(
563            changelog_config.comparison_links_template(),
564            Some("https://example.com/{repository}/compare/{base}...{target}")
565        );
566
567        Ok(())
568    }
569
570    #[test]
571    fn parse_changelog_config_defaults() -> anyhow::Result<()> {
572        let toml = r#"
573[workspace]
574members = ["crates/*"]
575"#;
576        let dir = setup_with_config(toml)?;
577
578        let config = parse_workspace_root_config(dir.path())?;
579        let changelog_config = config.changelog_config();
580
581        assert_eq!(changelog_config.changelog(), ChangelogLocation::Root);
582        assert_eq!(
583            changelog_config.comparison_links(),
584            ComparisonLinksSetting::Auto
585        );
586        assert!(changelog_config.comparison_links_template().is_none());
587
588        Ok(())
589    }
590
591    #[test]
592    fn parse_single_package_changelog_config() -> anyhow::Result<()> {
593        let toml = r#"
594[package]
595name = "my-crate"
596version = "0.1.0"
597
598[package.metadata.changeset]
599changelog = "root"
600comparison-links = "disabled"
601"#;
602        let dir = setup_with_config(toml)?;
603
604        let config = parse_package_root_config(dir.path())?;
605        let changelog_config = config.changelog_config();
606
607        assert_eq!(changelog_config.changelog(), ChangelogLocation::Root);
608        assert_eq!(
609            changelog_config.comparison_links(),
610            ComparisonLinksSetting::Disabled
611        );
612
613        Ok(())
614    }
615
616    #[test]
617    fn parse_git_config_defaults() -> anyhow::Result<()> {
618        let toml = r#"
619[workspace]
620members = ["crates/*"]
621"#;
622        let dir = setup_with_config(toml)?;
623
624        let config = parse_workspace_root_config(dir.path())?;
625        let git_config = config.git_config();
626
627        assert!(git_config.commit());
628        assert!(git_config.tags());
629        assert!(!git_config.keep_changesets());
630        assert_eq!(git_config.tag_format(), TagFormat::VersionOnly);
631        assert_eq!(git_config.commit_title_template(), "{new-version}");
632        assert!(git_config.changes_in_body());
633
634        Ok(())
635    }
636
637    #[test]
638    fn parse_git_config_all_options() -> anyhow::Result<()> {
639        let toml = r#"
640[workspace]
641members = ["crates/*"]
642
643[workspace.metadata.changeset]
644commit = false
645tags = false
646keep-changesets = true
647tag-format = "crate-prefixed"
648commit-title-template = "chore(release): {new-version}"
649changes-in-body = false
650"#;
651        let dir = setup_with_config(toml)?;
652
653        let config = parse_workspace_root_config(dir.path())?;
654        let git_config = config.git_config();
655
656        assert!(!git_config.commit());
657        assert!(!git_config.tags());
658        assert!(git_config.keep_changesets());
659        assert_eq!(git_config.tag_format(), TagFormat::CratePrefixed);
660        assert_eq!(
661            git_config.commit_title_template(),
662            "chore(release): {new-version}"
663        );
664        assert!(!git_config.changes_in_body());
665
666        Ok(())
667    }
668
669    #[test]
670    fn parse_git_config_version_only_format() -> anyhow::Result<()> {
671        let toml = r#"
672[workspace]
673members = ["crates/*"]
674
675[workspace.metadata.changeset]
676tag-format = "version-only"
677"#;
678        let dir = setup_with_config(toml)?;
679
680        let config = parse_workspace_root_config(dir.path())?;
681        let git_config = config.git_config();
682
683        assert_eq!(git_config.tag_format(), TagFormat::VersionOnly);
684
685        Ok(())
686    }
687
688    #[test]
689    fn parse_single_package_git_config() -> anyhow::Result<()> {
690        let toml = r#"
691[package]
692name = "my-crate"
693version = "0.1.0"
694
695[package.metadata.changeset]
696commit = false
697tags = true
698keep-changesets = true
699"#;
700        let dir = setup_with_config(toml)?;
701
702        let config = parse_package_root_config(dir.path())?;
703        let git_config = config.git_config();
704
705        assert!(!git_config.commit());
706        assert!(git_config.tags());
707        assert!(git_config.keep_changesets());
708
709        Ok(())
710    }
711
712    #[test]
713    fn parse_zero_version_behavior_default() -> anyhow::Result<()> {
714        let toml = r#"
715[workspace]
716members = ["crates/*"]
717"#;
718        let dir = setup_with_config(toml)?;
719
720        let config = parse_workspace_root_config(dir.path())?;
721
722        assert_eq!(
723            config.zero_version_behavior(),
724            ZeroVersionBehavior::EffectiveMinor
725        );
726
727        Ok(())
728    }
729
730    #[test]
731    fn parse_zero_version_behavior_effective_minor() -> anyhow::Result<()> {
732        let toml = r#"
733[workspace]
734members = ["crates/*"]
735
736[workspace.metadata.changeset]
737zero-version-behavior = "effective-minor"
738"#;
739        let dir = setup_with_config(toml)?;
740
741        let config = parse_workspace_root_config(dir.path())?;
742
743        assert_eq!(
744            config.zero_version_behavior(),
745            ZeroVersionBehavior::EffectiveMinor
746        );
747
748        Ok(())
749    }
750
751    #[test]
752    fn parse_zero_version_behavior_auto_promote() -> anyhow::Result<()> {
753        let toml = r#"
754[workspace]
755members = ["crates/*"]
756
757[workspace.metadata.changeset]
758zero-version-behavior = "auto-promote-on-major"
759"#;
760        let dir = setup_with_config(toml)?;
761
762        let config = parse_workspace_root_config(dir.path())?;
763
764        assert_eq!(
765            config.zero_version_behavior(),
766            ZeroVersionBehavior::AutoPromoteOnMajor
767        );
768
769        Ok(())
770    }
771
772    #[test]
773    fn parse_single_package_zero_version_behavior() -> anyhow::Result<()> {
774        let toml = r#"
775[package]
776name = "my-crate"
777version = "0.1.0"
778
779[package.metadata.changeset]
780zero-version-behavior = "auto-promote-on-major"
781"#;
782        let dir = setup_with_config(toml)?;
783
784        let config = parse_package_root_config(dir.path())?;
785
786        assert_eq!(
787            config.zero_version_behavior(),
788            ZeroVersionBehavior::AutoPromoteOnMajor
789        );
790
791        Ok(())
792    }
793}