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 {
180 changelog: changelog.unwrap_or_default(),
181 comparison_links: 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
207fn 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
259fn 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
311pub 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
328pub 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
347pub 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.as_deref(),
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}