#![forbid(clippy::indexing_slicing)]
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use glob::Pattern;
use miette::LabeledSpan;
use miette::SourceSpan;
use monochange_core::BumpSeverity;
use monochange_core::ChangeSignal;
use monochange_core::ChangelogDefinition;
use monochange_core::ChangelogFormat;
use monochange_core::ChangelogSettings;
use monochange_core::ChangelogTarget;
use monochange_core::ChangesetSettings;
use monochange_core::ChangesetTargetKind;
use monochange_core::CliCommandDefinition;
use monochange_core::CliInputDefinition;
use monochange_core::CliInputKind;
use monochange_core::CliStepDefinition;
use monochange_core::CliStepInputValue;
use monochange_core::Ecosystem;
use monochange_core::EcosystemSettings;
use monochange_core::EcosystemType;
use monochange_core::GroupChangelogInclude;
use monochange_core::GroupDefinition;
use monochange_core::LockfileCommandDefinition;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::PackageDefinition;
use monochange_core::PackageRecord;
use monochange_core::PackageType;
use monochange_core::ProviderMergeRequestSettings;
use monochange_core::ProviderReleaseNotesSource;
use monochange_core::ProviderReleaseSettings;
use monochange_core::PublishAttestationSettings;
use monochange_core::PublishMode;
use monochange_core::PublishOrderSettings;
use monochange_core::PublishRegistry;
use monochange_core::PublishSettings;
use monochange_core::RegistryKind;
use monochange_core::SourceCapabilities;
use monochange_core::SourceConfiguration;
use monochange_core::SourceProvider;
use monochange_core::TrustedPublishingSettings;
use monochange_core::VersionFormat;
use monochange_core::VersionGroup;
use monochange_core::VersionedFileDefinition;
use monochange_core::WorkspaceConfiguration;
use monochange_core::WorkspaceDefaults;
use monochange_core::default_cli_commands;
use monochange_core::lint::ChangesetLintSettings;
use monochange_core::lint::ChangesetScopedLintSettings;
use monochange_core::lint::ChangesetSummaryLintSettings;
use monochange_core::lint::LintRuleConfig;
use monochange_core::lint::WorkspaceLintSettings;
use monochange_core::relative_to_root;
use regex::Regex;
use semver::Version;
use serde::Deserialize;
use serde_yaml_ng::Mapping;
const CONFIG_FILE: &str = "monochange.toml";
pub const RESERVED_CLI_COMMAND_NAMES: &[&str] = &[
"agent",
"agents",
"analyze",
"check",
"command",
"fix",
"help",
"init",
"lint",
"lsp",
"mcp",
"skill",
"skills",
"subagents",
"validate",
"version",
];
const SUPPORTED_CHANGE_TEMPLATE_VARIABLES: &[&str] = &[
"summary",
"details",
"package",
"version",
"target_id",
"bump",
"type",
"context",
"changeset_path",
"change_owner",
"change_owner_link",
"review_request",
"review_request_link",
"introduced_commit",
"introduced_commit_link",
"last_updated_commit",
"last_updated_commit_link",
"related_issues",
"related_issue_links",
"closed_issues",
"closed_issue_links",
];
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", schemars(rename = "workspaceConfiguration"))]
pub(crate) struct RawWorkspaceConfiguration {
#[serde(default)]
defaults: RawWorkspaceDefaults,
#[serde(default)]
changelog: RawChangelogSettings,
#[serde(default)]
package: BTreeMap<String, RawPackageDefinition>,
#[serde(default)]
group: BTreeMap<String, RawGroupDefinition>,
#[serde(default)]
cli: BTreeMap<String, RawCliCommandDefinition>,
#[serde(default)]
changesets: ChangesetSettings,
#[serde(default)]
source: Option<RawSourceConfiguration>,
#[serde(default)]
#[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
lints: WorkspaceLintSettings,
#[serde(default)]
ecosystems: RawEcosystems,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "schema", schemars(rename = "defaults"))]
pub(crate) struct RawWorkspaceDefaults {
#[serde(default = "default_parent_bump")]
parent_bump: BumpSeverity,
#[serde(default)]
include_private: bool,
#[serde(default = "default_warn_on_group_mismatch")]
warn_on_group_mismatch: bool,
#[serde(default)]
strict_version_conflicts: bool,
#[serde(default)]
package_type: Option<PackageType>,
#[serde(default)]
changelog: Option<RawChangelogConfig>,
#[serde(default)]
empty_update_message: Option<String>,
#[serde(default)]
release_title: Option<String>,
#[serde(default)]
changelog_version_title: Option<String>,
}
impl Default for RawWorkspaceDefaults {
fn default() -> Self {
Self {
parent_bump: default_parent_bump(),
include_private: false,
warn_on_group_mismatch: default_warn_on_group_mismatch(),
strict_version_conflicts: false,
package_type: None,
changelog: None,
empty_update_message: None,
release_title: None,
changelog_version_title: None,
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", schemars(rename = "changelogDefinition"))]
pub(crate) enum RawChangelogDefinition {
Enabled(bool),
Path(String),
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", schemars(rename = "changelogConfig"))]
pub(crate) enum RawChangelogConfig {
Legacy(RawChangelogDefinition),
Detailed(RawChangelogTable),
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", schemars(rename = "groupChangelogInclude"))]
pub(crate) enum RawGroupChangelogInclude {
Mode(String),
Packages(Vec<String>),
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "changelogTable"))]
pub(crate) struct RawChangelogTable {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
format: Option<ChangelogFormat>,
#[serde(default)]
initial_header: Option<String>,
#[serde(default)]
include: Option<RawGroupChangelogInclude>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "schema", schemars(rename = "packageDefinition"))]
pub(crate) struct RawPackageDefinition {
path: PathBuf,
#[serde(rename = "type")]
package_type: Option<PackageType>,
#[serde(default)]
changelog: Option<RawChangelogConfig>,
#[serde(default)]
pub excluded_changelog_types: Vec<String>,
#[serde(default)]
empty_update_message: Option<String>,
#[serde(default)]
release_title: Option<String>,
#[serde(default)]
changelog_version_title: Option<String>,
#[serde(default)]
versioned_files: Vec<RawVersionedFileDefinition>,
#[serde(default)]
ignore_ecosystem_versioned_files: bool,
#[serde(default)]
ignored_paths: Vec<String>,
#[serde(default)]
additional_paths: Vec<String>,
#[serde(default)]
tag: bool,
#[serde(default)]
release: bool,
#[serde(default)]
version_format: VersionFormat,
#[serde(default)]
publish: RawPublishSettings,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "schema", schemars(rename = "groupDefinition"))]
pub(crate) struct RawGroupDefinition {
packages: Vec<String>,
#[serde(default)]
changelog: Option<RawChangelogConfig>,
#[serde(default)]
pub excluded_changelog_types: Vec<String>,
#[serde(default)]
empty_update_message: Option<String>,
#[serde(default)]
release_title: Option<String>,
#[serde(default)]
changelog_version_title: Option<String>,
#[serde(default)]
versioned_files: Vec<RawVersionedFileDefinition>,
#[serde(default)]
tag: bool,
#[serde(default)]
release: bool,
#[serde(default)]
version_format: VersionFormat,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "cliCommand"))]
pub(crate) struct RawCliCommandDefinition {
#[serde(default)]
help_text: Option<String>,
#[serde(default)]
inputs: Vec<CliInputDefinition>,
#[serde(default)]
steps: Vec<CliStepDefinition>,
#[serde(default)]
dry_run: bool,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "ecosystems"))]
pub(crate) struct RawEcosystems {
#[serde(default)]
cargo: RawEcosystemSettings,
#[serde(default)]
npm: RawEcosystemSettings,
#[serde(default)]
deno: RawEcosystemSettings,
#[serde(default)]
dart: RawEcosystemSettings,
#[serde(default)]
python: RawEcosystemSettings,
#[serde(default)]
go: RawEcosystemSettings,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "ecosystemSettings"))]
pub(crate) struct RawEcosystemSettings {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
roots: Vec<String>,
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
dependency_version_prefix: Option<String>,
#[serde(default)]
versioned_files: Vec<RawVersionedFileDefinition>,
#[serde(default)]
lockfile_commands: Vec<LockfileCommandDefinition>,
#[serde(default)]
publish: RawPublishSettings,
#[serde(default)]
publish_order: PublishOrderSettings,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "placeholderSettings"))]
pub(crate) struct RawPlaceholderSettings {
#[serde(default)]
readme: Option<String>,
#[serde(default)]
readme_file: Option<PathBuf>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "publishSettings"))]
pub(crate) struct RawPublishSettings {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
mode: Option<PublishMode>,
#[serde(default)]
registry: Option<PublishRegistry>,
#[serde(default)]
trusted_publishing: Option<RawTrustedPublishingSettings>,
#[serde(default)]
attestations: RawPublishAttestationSettings,
#[serde(default)]
rate_limits: RawPublishRateLimitSettings,
#[serde(default)]
placeholder: RawPlaceholderSettings,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "publishAttestationSettings"))]
pub(crate) struct RawPublishAttestationSettings {
#[serde(default)]
require_registry_provenance: Option<bool>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "publishRateLimitSettings"))]
pub(crate) struct RawPublishRateLimitSettings {
#[serde(default)]
enforce: Option<bool>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", schemars(rename = "trustedPublishingSettings"))]
pub(crate) enum RawTrustedPublishingSettings {
Enabled(bool),
Detailed(RawTrustedPublishingDetails),
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "trustedPublishingDetails"))]
pub(crate) struct RawTrustedPublishingDetails {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
repository: Option<String>,
#[serde(default)]
workflow: Option<String>,
#[serde(default)]
environment: Option<String>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", schemars(rename = "versionedFileDefinition"))]
pub(crate) enum RawVersionedFileDefinition {
Path(String),
Detailed(VersionedFileDefinition),
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "changelogSettings"))]
pub(crate) struct RawChangelogSettings {
#[serde(default)]
pub templates: Vec<String>,
#[serde(default)]
pub sections: BTreeMap<String, monochange_core::ChangelogSectionDef>,
#[serde(default)]
pub section_thresholds: monochange_core::ChangelogSectionThresholds,
#[serde(default)]
pub types: BTreeMap<String, monochange_core::ChangelogType>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "schema", schemars(rename = "source"))]
pub(crate) struct RawSourceConfiguration {
#[serde(default)]
provider: SourceProvider,
owner: String,
repo: String,
#[serde(default)]
host: Option<String>,
#[serde(default)]
api_url: Option<String>,
#[serde(default)]
releases: ProviderReleaseSettings,
#[serde(default)]
pull_requests: ProviderMergeRequestSettings,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", schemars(rename = "changeFile"))]
pub(crate) struct RawChangeFile {
#[serde(default)]
changes: Vec<RawChangeEntry>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize)]
#[cfg_attr(feature = "schema", schemars(rename = "changeEntry"))]
pub(crate) struct RawChangeEntry {
package: String,
#[serde(default)]
bump: Option<BumpSeverity>,
#[serde(default)]
#[cfg_attr(feature = "schema", schemars(skip))]
version: Option<Version>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
details: Option<String>,
#[serde(rename = "type", default)]
change_type: Option<String>,
#[serde(default)]
caused_by: Vec<String>,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct LoadedChangesetTarget {
pub id: String,
pub kind: ChangesetTargetKind,
pub bump: Option<BumpSeverity>,
pub explicit_version: Option<Version>,
pub origin: String,
pub evidence_refs: Vec<String>,
pub change_type: Option<String>,
pub caused_by: Vec<String>,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct LoadedChangesetFile {
pub path: PathBuf,
pub summary: Option<String>,
pub details: Option<String>,
pub targets: Vec<LoadedChangesetTarget>,
pub signals: Vec<ChangeSignal>,
}
fn default_parent_bump() -> BumpSeverity {
BumpSeverity::Patch
}
fn default_warn_on_group_mismatch() -> bool {
true
}
fn merge_cli_commands(cli: BTreeMap<String, RawCliCommandDefinition>) -> Vec<CliCommandDefinition> {
let mut merged = default_cli_commands();
for (name, definition) in cli {
let command = CliCommandDefinition {
name: name.clone(),
help_text: definition.help_text,
inputs: definition.inputs,
steps: definition.steps,
dry_run: definition.dry_run,
};
if let Some(existing) = merged
.iter_mut()
.find(|cli_command| cli_command.name == name)
{
*existing = command;
} else {
merged.push(command);
}
}
merged
}
fn render_changelog_path_template(template: &str, package_path: &Path) -> String {
let path = package_path.to_string_lossy();
let package_dir = package_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let package_name = package_path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
template
.replace("{{package_dir}}", &package_dir)
.replace("{{package_name}}", &package_name)
.replace("{{ path }}", &path)
}
impl RawChangelogConfig {
fn as_defaults_definition(&self) -> ChangelogDefinition {
match self {
Self::Legacy(RawChangelogDefinition::Enabled(false)) => ChangelogDefinition::Disabled,
Self::Legacy(RawChangelogDefinition::Enabled(true)) => {
ChangelogDefinition::PackageDefault
}
Self::Legacy(RawChangelogDefinition::Path(path_pattern)) => {
ChangelogDefinition::PathPattern(path_pattern.clone())
}
Self::Detailed(table) if matches!(table.enabled, Some(false)) => {
ChangelogDefinition::Disabled
}
Self::Detailed(table) => {
table.path.clone().map_or(
ChangelogDefinition::PackageDefault,
ChangelogDefinition::PathPattern,
)
}
}
}
fn format(&self) -> Option<ChangelogFormat> {
match self {
Self::Legacy(_) => None,
Self::Detailed(table) => table.format,
}
}
fn include(&self) -> Option<&RawGroupChangelogInclude> {
match self {
Self::Legacy(_) => None,
Self::Detailed(table) => table.include.as_ref(),
}
}
fn initial_header(&self) -> Option<String> {
match self {
Self::Legacy(_) => None,
Self::Detailed(table) => {
table
.initial_header
.as_ref()
.filter(|header| !header.trim().is_empty())
.cloned()
}
}
}
fn is_disabled(&self) -> bool {
match self {
Self::Legacy(definition) => {
matches!(definition, RawChangelogDefinition::Enabled(false))
}
Self::Detailed(table) => matches!(table.enabled, Some(false)),
}
}
fn resolve_for_package(
&self,
package_path: &Path,
treat_string_as_pattern: bool,
) -> Option<PathBuf> {
match self {
Self::Legacy(RawChangelogDefinition::Enabled(false)) => None,
Self::Legacy(RawChangelogDefinition::Enabled(true)) => {
Some(package_path.join("CHANGELOG.md"))
}
Self::Legacy(RawChangelogDefinition::Path(path)) => {
Some(resolve_changelog_path(
path,
package_path,
treat_string_as_pattern,
))
}
Self::Detailed(table) if matches!(table.enabled, Some(false)) => None,
Self::Detailed(table) => {
Some(table.path.as_deref().map_or_else(
|| package_path.join("CHANGELOG.md"),
|path| resolve_changelog_path(path, package_path, treat_string_as_pattern),
))
}
}
}
fn resolve_for_group(&self) -> Option<PathBuf> {
match self {
Self::Legacy(RawChangelogDefinition::Enabled(false | true)) => None,
Self::Legacy(RawChangelogDefinition::Path(path)) => Some(PathBuf::from(path)),
Self::Detailed(table) if matches!(table.enabled, Some(false)) => None,
Self::Detailed(table) => table.path.as_ref().map(PathBuf::from),
}
}
}
fn resolve_changelog_path(
path: &str,
package_path: &Path,
treat_string_as_pattern: bool,
) -> PathBuf {
if treat_string_as_pattern {
return PathBuf::from(render_changelog_path_template(path, package_path));
}
PathBuf::from(path)
}
fn parse_group_changelog_include(
config_contents: &str,
group_id: &str,
group_packages: &[String],
include: Option<&RawGroupChangelogInclude>,
) -> MonochangeResult<GroupChangelogInclude> {
let Some(include) = include else {
return Ok(GroupChangelogInclude::All);
};
match include {
RawGroupChangelogInclude::Mode(mode) => match mode.as_str() {
"all" => Ok(GroupChangelogInclude::All),
"group-only" => Ok(GroupChangelogInclude::GroupOnly),
_ => Err(config_diagnostic(
config_contents,
format!(
"group `{group_id}` changelog include must be `\"all\"`, `\"group-only\"`, or an array of member package ids"
),
vec![config_field_label(
config_contents,
"group",
&format!("{group_id}.changelog"),
"include",
"group changelog include",
)],
Some(
"use `include = \"all\"`, `include = \"group-only\"`, or `include = [\"member-id\"]`"
.to_string(),
),
)),
},
RawGroupChangelogInclude::Packages(package_ids) => {
let mut selected = BTreeSet::new();
for package_id in package_ids {
if package_id.trim().is_empty() {
return Err(config_diagnostic(
config_contents,
format!(
"group `{group_id}` changelog include entries must not be empty"
),
vec![config_field_label(
config_contents,
"group",
&format!("{group_id}.changelog"),
"include",
"group changelog include member",
)],
Some(
"remove the empty value or replace it with a package id declared in the group"
.to_string(),
),
));
}
if !group_packages.iter().any(|member| member == package_id) {
return Err(config_diagnostic(
config_contents,
format!(
"group `{group_id}` changelog include entry `{package_id}` must reference a package declared in that group"
),
vec![config_field_label(
config_contents,
"group",
&format!("{group_id}.changelog"),
"include",
"group changelog include member",
)],
Some(
"list only package ids from `group.<id>.packages` in `group.<id>.changelog.include`"
.to_string(),
),
));
}
selected.insert(package_id.clone());
}
if selected.is_empty() {
Ok(GroupChangelogInclude::GroupOnly)
} else {
Ok(GroupChangelogInclude::Selected(selected))
}
}
}
}
#[must_use]
pub fn config_path(root: &Path) -> PathBuf {
root.join(CONFIG_FILE)
}
#[allow(clippy::match_same_arms)]
fn package_type_to_ecosystem_type(package_type: PackageType) -> EcosystemType {
match package_type {
PackageType::Cargo => EcosystemType::Cargo,
PackageType::Npm => EcosystemType::Npm,
PackageType::Deno => EcosystemType::Deno,
PackageType::Dart | PackageType::Flutter => EcosystemType::Dart,
PackageType::Python => EcosystemType::Python,
PackageType::Go => EcosystemType::Go,
_ => EcosystemType::Cargo,
}
}
fn normalize_versioned_files(
contents: &str,
versioned_files: Vec<RawVersionedFileDefinition>,
inferred_ecosystem_type: EcosystemType,
owner_kind: &str,
owner_id: &str,
allow_shorthand: bool,
) -> MonochangeResult<Vec<VersionedFileDefinition>> {
versioned_files
.into_iter()
.map(|versioned_file| match versioned_file {
RawVersionedFileDefinition::Detailed(definition) => Ok(definition),
RawVersionedFileDefinition::Path(path) if allow_shorthand => {
Ok(VersionedFileDefinition {
path,
ecosystem_type: Some(inferred_ecosystem_type),
prefix: None,
fields: None,
name: None,
regex: None,
})
}
RawVersionedFileDefinition::Path(_) => Err(config_diagnostic(
contents,
format!(
"{owner_kind} `{owner_id}` uses bare-string `versioned_files`, but the ecosystem cannot be inferred here"
),
vec![config_section_label(
contents,
owner_kind,
owner_id,
"bare-string versioned_files not allowed here",
)],
Some(
"use `versioned_files = [{ path = \"...\", type = \"cargo\" }]` (or another explicit ecosystem type) for groups"
.to_string(),
),
)),
})
.collect()
}
fn normalize_ecosystem_settings(
contents: &str,
owner_id: &str,
inferred_ecosystem_type: EcosystemType,
raw: RawEcosystemSettings,
) -> MonochangeResult<EcosystemSettings> {
#[rustfmt::skip]
let publish = normalize_publish_settings(contents, None, raw.publish, "ecosystems", owner_id, inferred_ecosystem_type)?;
#[rustfmt::skip]
let versioned_files = normalize_versioned_files(contents, raw.versioned_files, inferred_ecosystem_type, "ecosystems", owner_id, true)?;
Ok(EcosystemSettings {
enabled: raw.enabled,
roots: raw.roots,
exclude: raw.exclude,
dependency_version_prefix: raw.dependency_version_prefix,
versioned_files,
lockfile_commands: raw.lockfile_commands,
publish,
publish_order: raw.publish_order,
})
}
fn default_publish_registry_for_ecosystem(
inferred_ecosystem_type: EcosystemType,
) -> Option<PublishRegistry> {
#[rustfmt::skip]
let registry = match inferred_ecosystem_type {
EcosystemType::Cargo => Some(PublishRegistry::Builtin(RegistryKind::CratesIo)),
EcosystemType::Npm => Some(PublishRegistry::Builtin(RegistryKind::Npm)),
EcosystemType::Deno => Some(PublishRegistry::Builtin(RegistryKind::Jsr)),
EcosystemType::Dart => Some(PublishRegistry::Builtin(RegistryKind::PubDev)),
EcosystemType::Python => Some(PublishRegistry::Builtin(RegistryKind::Pypi)),
EcosystemType::Go => Some(PublishRegistry::Builtin(RegistryKind::GoProxy)),
_ => None,
};
registry
}
fn normalize_trusted_publishing_settings(
base: Option<&TrustedPublishingSettings>,
raw: Option<RawTrustedPublishingSettings>,
) -> TrustedPublishingSettings {
let mut settings = base.cloned().unwrap_or_default();
match raw {
Some(RawTrustedPublishingSettings::Enabled(enabled)) => {
settings.enabled = enabled;
}
Some(RawTrustedPublishingSettings::Detailed(details)) => {
if let Some(enabled) = details.enabled {
settings.enabled = enabled;
}
if let Some(repository) = details.repository {
settings.repository = Some(repository);
}
if let Some(workflow) = details.workflow {
settings.workflow = Some(workflow);
}
if let Some(environment) = details.environment {
settings.environment = Some(environment);
}
}
None => {}
}
settings
}
fn normalize_publish_attestation_settings(
base: Option<&PublishAttestationSettings>,
raw: &RawPublishAttestationSettings,
) -> PublishAttestationSettings {
let mut settings = base.cloned().unwrap_or_default();
if let Some(require_registry_provenance) = raw.require_registry_provenance {
settings.require_registry_provenance = require_registry_provenance;
}
settings
}
fn normalize_publish_settings(
contents: &str,
base: Option<&PublishSettings>,
raw: RawPublishSettings,
owner_kind: &str,
owner_id: &str,
inferred_ecosystem_type: EcosystemType,
) -> MonochangeResult<PublishSettings> {
let mut settings = base.cloned().unwrap_or_else(|| {
PublishSettings {
registry: default_publish_registry_for_ecosystem(inferred_ecosystem_type),
..PublishSettings::default()
}
});
if let Some(enabled) = raw.enabled {
settings.enabled = enabled;
}
if let Some(mode) = raw.mode {
settings.mode = mode;
}
if let Some(registry) = raw.registry {
settings.registry = Some(registry);
}
settings.trusted_publishing = normalize_trusted_publishing_settings(
base.map(|settings| &settings.trusted_publishing),
raw.trusted_publishing,
);
settings.attestations = normalize_publish_attestation_settings(
base.map(|settings| &settings.attestations),
&raw.attestations,
);
if let Some(enforce) = raw.rate_limits.enforce {
settings.rate_limits.enforce = enforce;
}
if raw.placeholder.readme.is_some() {
settings.placeholder.readme_file = None;
settings.placeholder.readme = raw.placeholder.readme;
}
if raw.placeholder.readme_file.is_some() {
settings.placeholder.readme = None;
settings.placeholder.readme_file = raw.placeholder.readme_file;
}
if settings.placeholder.readme.is_some() && settings.placeholder.readme_file.is_some() {
return Err(config_diagnostic(
contents,
format!(
"{owner_kind} `{owner_id}` publish.placeholder cannot set both `readme` and `readme_file`"
),
vec![config_section_label(
contents,
owner_kind,
owner_id,
"publish placeholder readme conflict",
)],
Some("set either inline `readme` text or `readme_file`, but not both".to_string()),
));
}
let default_registry = default_publish_registry_for_ecosystem(inferred_ecosystem_type);
if settings.mode == PublishMode::Builtin && settings.registry != default_registry {
return Err(config_diagnostic(
contents,
format!(
"{owner_kind} `{owner_id}` uses built-in publishing with an unsupported registry override"
),
vec![config_section_label(
contents,
owner_kind,
owner_id,
"unsupported built-in publish registry",
)],
Some(
"remove the registry override to use the default public registry for that ecosystem, or set `mode = \"external\"` for custom/private registries".to_string(),
),
));
}
Ok(settings)
}
fn load_raw_configuration(root: &Path) -> MonochangeResult<(String, RawWorkspaceConfiguration)> {
let path = config_path(root);
let contents = if path.exists() {
fs::read_to_string(&path).map_err(|error| {
MonochangeError::Io(format!("failed to read {}: {error}", path.display()))
})?
} else {
String::new()
};
let raw = if path.exists() {
toml::from_str::<RawWorkspaceConfiguration>(&contents).map_err(|error| {
MonochangeError::Config(format!("failed to parse {}: {error}", path.display()))
})?
} else {
RawWorkspaceConfiguration::default()
};
Ok((contents, raw))
}
#[allow(clippy::too_many_arguments, clippy::option_as_ref_cloned)]
fn build_package_definitions(
contents: &str,
packages: BTreeMap<String, RawPackageDefinition>,
default_package_type: Option<PackageType>,
default_package_changelog: Option<&RawChangelogConfig>,
default_changelog_format: ChangelogFormat,
cargo_ecosystem: &EcosystemSettings,
npm_ecosystem: &EcosystemSettings,
deno_ecosystem: &EcosystemSettings,
dart_ecosystem: &EcosystemSettings,
python_ecosystem: &EcosystemSettings,
go_ecosystem: &EcosystemSettings,
) -> MonochangeResult<Vec<PackageDefinition>> {
packages
.into_iter()
.map(|(id, package)| {
let package_type = package.package_type.or(default_package_type).ok_or_else(|| {
config_diagnostic(
contents,
format!(
"package `{id}` must declare `type` or set `[defaults].package_type`"
),
vec![config_section_label(
contents,
"package",
&id,
"package missing type",
)],
Some(
"set `type = \"cargo\"` (or another supported type) on the package, or set `[defaults].package_type` for a single-ecosystem repository"
.to_string(),
),
)
})?;
let changelog = package
.changelog
.as_ref()
.and_then(|definition| {
definition.resolve_for_package(&package.path, false).map(|path| ChangelogTarget {
path,
format: definition.format().unwrap_or(default_changelog_format),
initial_header: definition
.initial_header()
.or_else(|| default_package_changelog.and_then(RawChangelogConfig::initial_header)),
})
})
.or_else(|| {
default_package_changelog.and_then(|definition| {
definition.resolve_for_package(&package.path, true).map(|path| ChangelogTarget {
path,
format: definition.format().unwrap_or(default_changelog_format),
initial_header: definition.initial_header(),
})
})
});
let inferred_ecosystem_type = package_type_to_ecosystem_type(package_type);
let inherited_versioned_files = if package.ignore_ecosystem_versioned_files {
Vec::new()
} else {
match inferred_ecosystem_type {
EcosystemType::Cargo => cargo_ecosystem.versioned_files.clone(),
EcosystemType::Npm => npm_ecosystem.versioned_files.clone(),
EcosystemType::Deno => deno_ecosystem.versioned_files.clone(),
EcosystemType::Dart => dart_ecosystem.versioned_files.clone(),
EcosystemType::Python => python_ecosystem.versioned_files.clone(),
EcosystemType::Go => go_ecosystem.versioned_files.clone(),
_ => Vec::new(),
}
};
let mut versioned_files = inherited_versioned_files;
versioned_files.extend(normalize_versioned_files(
contents,
package.versioned_files,
inferred_ecosystem_type,
"package",
&id,
true,
)?);
let publish = normalize_publish_settings(
contents,
Some({
#[rustfmt::skip]
let publish = match inferred_ecosystem_type {
EcosystemType::Npm => &npm_ecosystem.publish,
EcosystemType::Deno => &deno_ecosystem.publish,
EcosystemType::Dart => &dart_ecosystem.publish,
EcosystemType::Python => &python_ecosystem.publish,
EcosystemType::Go => &go_ecosystem.publish,
_ => &cargo_ecosystem.publish,
};
publish
}),
package.publish,
"package",
&id,
inferred_ecosystem_type,
)?;
Ok::<_, MonochangeError>(PackageDefinition {
id,
path: package.path,
package_type,
changelog,
excluded_changelog_types: package.excluded_changelog_types,
empty_update_message: package.empty_update_message,
release_title: package.release_title,
changelog_version_title: package.changelog_version_title,
versioned_files,
ignore_ecosystem_versioned_files: package.ignore_ecosystem_versioned_files,
ignored_paths: package.ignored_paths,
additional_paths: package.additional_paths,
tag: package.tag,
release: package.release,
version_format: package.version_format,
publish,
})
})
.collect::<Result<Vec<_>, _>>()
}
fn build_group_definitions(
contents: &str,
groups: BTreeMap<String, RawGroupDefinition>,
default_changelog_format: ChangelogFormat,
default_changelog_initial_header: Option<&str>,
) -> MonochangeResult<Vec<GroupDefinition>> {
groups
.into_iter()
.map(|(id, group)| {
let changelog = match group.changelog.as_ref() {
None => None,
Some(definition) => match definition.resolve_for_group() {
Some(path) => Some(ChangelogTarget {
path,
format: definition.format().unwrap_or(default_changelog_format),
initial_header: definition
.initial_header()
.or_else(|| default_changelog_initial_header.map(str::to_string)),
}),
None if definition.is_disabled() => None,
None => {
return Err(config_diagnostic(
contents,
format!(
"group `{id}` changelog must declare a `path` when changelog output is enabled"
),
vec![config_section_label(
contents,
"group",
&id,
"group changelog missing path",
)],
Some(
"set `changelog = \"changelog.md\"` or `[group.<id>.changelog].path` when enabling grouped changelog output"
.to_string(),
),
));
}
},
};
let changelog_include = parse_group_changelog_include(
contents,
&id,
&group.packages,
group.changelog.as_ref().and_then(RawChangelogConfig::include),
)?;
Ok::<_, MonochangeError>(GroupDefinition {
id: id.clone(),
packages: group.packages,
changelog,
changelog_include,
excluded_changelog_types: group.excluded_changelog_types,
empty_update_message: group.empty_update_message,
release_title: group.release_title,
changelog_version_title: group.changelog_version_title,
versioned_files: normalize_versioned_files(
contents,
group.versioned_files,
EcosystemType::Cargo,
"group",
&id,
false,
)?,
tag: group.tag,
release: group.release,
version_format: group.version_format,
})
})
.collect::<Result<Vec<_>, _>>()
}
fn resolve_source_configuration(
source: Option<RawSourceConfiguration>,
) -> Option<SourceConfiguration> {
source.map(|source| {
SourceConfiguration {
provider: source.provider,
owner: source.owner,
repo: source.repo,
host: source.host,
api_url: source.api_url,
releases: source.releases,
pull_requests: source.pull_requests,
}
})
}
#[must_use = "the configuration result must be checked"]
#[tracing::instrument(skip_all)]
pub fn load_workspace_configuration(root: &Path) -> MonochangeResult<WorkspaceConfiguration> {
let (contents, raw) = load_raw_configuration(root)?;
let RawWorkspaceConfiguration {
defaults,
changelog,
package,
group,
cli,
changesets,
source,
lints,
ecosystems,
} = raw;
let cli = merge_cli_commands(cli);
let default_package_type = defaults.package_type;
let default_package_changelog = defaults.changelog.clone();
let cargo_ecosystem =
normalize_ecosystem_settings(&contents, "cargo", EcosystemType::Cargo, ecosystems.cargo)?;
let npm_ecosystem =
normalize_ecosystem_settings(&contents, "npm", EcosystemType::Npm, ecosystems.npm)?;
let deno_ecosystem =
normalize_ecosystem_settings(&contents, "deno", EcosystemType::Deno, ecosystems.deno)?;
let dart_ecosystem =
normalize_ecosystem_settings(&contents, "dart", EcosystemType::Dart, ecosystems.dart)?;
let python_ecosystem_input = ecosystems.python;
let python_ecosystem = normalize_ecosystem_settings(
&contents,
"python",
EcosystemType::Python,
python_ecosystem_input,
)?;
let go_ecosystem =
normalize_ecosystem_settings(&contents, "go", EcosystemType::Go, ecosystems.go)?;
let defaults_changelog_policy = defaults
.changelog
.as_ref()
.map(RawChangelogConfig::as_defaults_definition);
let default_changelog_format = defaults
.changelog
.as_ref()
.and_then(RawChangelogConfig::format)
.unwrap_or_default();
let packages = build_package_definitions(
&contents,
package,
default_package_type,
default_package_changelog.as_ref(),
default_changelog_format,
&cargo_ecosystem,
&npm_ecosystem,
&deno_ecosystem,
&dart_ecosystem,
&python_ecosystem,
&go_ecosystem,
)?;
let default_changelog_initial_header = defaults
.changelog
.as_ref()
.and_then(RawChangelogConfig::initial_header);
let groups = build_group_definitions(
&contents,
group,
default_changelog_format,
default_changelog_initial_header.as_deref(),
)?;
let source = resolve_source_configuration(source);
validate_cli(&cli)?;
validate_changelog_configuration(&contents, &changelog, &packages, &groups)?;
validate_changesets_configuration(&changesets, &packages)?;
let changelog = build_changelog_settings(changelog);
let changeset_lints = changeset_lint_settings_from_rules(&lints.rules)?;
validate_changeset_lint_settings(&changeset_lints, &changelog)?;
validate_source_configuration(source.as_ref())?;
for (ecosystem_id, ecosystem_settings) in [
("cargo", &cargo_ecosystem),
("npm", &npm_ecosystem),
("deno", &deno_ecosystem),
("dart", &dart_ecosystem),
("python", &python_ecosystem),
("go", &go_ecosystem),
] {
let declared_packages = packages
.iter()
.map(|package| package.id.as_str())
.collect::<BTreeSet<_>>();
validate_versioned_files(
root,
&contents,
&ecosystem_settings.versioned_files,
&declared_packages,
"ecosystems",
ecosystem_id,
)?;
validate_lockfile_commands(root, ecosystem_id, &ecosystem_settings.lockfile_commands)?;
}
validate_package_and_group_definitions(root, &contents, &packages, &groups)?;
validate_cli_runtime_requirements(&cli, &changesets, source.as_ref())?;
Ok(WorkspaceConfiguration {
root_path: root.to_path_buf(),
defaults: WorkspaceDefaults {
parent_bump: defaults.parent_bump,
include_private: defaults.include_private,
warn_on_group_mismatch: defaults.warn_on_group_mismatch,
strict_version_conflicts: defaults.strict_version_conflicts,
package_type: defaults.package_type,
changelog: defaults_changelog_policy,
changelog_format: default_changelog_format,
empty_update_message: defaults.empty_update_message,
release_title: defaults.release_title,
changelog_version_title: defaults.changelog_version_title,
},
changelog,
packages,
groups,
cli,
changesets,
source,
lints,
cargo: cargo_ecosystem,
npm: npm_ecosystem,
deno: deno_ecosystem,
dart: dart_ecosystem,
python: python_ecosystem,
go: go_ecosystem,
})
}
#[derive(Debug)]
struct ChangeTypeLookup {
valid_types: Vec<String>,
default_bumps: HashMap<String, BumpSeverity>,
}
#[derive(Debug)]
pub struct ChangesetLoadContext<'a> {
_configuration: &'a WorkspaceConfiguration,
package_ids: HashSet<&'a str>,
groups_by_id: HashMap<&'a str, &'a GroupDefinition>,
package_reference_matches: HashMap<String, Vec<&'a str>>,
package_versions: HashMap<&'a str, &'a Version>,
change_types_by_target: HashMap<&'a str, ChangeTypeLookup>,
}
#[must_use]
pub fn build_changeset_load_context<'a>(
configuration: &'a WorkspaceConfiguration,
packages: &'a [PackageRecord],
) -> ChangesetLoadContext<'a> {
let package_ids = configuration
.packages
.iter()
.map(|package| package.id.as_str())
.collect::<HashSet<_>>();
let groups_by_id = configuration
.groups
.iter()
.map(|group| (group.id.as_str(), group))
.collect::<HashMap<_, _>>();
let package_versions = packages
.iter()
.filter_map(|package| {
package
.current_version
.as_ref()
.map(|version| (package.id.as_str(), version))
})
.collect::<HashMap<_, _>>();
let mut package_reference_matches = HashMap::<String, Vec<&'a str>>::new();
for package in packages {
for reference in changeset_package_references(configuration.root_path.as_path(), package) {
package_reference_matches
.entry(reference)
.or_default()
.push(package.id.as_str());
}
}
let mut change_types_by_target = HashMap::new();
for package in &configuration.packages {
change_types_by_target.insert(
package.id.as_str(),
build_change_type_lookup(&configuration.changelog, &package.excluded_changelog_types),
);
}
for group in &configuration.groups {
change_types_by_target.insert(
group.id.as_str(),
build_change_type_lookup(&configuration.changelog, &group.excluded_changelog_types),
);
}
ChangesetLoadContext {
_configuration: configuration,
package_ids,
groups_by_id,
package_reference_matches,
package_versions,
change_types_by_target,
}
}
fn build_change_type_lookup(
changelog: &ChangelogSettings,
excluded_types: &[String],
) -> ChangeTypeLookup {
let excluded: BTreeSet<&str> = excluded_types.iter().map(String::as_str).collect();
let mut valid_types = changelog
.types
.keys()
.filter(|key| !excluded.contains(key.as_str()))
.map(ToString::to_string)
.collect::<Vec<_>>();
valid_types.sort();
let default_bumps = changelog
.types
.iter()
.filter(|(key, _)| !excluded.contains(key.as_str()))
.map(|(change_type, typ)| (change_type.clone(), typ.bump))
.collect::<HashMap<_, _>>();
ChangeTypeLookup {
valid_types,
default_bumps,
}
}
fn changeset_package_references(root: &Path, package: &PackageRecord) -> Vec<String> {
let mut references = vec![package.name.clone(), package.id.clone()];
if let Some(config_id) = package.metadata.get("config_id") {
references.push(config_id.clone());
}
if let Some(manifest_path) = relative_to_root(root, &package.manifest_path)
.and_then(|path| path.to_str().map(ToString::to_string))
{
references.push(manifest_path);
}
if let Some(directory_path) = package
.manifest_path
.parent()
.and_then(|path| relative_to_root(root, path))
.and_then(|path| path.to_str().map(ToString::to_string))
{
references.push(directory_path);
}
references.sort();
references.dedup();
references
}
#[must_use = "the change signals result must be checked"]
pub fn load_change_signals(
changes_path: &Path,
configuration: &WorkspaceConfiguration,
packages: &[PackageRecord],
) -> MonochangeResult<Vec<ChangeSignal>> {
let context = build_changeset_load_context(configuration, packages);
Ok(load_changeset_file_with_context(changes_path, &context)?.signals)
}
#[must_use = "the changeset result must be checked"]
pub fn load_changeset_file(
changes_path: &Path,
configuration: &WorkspaceConfiguration,
packages: &[PackageRecord],
) -> MonochangeResult<LoadedChangesetFile> {
let context = build_changeset_load_context(configuration, packages);
load_changeset_file_with_context(changes_path, &context)
}
#[must_use = "the changeset result must be checked"]
pub fn load_changeset_file_with_context(
changes_path: &Path,
context: &ChangesetLoadContext<'_>,
) -> MonochangeResult<LoadedChangesetFile> {
let contents = fs::read_to_string(changes_path).map_err(|error| {
MonochangeError::Io(format!(
"failed to read {}: {error}",
changes_path.display()
))
})?;
load_changeset_contents_with_context(changes_path, &contents, context)
}
#[must_use = "the changeset result must be checked"]
pub fn load_changeset_contents_with_context(
changes_path: &Path,
contents: &str,
context: &ChangesetLoadContext<'_>,
) -> MonochangeResult<LoadedChangesetFile> {
let raw = if changes_path.extension().and_then(|value| value.to_str()) == Some("md") {
parse_markdown_change_file_with_context(contents, changes_path, context)?
} else {
toml::from_str::<RawChangeFile>(contents).map_err(|error| {
MonochangeError::Config(format!(
"failed to parse {}: {error}",
changes_path.display()
))
})?
};
let referenced_packages: HashSet<String> = raw
.changes
.iter()
.filter(|change| context.package_ids.contains(change.package.as_str()))
.map(|change| change.package.clone())
.collect();
for change in &raw.changes {
if !context.package_ids.contains(change.package.as_str())
&& !context.groups_by_id.contains_key(change.package.as_str())
{
return Err(changeset_diagnostic(
contents,
changes_path,
format!(
"changeset `{}` references unknown package or group `{}`",
changes_path.display(),
change.package,
),
vec![changeset_key_label(
contents,
&change.package,
"unknown package or group",
)],
Some("declare the package or group id in monochange.toml before referencing it in a changeset".to_string()),
));
}
}
let summary = raw.changes.first().and_then(|change| change.reason.clone());
let details = raw
.changes
.first()
.and_then(|change| change.details.clone());
let mut seen_package_ids = HashSet::new();
let mut signals = Vec::new();
let mut targets = Vec::new();
for change in raw.changes {
if let Some(group) = context.groups_by_id.get(change.package.as_str()) {
let explicit_version = change.version.clone();
let caused_by = change.caused_by.clone();
let change_type = change.change_type.clone();
if let Some(change_type) = change_type.as_deref() {
validate_configured_change_type_with_context(
context,
changes_path,
&change.package,
change_type,
)?;
}
let type_default_bump = change_type.as_deref().and_then(|change_type| {
configured_change_type_default_bump_with_context(
context,
&change.package,
change_type,
)
});
let inferred_bump = match change.bump {
Some(bump) => Some(bump),
None => {
match type_default_bump {
Some(bump) => Some(bump),
None => {
infer_group_bump_from_explicit_version_with_context(
group,
context,
explicit_version.as_ref(),
)?
}
}
}
};
targets.push(LoadedChangesetTarget {
id: change.package.clone(),
kind: ChangesetTargetKind::Group,
bump: inferred_bump,
explicit_version: explicit_version.clone(),
origin: "direct-change".to_string(),
evidence_refs: Vec::new(),
change_type: change_type.clone(),
caused_by: caused_by.clone(),
});
for member_id in &group.packages {
if referenced_packages.contains(member_id.as_str()) {
continue;
}
let package_id = resolve_package_reference_with_context(member_id, context)?;
if !seen_package_ids.insert(package_id.clone()) {
return Err(changeset_diagnostic(
contents,
changes_path,
format!(
"duplicate change entry for `{package_id}` in {}",
changes_path.display()
),
vec![changeset_key_label(
contents,
member_id,
"duplicate package target",
)],
Some("keep one change entry per effective package target".to_string()),
));
}
signals.push(ChangeSignal {
package_id,
requested_bump: inferred_bump,
explicit_version: explicit_version.clone(),
change_origin: "direct-change".to_string(),
evidence_refs: Vec::new(),
notes: change.reason.clone(),
details: change.details.clone(),
change_type: change_type.clone(),
caused_by: caused_by.clone(),
source_path: changes_path.to_path_buf(),
});
}
} else {
let package_id = resolve_package_reference_with_context(&change.package, context)?;
let explicit_version = change.version;
let caused_by = change.caused_by;
let change_type = change.change_type;
if let Some(change_type) = change_type.as_deref() {
validate_configured_change_type_with_context(
context,
changes_path,
&change.package,
change_type,
)?;
}
let type_default_bump = change_type.as_deref().and_then(|change_type| {
configured_change_type_default_bump_with_context(
context,
&change.package,
change_type,
)
});
let inferred_bump = change.bump.or(type_default_bump).or_else(|| {
infer_package_bump_from_explicit_version_with_context(
&package_id,
context,
explicit_version.as_ref(),
)
});
targets.push(LoadedChangesetTarget {
id: change.package.clone(),
kind: ChangesetTargetKind::Package,
bump: inferred_bump,
explicit_version: explicit_version.clone(),
origin: "direct-change".to_string(),
evidence_refs: Vec::new(),
change_type: change_type.clone(),
caused_by: caused_by.clone(),
});
if !seen_package_ids.insert(package_id.clone()) {
return Err(changeset_diagnostic(
contents,
changes_path,
format!(
"duplicate change entry for `{package_id}` in {}",
changes_path.display()
),
vec![changeset_key_label(
contents,
&change.package,
"duplicate package target",
)],
Some("keep one change entry per effective package target".to_string()),
));
}
signals.push(ChangeSignal {
package_id,
requested_bump: inferred_bump,
explicit_version,
change_origin: "direct-change".to_string(),
evidence_refs: Vec::new(),
notes: change.reason,
details: change.details,
change_type,
caused_by,
source_path: changes_path.to_path_buf(),
});
}
}
Ok(LoadedChangesetFile {
path: changes_path.to_path_buf(),
summary,
details,
targets,
signals,
})
}
fn infer_package_bump_from_explicit_version_with_context(
package_id: &str,
context: &ChangesetLoadContext<'_>,
explicit_version: Option<&Version>,
) -> Option<BumpSeverity> {
let explicit_version = explicit_version?;
context
.package_versions
.get(package_id)
.map(|current_version| infer_bump_from_versions(current_version, explicit_version))
}
fn infer_group_bump_from_explicit_version_with_context(
group: &GroupDefinition,
context: &ChangesetLoadContext<'_>,
explicit_version: Option<&Version>,
) -> MonochangeResult<Option<BumpSeverity>> {
let Some(explicit_version) = explicit_version else {
return Ok(None);
};
let mut max_version: Option<&Version> = None;
for member_id in &group.packages {
let package_id = resolve_package_reference_with_context(member_id, context)?;
if let Some(current_version) = context.package_versions.get(package_id.as_str()) {
max_version = Some(match max_version {
Some(current_max) if *current_version > current_max => current_version,
Some(current_max) => current_max,
None => current_version,
});
}
}
Ok(max_version
.map(|current_version| infer_bump_from_versions(current_version, explicit_version)))
}
#[must_use = "the resolution result must be checked"]
fn resolve_package_reference_with_context(
reference: &str,
context: &ChangesetLoadContext<'_>,
) -> MonochangeResult<String> {
match context
.package_reference_matches
.get(reference)
.map(Vec::as_slice)
.unwrap_or_default()
{
[] => {
Err(MonochangeError::Config(format!(
"change package reference `{reference}` did not match any discovered package"
)))
}
[package_id] => Ok((*package_id).to_string()),
package_ids => {
Err(MonochangeError::Config(format!(
"change package reference `{reference}` matched multiple packages: {}",
package_ids.join(", ")
)))
}
}
}
fn configured_change_type_default_bump_with_context(
context: &ChangesetLoadContext<'_>,
target: &str,
change_type: &str,
) -> Option<BumpSeverity> {
context
.change_types_by_target
.get(target)
.and_then(|lookup| lookup.default_bumps.get(change_type))
.copied()
}
fn configured_change_types_with_context(
context: &ChangesetLoadContext<'_>,
target: &str,
) -> Vec<String> {
context
.change_types_by_target
.get(target)
.map(|lookup| lookup.valid_types.clone())
.unwrap_or_default()
}
fn parse_markdown_change_file_with_context(
contents: &str,
changes_path: &Path,
context: &ChangesetLoadContext<'_>,
) -> MonochangeResult<RawChangeFile> {
let contents = &contents.replace("\r\n", "\n").replace('\r', "\n");
let Some(without_opening) = contents.strip_prefix("---") else {
return Err(MonochangeError::Config(format!(
"failed to parse {}: missing markdown frontmatter",
changes_path.display()
)));
};
let Some((frontmatter, body_with_separator)) = without_opening.split_once("\n---\n") else {
return Err(MonochangeError::Config(format!(
"failed to parse {}: unterminated markdown frontmatter",
changes_path.display()
)));
};
let body = body_with_separator.trim();
let mapping = parse_changeset_frontmatter(contents, frontmatter, changes_path)?;
let (reason, details) = markdown_change_text(body);
let mut changes = Vec::new();
for (key, value) in &mapping {
let Some(package) = key.as_str() else {
continue;
};
let (requested_bump, explicit_version, change_type, caused_by) =
parse_markdown_change_target_with_context(value, changes_path, package, context)?;
changes.push(RawChangeEntry {
package: package.to_string(),
bump: requested_bump,
version: explicit_version,
reason: reason.clone(),
details: details.clone(),
change_type,
caused_by,
});
}
Ok(RawChangeFile { changes })
}
type ParsedMarkdownChangeTarget = (
Option<BumpSeverity>,
Option<Version>,
Option<String>,
Vec<String>,
);
fn parse_markdown_change_target_with_context(
value: &serde_yaml_ng::Value,
changes_path: &Path,
package: &str,
context: &ChangesetLoadContext<'_>,
) -> MonochangeResult<ParsedMarkdownChangeTarget> {
if let Some(token) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Some(default_bump) =
configured_change_type_default_bump_with_context(context, package, token)
{
return Ok((
Some(default_bump),
None,
Some(token.to_string()),
Vec::new(),
));
}
if context.package_ids.contains(package) || context.groups_by_id.contains_key(package) {
let valid_types = configured_change_types_with_context(context, package);
let valid_types_help = if valid_types.is_empty() {
"no configured types are available for this target".to_string()
} else {
format!("valid types: {}", valid_types.join(", "))
};
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` has invalid scalar change type `{token}`; {valid_types_help}",
changes_path.display()
)));
}
return Ok((None, None, Some(token.to_string()), Vec::new()));
}
let Some(mapping) = value.as_mapping() else {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` must map to a configured change type or to a table with `bump`, `version`, `type`, and/or `caused_by`",
changes_path.display()
)));
};
let allowed_keys = ["bump", "version", "type", "caused_by"];
let unknown_keys = mapping
.keys()
.filter_map(serde_yaml_ng::Value::as_str)
.filter(|key| !allowed_keys.contains(key))
.collect::<Vec<_>>();
if !unknown_keys.is_empty() {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` uses unsupported field(s): {}",
changes_path.display(),
unknown_keys.join(", ")
)));
}
let requested_bump = mapping
.get(serde_yaml_ng::Value::String("bump".to_string()))
.and_then(serde_yaml_ng::Value::as_str)
.map(|value| {
parse_bump_severity(value).ok_or_else(|| {
MonochangeError::Config(format!(
"failed to parse {}: target `{package}` has invalid bump `{value}`; expected `none`, `patch`, `minor`, or `major`",
changes_path.display()
))
})
})
.transpose()?;
let explicit_version = mapping
.get(serde_yaml_ng::Value::String("version".to_string()))
.and_then(serde_yaml_ng::Value::as_str)
.map(|value| {
Version::parse(value).map_err(|error| {
MonochangeError::Config(format!(
"failed to parse {}: target `{package}` has invalid version `{value}`: {error}",
changes_path.display()
))
})
})
.transpose()?;
let change_type = mapping
.get(serde_yaml_ng::Value::String("type".to_string()))
.and_then(serde_yaml_ng::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
if let Some(change_type) = change_type.as_deref() {
validate_configured_change_type_with_context(context, changes_path, package, change_type)?;
}
let caused_by_value = mapping.get(serde_yaml_ng::Value::String("caused_by".to_string()));
let caused_by = parse_caused_by_refs(caused_by_value, changes_path, package, |reference| {
context.package_ids.contains(reference) || context.groups_by_id.contains_key(reference)
})?;
let requested_bump = requested_bump.or_else(|| {
change_type.as_deref().and_then(|change_type| {
configured_change_type_default_bump_with_context(context, package, change_type)
})
});
if requested_bump.is_none() && explicit_version.is_none() && change_type.is_none() {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` must declare `bump`, `version`, `type`, or a valid scalar shorthand",
changes_path.display()
)));
}
if requested_bump == Some(BumpSeverity::None)
&& explicit_version.is_none()
&& change_type.is_none()
&& caused_by.is_empty()
{
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` must not use `bump = \"none\"` without also declaring `type`, `version`, or `caused_by`",
changes_path.display()
)));
}
Ok((requested_bump, explicit_version, change_type, caused_by))
}
fn parse_caused_by_refs(
value: Option<&serde_yaml_ng::Value>,
changes_path: &Path,
target: &str,
is_valid_reference: impl Fn(&str) -> bool,
) -> MonochangeResult<Vec<String>> {
let Some(value) = value else {
return Ok(Vec::new());
};
let references = match value {
serde_yaml_ng::Value::String(reference) => {
let reference = reference.trim();
if reference.is_empty() {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{target}` must not use an empty `caused_by` reference",
changes_path.display()
)));
}
vec![reference.to_string()]
}
serde_yaml_ng::Value::Sequence(sequence) => {
if sequence.is_empty() {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{target}` must list at least one `caused_by` reference",
changes_path.display()
)));
}
let mut references = Vec::with_capacity(sequence.len());
for item in sequence {
let Some(reference) = item
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{target}` must use string package or group ids in `caused_by`",
changes_path.display()
)));
};
references.push(reference.to_string());
}
references
}
_ => {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{target}` must use a string or list of strings for `caused_by`",
changes_path.display()
)));
}
};
for reference in &references {
if !is_valid_reference(reference) {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{target}` references unknown `caused_by` package or group `{reference}`",
changes_path.display()
)));
}
}
Ok(references)
}
fn validate_configured_change_type_with_context(
context: &ChangesetLoadContext<'_>,
changes_path: &Path,
target: &str,
change_type: &str,
) -> MonochangeResult<()> {
if !context.package_ids.contains(target) && !context.groups_by_id.contains_key(target) {
return Ok(());
}
let valid_types = configured_change_types_with_context(context, target);
if valid_types.iter().any(|candidate| candidate == change_type) {
return Ok(());
}
let valid_types_help = if valid_types.is_empty() {
"no configured types are available for this target".to_string()
} else {
format!("valid types: {}", valid_types.join(", "))
};
Err(MonochangeError::Config(format!(
"failed to parse {}: target `{target}` has invalid type `{change_type}`; {valid_types_help}",
changes_path.display()
)))
}
fn infer_bump_from_versions(current_version: &Version, explicit_version: &Version) -> BumpSeverity {
if explicit_version.major > current_version.major {
BumpSeverity::Major
} else if explicit_version.minor > current_version.minor {
BumpSeverity::Minor
} else if explicit_version.patch > current_version.patch
|| explicit_version.pre != current_version.pre
|| explicit_version.build != current_version.build
{
BumpSeverity::Patch
} else {
BumpSeverity::None
}
}
#[must_use = "the resolution result must be checked"]
pub fn resolve_package_reference(
reference: &str,
workspace_root: &Path,
packages: &[PackageRecord],
) -> MonochangeResult<String> {
let matching_package_ids = find_matching_package_ids(reference, workspace_root, packages);
match matching_package_ids.as_slice() {
[] => {
Err(MonochangeError::Config(format!(
"change package reference `{reference}` did not match any discovered package"
)))
}
[package_id] => Ok(package_id.clone()),
_ => {
Err(MonochangeError::Config(format!(
"change package reference `{reference}` matched multiple packages: {}",
matching_package_ids.join(", ")
)))
}
}
}
fn parse_markdown_change_file(
contents: &str,
changes_path: &Path,
configuration: &WorkspaceConfiguration,
) -> MonochangeResult<RawChangeFile> {
let contents = &contents.replace("\r\n", "\n").replace('\r', "\n");
let Some(without_opening) = contents.strip_prefix("---") else {
return Err(MonochangeError::Config(format!(
"failed to parse {}: missing markdown frontmatter",
changes_path.display()
)));
};
let Some((frontmatter, body_with_separator)) = without_opening.split_once("\n---\n") else {
return Err(MonochangeError::Config(format!(
"failed to parse {}: unterminated markdown frontmatter",
changes_path.display()
)));
};
let body = body_with_separator.trim();
let mapping = parse_changeset_frontmatter(contents, frontmatter, changes_path)?;
let (reason, details) = markdown_change_text(body);
let mut changes = Vec::new();
for (key, value) in &mapping {
let Some(package) = key.as_str() else {
continue;
};
let (requested_bump, explicit_version, change_type, caused_by) =
parse_markdown_change_target(value, changes_path, package, configuration)?;
changes.push(RawChangeEntry {
package: package.to_string(),
bump: requested_bump,
version: explicit_version,
reason: reason.clone(),
details: details.clone(),
change_type,
caused_by,
});
}
Ok(RawChangeFile { changes })
}
fn parse_changeset_frontmatter(
contents: &str,
frontmatter: &str,
changes_path: &Path,
) -> MonochangeResult<Mapping> {
serde_yaml_ng::from_str::<Mapping>(frontmatter).map_err(|error| {
let message = format!(
"failed to parse {} frontmatter: {error}",
changes_path.display()
);
let location = error.location().map(|location| {
frontmatter_span_for_line_column(contents, location.line(), location.column())
});
let labels = location
.map(|span| {
vec![LabeledSpan::new_with_span(
Some("frontmatter parse error".to_string()),
range_to_span(span),
)]
})
.unwrap_or_default();
changeset_diagnostic(
contents,
changes_path,
message,
labels,
Some(
"wrap package or group ids that contain characters like `@`, `/`, `:`, or spaces in double quotes, for example `\"@scope/pkg\": patch`".to_string(),
),
)
})
}
pub(crate) fn markdown_heading_level(line: &str) -> Option<usize> {
let trimmed = line.trim_start();
let level = trimmed
.chars()
.take_while(|character| *character == '#')
.count();
if !(1..=6).contains(&level) {
return None;
}
let remainder = &trimmed[level..];
if remainder.is_empty() || remainder.starts_with(char::is_whitespace) {
Some(level)
} else {
None
}
}
fn normalize_markdown_heading_levels(
markdown: &str,
summary_heading_level: Option<usize>,
summary_render_level: usize,
) -> String {
let mut in_fenced_code_block = false;
let mut first_detail_heading_level = None;
markdown
.lines()
.map(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fenced_code_block = !in_fenced_code_block;
return line.to_string();
}
if in_fenced_code_block {
return line.to_string();
}
let Some(authored_level) = markdown_heading_level(line) else {
return line.to_string();
};
let summary_context_level = if let Some(summary_heading_level) = summary_heading_level {
summary_render_level as isize + authored_level as isize
- summary_heading_level as isize
} else {
let baseline = *first_detail_heading_level.get_or_insert(authored_level);
(summary_render_level + 1) as isize + authored_level as isize - baseline as isize
};
let normalized_level = summary_context_level.clamp(1, 6) as usize;
let text = trimmed.trim_start_matches('#').trim();
format!("{} {text}", "#".repeat(normalized_level))
})
.collect::<Vec<_>>()
.join("\n")
}
fn markdown_change_text(body: &str) -> (Option<String>, Option<String>) {
let trimmed = body.trim();
if trimmed.is_empty() {
return (None, None);
}
let lines = trimmed.lines().collect::<Vec<_>>();
let Some((summary_index, summary_line)) = lines.iter().enumerate().find_map(|(index, line)| {
let candidate = line.trim();
if candidate.is_empty() {
None
} else {
Some((index, candidate))
}
}) else {
return (None, None);
};
let summary_heading_level = markdown_heading_level(summary_line);
let summary = summary_heading_level.map_or_else(
|| summary_line.to_string(),
|_| summary_line.trim_start_matches('#').trim().to_string(),
);
let details = lines
.iter()
.skip(summary_index + 1)
.copied()
.collect::<Vec<_>>()
.join("\n");
let normalized_details = normalize_markdown_heading_levels(&details, summary_heading_level, 4)
.trim()
.to_string();
(
Some(summary),
if normalized_details.is_empty() {
None
} else {
Some(normalized_details)
},
)
}
fn changeset_lint_settings_from_rules(
rules: &BTreeMap<String, LintRuleConfig>,
) -> MonochangeResult<ChangesetLintSettings> {
let mut settings = ChangesetLintSettings::default();
for (rule_id, config) in rules {
if !rule_id.starts_with("changesets/") || !config.severity().is_enabled() {
continue;
}
match rule_id.as_str() {
"changesets/duplicate" => {}
"changesets/no_section_headings" => settings.no_section_headings = true,
"changesets/summary" => {
settings.summary = changeset_summary_lint_settings_from_rule(rule_id, config)?;
}
_ => {
if let Some(bump) = rule_id.strip_prefix("changesets/bump/") {
let Some(bump) = parse_bump_severity(bump) else {
return Err(MonochangeError::Config(format!(
"[lints.rules].{rule_id} uses an unknown bump severity"
)));
};
settings.bump.insert(
bump,
changeset_scoped_lint_settings_from_rule(rule_id, config)?,
);
} else if let Some(change_type) = rule_id.strip_prefix("changesets/types/") {
if change_type.trim().is_empty() {
return Err(MonochangeError::Config(
"[lints.rules].changesets/types/<type> must include a type name"
.to_string(),
));
}
settings.types.insert(
change_type.to_string(),
changeset_scoped_lint_settings_from_rule(rule_id, config)?,
);
}
}
}
}
Ok(settings)
}
fn changeset_summary_lint_settings_from_rule(
rule_id: &str,
config: &LintRuleConfig,
) -> MonochangeResult<ChangesetSummaryLintSettings> {
Ok(ChangesetSummaryLintSettings {
required: lint_bool_option(rule_id, config, "required")?.unwrap_or(false),
heading_level: lint_usize_option(rule_id, config, "heading_level")?,
min_length: lint_usize_option(rule_id, config, "min_length")?,
max_length: lint_usize_option(rule_id, config, "max_length")?,
forbid_trailing_period: lint_bool_option(rule_id, config, "forbid_trailing_period")?
.unwrap_or(false),
forbid_conventional_commit_prefix: lint_bool_option(
rule_id,
config,
"forbid_conventional_commit_prefix",
)?
.unwrap_or(false),
})
}
fn changeset_scoped_lint_settings_from_rule(
rule_id: &str,
config: &LintRuleConfig,
) -> MonochangeResult<ChangesetScopedLintSettings> {
Ok(ChangesetScopedLintSettings {
required_sections: lint_string_list_option(rule_id, config, "required_sections")?
.unwrap_or_default(),
min_body_chars: lint_usize_option(rule_id, config, "min_body_chars")?,
max_body_chars: lint_usize_option(rule_id, config, "max_body_chars")?,
require_code_block: lint_bool_option(rule_id, config, "require_code_block")?
.unwrap_or(false),
required_bump: lint_bump_option(rule_id, config, "required_bump")?,
forbidden_headings: lint_string_list_option(rule_id, config, "forbidden_headings")?
.unwrap_or_default(),
})
}
fn lint_bool_option(
rule_id: &str,
config: &LintRuleConfig,
key: &str,
) -> MonochangeResult<Option<bool>> {
let Some(value) = config.option(key) else {
return Ok(None);
};
value.as_bool().map(Some).ok_or_else(|| {
MonochangeError::Config(format!("[lints.rules].{rule_id}.{key} must be a boolean"))
})
}
fn lint_usize_option(
rule_id: &str,
config: &LintRuleConfig,
key: &str,
) -> MonochangeResult<Option<usize>> {
let Some(value) = config.option(key) else {
return Ok(None);
};
let Some(value) = value.as_u64().and_then(|value| usize::try_from(value).ok()) else {
return Err(MonochangeError::Config(format!(
"[lints.rules].{rule_id}.{key} must be a non-negative integer"
)));
};
Ok(Some(value))
}
fn lint_string_list_option(
rule_id: &str,
config: &LintRuleConfig,
key: &str,
) -> MonochangeResult<Option<Vec<String>>> {
let Some(value) = config.option(key) else {
return Ok(None);
};
let Some(values) = value.as_array() else {
return Err(MonochangeError::Config(format!(
"[lints.rules].{rule_id}.{key} must be a string array"
)));
};
let mut strings = Vec::new();
for value in values {
let Some(value) = value.as_str() else {
return Err(MonochangeError::Config(format!(
"[lints.rules].{rule_id}.{key} must be a string array"
)));
};
strings.push(value.to_string());
}
Ok(Some(strings))
}
fn lint_bump_option(
rule_id: &str,
config: &LintRuleConfig,
key: &str,
) -> MonochangeResult<Option<BumpSeverity>> {
let Some(value) = config.option(key) else {
return Ok(None);
};
let Some(value) = value.as_str() else {
return Err(MonochangeError::Config(format!(
"[lints.rules].{rule_id}.{key} must be a bump severity string"
)));
};
parse_bump_severity(value).map(Some).ok_or_else(|| {
MonochangeError::Config(format!(
"[lints.rules].{rule_id}.{key} uses an unknown bump severity"
))
})
}
#[allow(dead_code)]
fn lint_markdown_changeset(
body: &str,
changes: &[RawChangeEntry],
settings: &ChangesetLintSettings,
changes_path: &Path,
) -> MonochangeResult<()> {
lint_markdown_summary(body, settings, changes_path)?;
if settings.no_section_headings {
lint_markdown_no_section_headings(body, changes, changes_path)?;
}
for change in changes {
if let Some(bump) = change.bump
&& let Some(scoped) = settings.bump.get(&bump)
{
lint_markdown_scope(body, change, scoped, changes_path)?;
}
if let Some(change_type) = &change.change_type
&& let Some(scoped) = changeset_type_lint_settings(settings, change_type)
{
lint_markdown_scope(body, change, scoped, changes_path)?;
}
}
Ok(())
}
#[allow(dead_code)]
fn lint_markdown_no_section_headings(
body: &str,
changes: &[RawChangeEntry],
changes_path: &Path,
) -> MonochangeResult<()> {
let change_types = changes
.iter()
.filter_map(|change| change.change_type.as_deref())
.collect::<BTreeSet<_>>();
for change_type in change_types {
if markdown_has_heading(body, change_type) {
return Err(changeset_lint_error(
changes_path,
format!("changeset type `{change_type}` must not also be used as a heading"),
));
}
}
Ok(())
}
#[allow(dead_code)]
fn changeset_type_lint_settings<'settings>(
settings: &'settings ChangesetLintSettings,
change_type: &str,
) -> Option<&'settings ChangesetScopedLintSettings> {
settings.types.get(change_type).or_else(|| {
settings.types.iter().find_map(|(configured_type, scoped)| {
configured_type
.eq_ignore_ascii_case(change_type)
.then_some(scoped)
})
})
}
#[allow(dead_code)]
fn lint_markdown_summary(
body: &str,
settings: &ChangesetLintSettings,
changes_path: &Path,
) -> MonochangeResult<()> {
let summary_settings = &settings.summary;
let Some(first_line) = first_non_empty_line(body) else {
if summary_settings.required {
return Err(changeset_lint_error(
changes_path,
"changeset body must start with a summary heading",
));
}
return Ok(());
};
let heading_level = markdown_heading_level(first_line);
if summary_settings.required && heading_level.is_none() {
return Err(changeset_lint_error(
changes_path,
"changeset body must start with a summary heading",
));
}
if let (Some(required_level), Some(actual_level)) =
(summary_settings.heading_level, heading_level)
&& actual_level != required_level
{
return Err(changeset_lint_error(
changes_path,
format!(
"changeset summary heading must use level {required_level}, found level {actual_level}"
),
));
}
let summary =
markdown_heading_text(first_line).unwrap_or_else(|| first_line.trim().to_string());
if let Some(min_length) = summary_settings.min_length
&& summary.chars().count() < min_length
{
return Err(changeset_lint_error(
changes_path,
format!("changeset summary must be at least {min_length} characters"),
));
}
if let Some(max_length) = summary_settings.max_length
&& summary.chars().count() > max_length
{
return Err(changeset_lint_error(
changes_path,
format!("changeset summary must be at most {max_length} characters"),
));
}
if summary_settings.forbid_trailing_period && summary.ends_with('.') {
return Err(changeset_lint_error(
changes_path,
"changeset summary must not end with a period",
));
}
if summary_settings.forbid_conventional_commit_prefix
&& has_conventional_commit_prefix(&summary)
{
return Err(changeset_lint_error(
changes_path,
"changeset summary must not use a conventional-commit prefix",
));
}
Ok(())
}
#[allow(dead_code)]
fn lint_markdown_scope(
body: &str,
change: &RawChangeEntry,
settings: &ChangesetScopedLintSettings,
changes_path: &Path,
) -> MonochangeResult<()> {
if let Some(required_bump) = settings.required_bump
&& change.bump != Some(required_bump)
{
let actual = change
.bump
.map_or_else(|| "auto".to_string(), |bump| bump.to_string());
return Err(changeset_lint_error(
changes_path,
format!(
"changeset type `{}` requires bump `{required_bump}`, found `{actual}`",
change.change_type.as_deref().unwrap_or("<unknown>")
),
));
}
for section in &settings.required_sections {
if !markdown_has_heading(body, section) {
return Err(changeset_lint_error(
changes_path,
format!("changeset must include a `{section}` section"),
));
}
}
for heading in &settings.forbidden_headings {
if markdown_has_heading(body, heading) {
return Err(changeset_lint_error(
changes_path,
format!("changeset must not use `{heading}` as a heading"),
));
}
}
if let Some(min_body_chars) = settings.min_body_chars
&& body.trim().chars().count() < min_body_chars
{
return Err(changeset_lint_error(
changes_path,
format!("changeset body must be at least {min_body_chars} characters"),
));
}
if let Some(max_body_chars) = settings.max_body_chars
&& body.trim().chars().count() > max_body_chars
{
return Err(changeset_lint_error(
changes_path,
format!("changeset body must be at most {max_body_chars} characters"),
));
}
if settings.require_code_block && !markdown_has_code_block(body) {
return Err(changeset_lint_error(
changes_path,
"changeset must include a fenced code block",
));
}
Ok(())
}
pub(crate) fn first_non_empty_line(markdown: &str) -> Option<&str> {
markdown.lines().find_map(|line| {
let trimmed = line.trim();
(!trimmed.is_empty()).then_some(trimmed)
})
}
pub(crate) fn markdown_heading_text(line: &str) -> Option<String> {
let level = markdown_heading_level(line)?;
let text = line
.trim_start()
.chars()
.skip(level)
.collect::<String>()
.trim()
.trim_end_matches('#')
.trim()
.to_string();
Some(text)
}
pub(crate) fn markdown_has_heading(markdown: &str, heading: &str) -> bool {
markdown.lines().any(|line| {
markdown_heading_text(line).is_some_and(|text| text.eq_ignore_ascii_case(heading.trim()))
})
}
pub(crate) fn markdown_has_code_block(markdown: &str) -> bool {
markdown.lines().any(|line| {
let trimmed = line.trim_start();
trimmed.starts_with("```") || trimmed.starts_with("~~~")
})
}
pub(crate) fn has_conventional_commit_prefix(summary: &str) -> bool {
let Some((prefix, _)) = summary.split_once(':') else {
return false;
};
let prefix = prefix.trim();
let kind = prefix.split('(').next().unwrap_or(prefix);
matches!(
kind,
"build" | "chore" | "ci" | "docs" | "feat" | "fix" | "perf" | "refactor" | "style" | "test"
)
}
#[allow(dead_code)]
fn changeset_lint_error(path: &Path, message: impl Into<String>) -> MonochangeError {
MonochangeError::Config(format!(
"changeset lint failed for {}: {}",
path.display(),
message.into()
))
}
fn configured_change_sections<'config>(
configuration: &'config WorkspaceConfiguration,
_target: &str,
) -> &'config ChangelogSettings {
&configuration.changelog
}
fn configured_change_type_default_bump(
configuration: &WorkspaceConfiguration,
target: &str,
change_type: &str,
) -> Option<BumpSeverity> {
let excluded = configured_excluded_types(configuration, target);
if excluded.contains(&change_type) {
return None;
}
configured_change_sections(configuration, target)
.types
.get(change_type)
.map(|typ| typ.bump)
}
fn configured_change_types(configuration: &WorkspaceConfiguration, target: &str) -> Vec<String> {
let excluded = configured_excluded_types(configuration, target);
configured_change_sections(configuration, target)
.types
.keys()
.filter(|key| !excluded.contains(&key.as_str()))
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn configured_excluded_types<'a>(
configuration: &'a WorkspaceConfiguration,
target: &str,
) -> Vec<&'a str> {
if let Some(package) = configuration.package_by_id(target) {
return package
.excluded_changelog_types
.iter()
.map(String::as_str)
.collect();
}
if let Some(group) = configuration.group_by_id(target) {
return group
.excluded_changelog_types
.iter()
.map(String::as_str)
.collect();
}
Vec::new()
}
fn validate_configured_change_type(
configuration: &WorkspaceConfiguration,
changes_path: &Path,
target: &str,
change_type: &str,
) -> MonochangeResult<()> {
if configuration.package_by_id(target).is_none() && configuration.group_by_id(target).is_none()
{
return Ok(());
}
let valid_types = configured_change_types(configuration, target);
if valid_types.iter().any(|candidate| candidate == change_type) {
return Ok(());
}
let valid_types_help = if valid_types.is_empty() {
"no configured types are available for this target".to_string()
} else {
format!("valid types: {}", valid_types.join(", "))
};
Err(MonochangeError::Config(format!(
"failed to parse {}: target `{target}` has invalid type `{change_type}`; {valid_types_help}",
changes_path.display()
)))
}
fn parse_markdown_change_target(
value: &serde_yaml_ng::Value,
changes_path: &Path,
package: &str,
configuration: &WorkspaceConfiguration,
) -> MonochangeResult<ParsedMarkdownChangeTarget> {
if let Some(token) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Some(default_bump) =
configured_change_type_default_bump(configuration, package, token)
{
return Ok((
Some(default_bump),
None,
Some(token.to_string()),
Vec::new(),
));
}
if configuration.package_by_id(package).is_some()
|| configuration.group_by_id(package).is_some()
{
let valid_types = configured_change_types(configuration, package);
let valid_types_help = if valid_types.is_empty() {
"no configured types are available for this target".to_string()
} else {
format!("valid types: {}", valid_types.join(", "))
};
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` has invalid scalar change type `{token}`; {valid_types_help}",
changes_path.display()
)));
}
return Ok((None, None, Some(token.to_string()), Vec::new()));
}
let Some(mapping) = value.as_mapping() else {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` must map to a configured change type or to a table with `bump`, `version`, `type`, and/or `caused_by`",
changes_path.display()
)));
};
let allowed_keys = ["bump", "version", "type", "caused_by"];
let unknown_keys = mapping
.keys()
.filter_map(serde_yaml_ng::Value::as_str)
.filter(|key| !allowed_keys.contains(key))
.collect::<Vec<_>>();
if !unknown_keys.is_empty() {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` uses unsupported field(s): {}",
changes_path.display(),
unknown_keys.join(", ")
)));
}
let bump = mapping
.get(serde_yaml_ng::Value::String("bump".to_string()))
.and_then(serde_yaml_ng::Value::as_str)
.map(|value| {
parse_bump_severity(value).ok_or_else(|| {
MonochangeError::Config(format!(
"failed to parse {}: target `{package}` has invalid bump `{value}`; expected `none`, `patch`, `minor`, or `major`",
changes_path.display()
))
})
})
.transpose()?;
let version = mapping
.get(serde_yaml_ng::Value::String("version".to_string()))
.and_then(serde_yaml_ng::Value::as_str)
.map(|value| {
Version::parse(value).map_err(|error| {
MonochangeError::Config(format!(
"failed to parse {}: target `{package}` has invalid version `{value}`: {error}",
changes_path.display()
))
})
})
.transpose()?;
let change_type = mapping
.get(serde_yaml_ng::Value::String("type".to_string()))
.and_then(serde_yaml_ng::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let caused_by = parse_caused_by_refs(
mapping.get(serde_yaml_ng::Value::String("caused_by".to_string())),
changes_path,
package,
|reference| {
configuration.package_by_id(reference).is_some()
|| configuration.group_by_id(reference).is_some()
},
)?;
if let Some(change_type) = change_type.as_deref() {
validate_configured_change_type(configuration, changes_path, package, change_type)?;
}
if bump.is_none() && version.is_none() && change_type.is_none() {
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` must declare `bump`, `version`, `type`, or a valid scalar shorthand",
changes_path.display()
)));
}
if bump == Some(BumpSeverity::None)
&& version.is_none()
&& change_type.is_none()
&& caused_by.is_empty()
{
return Err(MonochangeError::Config(format!(
"failed to parse {}: target `{package}` must not use `bump = \"none\"` without also declaring `type`, `version`, or `caused_by`",
changes_path.display()
)));
}
Ok((bump, version, change_type, caused_by))
}
pub(crate) fn parse_bump_severity(value: &str) -> Option<BumpSeverity> {
match value {
"none" => Some(BumpSeverity::None),
"major" => Some(BumpSeverity::Major),
"minor" => Some(BumpSeverity::Minor),
"patch" => Some(BumpSeverity::Patch),
_ => None,
}
}
fn validate_package_and_group_definitions(
root: &Path,
config_contents: &str,
packages: &[PackageDefinition],
groups: &[GroupDefinition],
) -> MonochangeResult<()> {
let mut ids = BTreeSet::new();
let mut package_paths = BTreeMap::<PathBuf, String>::new();
let mut primary_owner = Option::<String>::None;
for package in packages {
if !ids.insert(package.id.clone()) {
return Err(config_diagnostic(
config_contents,
format!("duplicate package id `{}`", package.id),
vec![config_section_label(
config_contents,
"package",
&package.id,
"duplicate package id",
)],
Some("rename the package id so every [package.<id>] entry is unique".to_string()),
));
}
let resolved_path = root.join(&package.path);
if !resolved_path.exists() {
return Err(config_diagnostic(
config_contents,
format!(
"package `{}` path `{}` does not exist",
package.id,
package.path.display()
),
vec![config_field_label(
config_contents,
"package",
&package.id,
"path",
"missing package path",
)],
Some(
"create the package directory or update `path` to the correct package root"
.to_string(),
),
));
}
if let Some(existing_id) = package_paths.insert(package.path.clone(), package.id.clone()) {
return Err(config_diagnostic(
config_contents,
format!(
"package path `{}` is already used by `{existing_id}`",
package.path.display()
),
vec![
config_section_label(
config_contents,
"package",
&existing_id,
"first package using this path",
),
config_section_label(
config_contents,
"package",
&package.id,
"conflicting package declaration",
),
],
Some("declare each package path exactly once".to_string()),
));
}
let expected_manifest = resolved_path.join(expected_manifest_name(package.package_type));
if !expected_manifest.exists() {
return Err(config_diagnostic(
config_contents,
format!(
"package `{}` is missing expected {} manifest at {}",
package.id,
package.package_type.as_str(),
expected_manifest.display()
),
vec![config_section_label(
config_contents,
"package",
&package.id,
"declared package",
)],
Some(format!(
"add `{}` under `{}` or change the package type",
expected_manifest_name(package.package_type),
package.path.display()
)),
));
}
if package.version_format == VersionFormat::Primary {
assign_primary_release_owner(config_contents, &mut primary_owner, &package.id)?;
}
}
let declared_packages = packages
.iter()
.map(|package| package.id.as_str())
.collect::<BTreeSet<_>>();
for package in packages {
validate_versioned_files(
root,
config_contents,
&package.versioned_files,
&declared_packages,
"package",
&package.id,
)?;
}
let mut assigned_packages = BTreeMap::<String, String>::new();
for group in groups {
validate_versioned_files(
root,
config_contents,
&group.versioned_files,
&declared_packages,
"group",
&group.id,
)?;
if !ids.insert(group.id.clone()) {
return Err(config_diagnostic(
config_contents,
format!(
"group `{}` collides with an existing package or group id",
group.id
),
vec![config_section_label(
config_contents,
"group",
&group.id,
"conflicting group id",
)],
Some("package and group ids share one namespace; rename one of them".to_string()),
));
}
if group.version_format == VersionFormat::Primary {
assign_primary_release_owner(config_contents, &mut primary_owner, &group.id)?;
}
for package_id in &group.packages {
if !declared_packages.contains(package_id.as_str()) {
return Err(config_diagnostic(
config_contents,
format!("group `{}` references unknown package `{package_id}`", group.id),
vec![config_group_member_label(
config_contents,
&group.id,
package_id,
"unknown package reference",
)],
Some("declare the package first under [package.<id>] before referencing it from a group".to_string()),
));
}
if let Some(existing_group) =
assigned_packages.insert(package_id.clone(), group.id.clone())
{
return Err(config_diagnostic(
config_contents,
format!(
"package `{package_id}` belongs to multiple groups: `{existing_group}` and `{}`",
group.id
),
vec![
config_group_member_label(
config_contents,
&existing_group,
package_id,
"first group membership",
),
config_group_member_label(
config_contents,
&group.id,
package_id,
"conflicting group membership",
),
],
Some("move the package into exactly one [group.<id>] declaration".to_string()),
));
}
}
}
Ok(())
}
fn validate_cli_input_default(
cli_command: &CliCommandDefinition,
input: &CliInputDefinition,
default: &str,
) -> MonochangeResult<()> {
if matches!(input.kind, CliInputKind::Choice)
&& !input.choices.iter().any(|choice| choice == default)
{
return Err(MonochangeError::Config(format!(
"CLI command `{}` input `{}` default `{default}` is not one of the configured choices",
cli_command.name, input.name
)));
}
if matches!(input.kind, CliInputKind::Boolean) && default != "true" && default != "false" {
return Err(MonochangeError::Config(format!(
"CLI command `{}` input `{}` boolean default must be `true` or `false`",
cli_command.name, input.name
)));
}
Ok(())
}
fn validate_affected_packages_step_enabled(
cli_command: &CliCommandDefinition,
verify_enabled: bool,
) -> MonochangeResult<()> {
if verify_enabled {
return Ok(());
}
Err(MonochangeError::Config(format!(
"CLI command `{}` uses `AffectedPackages` but `[changesets.affected].enabled` is false",
cli_command.name
)))
}
fn validate_command_step_definition(
cli_command: &CliCommandDefinition,
command: &str,
dry_run_command: Option<&str>,
step_id: Option<&str>,
seen_step_ids: &mut BTreeSet<String>,
) -> MonochangeResult<()> {
if let Some(step_id) = step_id {
let trimmed = step_id.trim();
if trimmed.is_empty() {
return Err(MonochangeError::Config(format!(
"CLI command `{}` has a command step with an empty id",
cli_command.name
)));
}
if !seen_step_ids.insert(trimmed.to_string()) {
return Err(MonochangeError::Config(format!(
"CLI command `{}` has duplicate step id `{trimmed}`",
cli_command.name
)));
}
}
if command.trim().is_empty() {
return Err(MonochangeError::Config(format!(
"CLI command `{}` command steps must provide a non-empty command",
cli_command.name
)));
}
if matches!(dry_run_command, Some(value) if value.trim().is_empty()) {
return Err(MonochangeError::Config(format!(
"CLI command `{}` command steps with `dry_run_command` must provide a non-empty command",
cli_command.name
)));
}
Ok(())
}
fn path_uses_glob(path: &str) -> bool {
path.contains('*') || path.contains('?') || path.contains('[')
}
fn path_is_supported_for_ecosystem(path: &Path, ecosystem_type: EcosystemType) -> bool {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
match ecosystem_type {
EcosystemType::Cargo => {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
|| file_name == "Cargo.lock"
}
EcosystemType::Npm => {
matches!(
file_name,
"package.json" | "package-lock.json" | "pnpm-lock.yaml" | "bun.lock" | "bun.lockb"
)
}
EcosystemType::Deno => matches!(file_name, "deno.json" | "deno.jsonc" | "deno.lock"),
EcosystemType::Dart => matches!(file_name, "pubspec.yaml" | "pubspec.yml" | "pubspec.lock"),
EcosystemType::Python => matches!(file_name, "pyproject.toml" | "uv.lock" | "poetry.lock"),
_ => matches!(file_name, "go.mod" | "go.sum"),
}
}
fn source_capabilities(provider: SourceProvider) -> SourceCapabilities {
match provider {
SourceProvider::GitHub => {
SourceCapabilities {
draft_releases: true,
prereleases: true,
generated_release_notes: true,
auto_merge_change_requests: true,
released_issue_comments: true,
requires_host: false,
}
}
SourceProvider::GitLab => {
SourceCapabilities {
draft_releases: false,
prereleases: false,
generated_release_notes: false,
auto_merge_change_requests: false,
released_issue_comments: false,
requires_host: false,
}
}
SourceProvider::Gitea | SourceProvider::Forgejo => {
SourceCapabilities {
draft_releases: true,
prereleases: true,
generated_release_notes: false,
auto_merge_change_requests: false,
released_issue_comments: false,
requires_host: true,
}
}
}
}
fn validate_versioned_files(
root: &Path,
config_contents: &str,
versioned_files: &[VersionedFileDefinition],
declared_packages: &BTreeSet<&str>,
owner_kind: &str,
owner_id: &str,
) -> MonochangeResult<()> {
for versioned_file in versioned_files {
if let Some(regex) = versioned_file.regex.as_deref() {
validate_regex_versioned_file(
config_contents,
versioned_file,
owner_kind,
owner_id,
regex,
)?;
continue;
}
let Some(ecosystem_type) = versioned_file.ecosystem_type else {
return Err(config_diagnostic(
config_contents,
format!(
"{owner_kind} `{owner_id}` versioned_files must set `type` unless they use `regex` or package-scoped shorthand"
),
vec![config_section_label(
config_contents,
owner_kind,
owner_id,
"versioned_files entry is missing `type`",
)],
Some("set `type = \"cargo\"` (or another ecosystem) for ecosystem-aware file updates, or add `regex = '...'` for plain-text replacement".to_string()),
));
};
if let Some(name) = &versioned_file.name
&& !declared_packages.contains(name.as_str())
{
return Err(config_diagnostic(
config_contents,
format!(
"{owner_id} references unknown versioned file name `{name}`"
),
vec![config_dependency_label(
config_contents,
owner_kind,
owner_id,
name,
"unknown versioned file name",
)],
Some("reference a declared package id from `versioned_files` or remove the name entry".to_string()),
));
}
if path_uses_glob(&versioned_file.path) {
let pattern = root
.join(&versioned_file.path)
.to_string_lossy()
.to_string();
let matches = glob::glob(&pattern)
.map_err(|error| {
MonochangeError::Config(format!(
"invalid glob pattern `{}`: {error}",
versioned_file.path
))
})?
.filter_map(Result::ok)
.collect::<Vec<_>>();
if let Some(unsupported_path) = matches
.into_iter()
.find(|matched_path| !path_is_supported_for_ecosystem(matched_path, ecosystem_type))
{
return Err(config_diagnostic(
config_contents,
format!(
"{owner_kind} `{owner_id}` versioned_files glob `{}` matched unsupported file `{}` for ecosystem `{}`",
versioned_file.path,
unsupported_path.display(),
match ecosystem_type {
EcosystemType::Cargo => "cargo",
EcosystemType::Npm => "npm",
EcosystemType::Deno => "deno",
EcosystemType::Dart => "dart",
EcosystemType::Python => "python",
EcosystemType::Go => "go",
_ => "unknown",
}
),
vec![config_section_label(
config_contents,
owner_kind,
owner_id,
"versioned_files glob matched unsupported file type",
)],
Some("narrow the glob so it only matches files for that ecosystem, or change the `type` to match the files you want to update".to_string()),
));
}
}
}
Ok(())
}
fn validate_lockfile_commands(
root: &Path,
ecosystem_id: &str,
lockfile_commands: &[LockfileCommandDefinition],
) -> MonochangeResult<()> {
for lockfile_command in lockfile_commands {
if lockfile_command.command.trim().is_empty() {
return Err(MonochangeError::Config(format!(
"ecosystem `{ecosystem_id}` lockfile_commands must provide a non-empty command"
)));
}
let Some(cwd) = &lockfile_command.cwd else {
continue;
};
if cwd.as_os_str().is_empty() {
return Err(MonochangeError::Config(format!(
"ecosystem `{ecosystem_id}` lockfile_commands must provide a non-empty cwd when set"
)));
}
let resolved = if cwd.is_absolute() {
cwd.clone()
} else {
root.join(cwd)
};
if !resolved.starts_with(root) {
return Err(MonochangeError::Config(format!(
"ecosystem `{ecosystem_id}` lockfile_commands cwd `{}` must stay within the workspace root",
cwd.display()
)));
}
if !resolved.is_dir() {
return Err(MonochangeError::Config(format!(
"ecosystem `{ecosystem_id}` lockfile_commands cwd `{}` does not exist or is not a directory",
cwd.display()
)));
}
}
Ok(())
}
#[allow(clippy::match_same_arms)]
fn expected_manifest_name(package_type: PackageType) -> &'static str {
match package_type {
PackageType::Cargo => "Cargo.toml",
PackageType::Npm => "package.json",
PackageType::Deno => "deno.json",
PackageType::Dart | PackageType::Flutter => "pubspec.yaml",
PackageType::Python => "pyproject.toml",
PackageType::Go => "go.mod",
_ => "Cargo.toml",
}
}
fn build_changelog_settings(raw: RawChangelogSettings) -> ChangelogSettings {
if raw.sections.is_empty() && raw.types.is_empty() && raw.templates.is_empty() {
let mut defaults = ChangelogSettings::defaults();
defaults.section_thresholds = raw.section_thresholds;
defaults
} else {
ChangelogSettings {
templates: raw.templates,
sections: raw.sections,
section_thresholds: raw.section_thresholds,
types: raw.types,
}
}
}
fn validate_changelog_configuration(
contents: &str,
changelog: &RawChangelogSettings,
packages: &[PackageDefinition],
groups: &[GroupDefinition],
) -> MonochangeResult<()> {
for template in &changelog.templates {
if template.trim().is_empty() {
return Err(MonochangeError::Config(
"[changelog].templates must not include empty templates".to_string(),
));
}
let unsupported_variables = change_template_variables(template)
.into_iter()
.filter(|variable| !SUPPORTED_CHANGE_TEMPLATE_VARIABLES.contains(&variable.as_str()))
.collect::<BTreeSet<_>>();
if !unsupported_variables.is_empty() {
return Err(MonochangeError::Config(format!(
"[changelog].templates uses unsupported variables: {}",
unsupported_variables
.into_iter()
.collect::<Vec<_>>()
.join(", ")
)));
}
}
validate_changelog_keys(contents, "section", &changelog.sections)?;
validate_changelog_keys(contents, "type", &changelog.types)?;
if changelog.section_thresholds.ignored < changelog.section_thresholds.collapse {
return Err(MonochangeError::Config(
"[changelog].section_thresholds.ignored must be greater than or equal to [changelog].section_thresholds.collapse".to_string(),
));
}
let config_document = if changelog.types.is_empty() {
None
} else {
Some(toml::from_str::<toml::Value>(contents).map_err(|error| {
MonochangeError::Config(format!("failed to parse monochange.toml: {error}"))
})?)
};
for (type_key, typ) in &changelog.types {
if config_document
.as_ref()
.is_some_and(|document| !raw_changelog_type_has_field(document, type_key, "bump"))
{
return Err(MonochangeError::Config(format!(
"[changelog].types.{type_key} must declare a `bump` default (`none`, `patch`, `minor`, or `major`)"
)));
}
if !changelog.sections.contains_key(&typ.section) {
return Err(MonochangeError::Config(format!(
"[changelog].types.{type_key} references section `{}` which does not exist in [changelog.sections]",
typ.section
)));
}
}
for package in packages {
for excluded in &package.excluded_changelog_types {
if !changelog.types.contains_key(excluded) {
return Err(MonochangeError::Config(format!(
"package `{}` excludes changelog type `{}` which does not exist in [changelog.types]",
package.id, excluded
)));
}
}
}
for group in groups {
for excluded in &group.excluded_changelog_types {
if !changelog.types.contains_key(excluded) {
return Err(MonochangeError::Config(format!(
"group `{}` excludes changelog type `{}` which does not exist in [changelog.types]",
group.id, excluded
)));
}
}
}
Ok(())
}
fn raw_changelog_type_has_field(document: &toml::Value, type_key: &str, field: &str) -> bool {
document
.get("changelog")
.and_then(|changelog| changelog.get("types"))
.and_then(|types| types.get(type_key))
.and_then(toml::Value::as_table)
.is_some_and(|type_config| type_config.contains_key(field))
}
fn validate_changelog_keys<K, V>(
contents: &str,
key_kind: &str,
map: &BTreeMap<K, V>,
) -> MonochangeResult<()>
where
K: AsRef<str>,
{
for key in map.keys() {
let key_str = key.as_ref();
validate_identifier_key(contents, key_kind, key_str)?;
}
Ok(())
}
fn validate_identifier_key(_contents: &str, key_kind: &str, key: &str) -> MonochangeResult<()> {
if key.is_empty() {
return Err(MonochangeError::Config(format!(
"[changelog].{key_kind}s key must not be empty"
)));
}
let first = key.chars().next().unwrap();
if !first.is_ascii_lowercase() {
if first.is_ascii_uppercase() {
return Err(MonochangeError::Config(format!(
"[changelog].{key_kind}s key `{key}` must be lowercase; use `{}` instead",
key.to_ascii_lowercase()
)));
}
return Err(MonochangeError::Config(format!(
"[changelog].{key_kind}s key `{key}` must start with a letter (a-z)"
)));
}
for ch in key.chars() {
if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
if ch.is_ascii_uppercase() {
return Err(MonochangeError::Config(format!(
"[changelog].{key_kind}s key `{key}` must be lowercase; use `{}` instead",
key.to_ascii_lowercase()
)));
}
return Err(MonochangeError::Config(format!(
"[changelog].{key_kind}s key `{key}` contains invalid character `{ch}`; only lowercase letters, digits, and underscores are allowed"
)));
}
}
let underscore_count = key.chars().filter(|c| *c == '_').count();
if underscore_count > 1 {
return Err(MonochangeError::Config(format!(
"[changelog].{key_kind}s key `{key}` must contain at most one underscore; use a single word or `word_word` format"
)));
}
Ok(())
}
fn change_template_variables(template: &str) -> Vec<String> {
let mut variables = BTreeSet::new();
let mut remaining = template;
while let Some(start) = remaining.find("{{") {
let after_open = &remaining[start + 2..];
let Some(end) = after_open.find("}}") else {
break;
};
let expression = after_open[..end].trim();
let variable: String = expression
.chars()
.take_while(|character| character.is_ascii_alphanumeric() || *character == '_')
.collect();
if !variable.is_empty() {
variables.insert(variable);
}
remaining = &after_open[end + 2..];
}
variables.into_iter().collect()
}
fn validate_source_configuration(source: Option<&SourceConfiguration>) -> MonochangeResult<()> {
let Some(source) = source else {
return Ok(());
};
if source.owner.trim().is_empty() {
return Err(MonochangeError::Config(
"[source].owner must not be empty".to_string(),
));
}
if source.repo.trim().is_empty() {
return Err(MonochangeError::Config(
"[source].repo must not be empty".to_string(),
));
}
if source.pull_requests.branch_prefix.trim().is_empty() {
return Err(MonochangeError::Config(
"[source.pull_requests].branch_prefix must not be empty".to_string(),
));
}
if source.pull_requests.base.trim().is_empty() {
return Err(MonochangeError::Config(
"[source.pull_requests].base must not be empty".to_string(),
));
}
if source.pull_requests.title.trim().is_empty() {
return Err(MonochangeError::Config(
"[source.pull_requests].title must not be empty".to_string(),
));
}
if source.releases.branches.is_empty() {
return Err(MonochangeError::Config(
"[source.releases].branches must contain at least one branch pattern".to_string(),
));
}
if source
.releases
.branches
.iter()
.any(|branch| branch.trim().is_empty())
{
return Err(MonochangeError::Config(
"[source.releases].branches must not include empty values".to_string(),
));
}
if source
.releases
.attestations
.require_github_artifact_attestations
&& source.provider != SourceProvider::GitHub
{
return Err(MonochangeError::Config(
"[source.releases.attestations].require_github_artifact_attestations requires [source].provider = \"github\""
.to_string(),
));
}
if source
.pull_requests
.labels
.iter()
.any(|label| label.trim().is_empty())
{
return Err(MonochangeError::Config(
"[source.pull_requests].labels must not include empty values".to_string(),
));
}
if let Some(api_url) = &source.api_url {
validate_api_url_host(api_url, source.provider)?;
}
if let Some(host) = &source.host {
validate_api_url_host(host, source.provider)?;
}
validate_source_provider_capabilities(source)
}
fn validate_source_provider_capabilities(source: &SourceConfiguration) -> MonochangeResult<()> {
let capabilities = source_capabilities(source.provider);
if capabilities.requires_host && source.host.as_deref().is_none_or(str::is_empty) {
return Err(MonochangeError::Config(format!(
"[source].host must be set for `provider = \"{}\"`",
source.provider
)));
}
if source.releases.draft && !capabilities.draft_releases {
return Err(MonochangeError::Config(format!(
"[source.releases].draft is not supported for `provider = \"{}\"`",
source.provider
)));
}
if source.releases.prerelease && !capabilities.prereleases {
return Err(MonochangeError::Config(format!(
"[source.releases].prerelease is not supported for `provider = \"{}\"`",
source.provider
)));
}
if source.releases.generate_notes && !capabilities.generated_release_notes {
return Err(MonochangeError::Config(format!(
"provider-generated release notes are not supported for `provider = \"{}\"`; use `source = \"monochange\"`",
source.provider
)));
}
if source.releases.generate_notes
&& matches!(
source.releases.source,
ProviderReleaseNotesSource::Monochange
) {
return Err(MonochangeError::Config(
"[source.releases].generate_notes cannot be true when `source = \"monochange\"`; choose one release-note source"
.to_string(),
));
}
if source.pull_requests.auto_merge && !capabilities.auto_merge_change_requests {
return Err(MonochangeError::Config(format!(
"[source.pull_requests].auto_merge is not supported for `provider = \"{}\"`",
source.provider
)));
}
Ok(())
}
fn validate_api_url_host(url: &str, provider: SourceProvider) -> MonochangeResult<()> {
let lower = url.to_lowercase();
if lower.starts_with("http://") {
return Err(MonochangeError::Config(format!(
"[source] url `{url}` uses an insecure scheme (http://); \
API tokens would be transmitted in cleartext — use https:// instead"
)));
}
if provider == SourceProvider::GitHub && lower.starts_with("https://") {
let without_scheme = &lower["https://".len()..];
let host_part = without_scheme.split('/').next().unwrap_or("");
let is_standard = host_part == "api.github.com"
|| host_part.ends_with(".github.com")
|| host_part.ends_with(".githubusercontent.com");
if !is_standard {
eprintln!(
"warning: [source] url points to non-standard GitHub host `{url}`; \
verify this is intentional — API tokens will be sent to this host"
);
}
}
Ok(())
}
fn validate_changesets_configuration(
changesets: &ChangesetSettings,
packages: &[PackageDefinition],
) -> MonochangeResult<()> {
if changesets
.affected
.skip_labels
.iter()
.any(|label| label.trim().is_empty())
{
return Err(MonochangeError::Config(
"[changesets.affected].skip_labels must not include empty values".to_string(),
));
}
for (field, patterns) in [
(
"[changesets.affected].changed_paths",
&changesets.affected.changed_paths,
),
(
"[changesets.affected].ignored_paths",
&changesets.affected.ignored_paths,
),
] {
for pattern in patterns {
if pattern.trim().is_empty() {
return Err(MonochangeError::Config(format!(
"{field} must not include empty values"
)));
}
Pattern::new(pattern).map_err(|error| {
MonochangeError::Config(format!(
"{field} contains invalid glob pattern `{pattern}`: {error}"
))
})?;
}
}
for package in packages {
for (field, patterns) in [
("ignored_paths", &package.ignored_paths),
("additional_paths", &package.additional_paths),
] {
for pattern in patterns {
if pattern.trim().is_empty() {
return Err(MonochangeError::Config(format!(
"[package.{}].{field} must not include empty values",
package.id
)));
}
Pattern::new(pattern).map_err(|error| {
MonochangeError::Config(format!(
"[package.{}].{field} contains invalid glob pattern `{pattern}`: {error}",
package.id
))
})?;
}
}
}
Ok(())
}
fn validate_changeset_lint_settings(
settings: &ChangesetLintSettings,
changelog: &ChangelogSettings,
) -> MonochangeResult<()> {
if let Some(level) = settings.summary.heading_level
&& !(1..=6).contains(&level)
{
return Err(MonochangeError::Config(
"[lints.rules].changesets/summary.heading_level must be between 1 and 6".to_string(),
));
}
if let (Some(min_length), Some(max_length)) =
(settings.summary.min_length, settings.summary.max_length)
&& min_length > max_length
{
return Err(MonochangeError::Config(
"[lints.rules].changesets/summary.min_length must not exceed max_length".to_string(),
));
}
for (bump, scoped) in &settings.bump {
validate_changeset_scoped_lint_settings(
scoped,
&format!("[lints.rules].changesets/bump/{bump}"),
)?;
}
for (change_type, scoped) in &settings.types {
if !changelog
.types
.keys()
.any(|configured| configured.eq_ignore_ascii_case(change_type))
{
return Err(MonochangeError::Config(format!(
"[lints.rules].changesets/types/{change_type} references an unknown changeset type"
)));
}
validate_changeset_scoped_lint_settings(
scoped,
&format!("[lints.rules].changesets/types/{change_type}"),
)?;
}
Ok(())
}
fn validate_changeset_scoped_lint_settings(
settings: &ChangesetScopedLintSettings,
path: &str,
) -> MonochangeResult<()> {
if let (Some(min_body_chars), Some(max_body_chars)) =
(settings.min_body_chars, settings.max_body_chars)
&& min_body_chars > max_body_chars
{
return Err(MonochangeError::Config(format!(
"{path}.min_body_chars must not exceed max_body_chars"
)));
}
if settings
.required_sections
.iter()
.any(|section| section.trim().is_empty())
{
return Err(MonochangeError::Config(format!(
"{path}.required_sections must not include empty values"
)));
}
if settings
.forbidden_headings
.iter()
.any(|heading| heading.trim().is_empty())
{
return Err(MonochangeError::Config(format!(
"{path}.forbidden_headings must not include empty values"
)));
}
Ok(())
}
#[allow(clippy::match_same_arms)]
fn validate_regex_versioned_file(
config_contents: &str,
versioned_file: &VersionedFileDefinition,
owner_kind: &str,
owner_id: &str,
regex: &str,
) -> MonochangeResult<()> {
if versioned_file.ecosystem_type.is_some() {
return Err(config_diagnostic(
config_contents,
format!("{owner_kind} `{owner_id}` regex versioned_files cannot also set `type`"),
vec![config_section_label(
config_contents,
owner_kind,
owner_id,
"regex versioned_files cannot set `type`",
)],
Some("remove `type` when using `regex`; regex versioned_files operate on plain text files without ecosystem-specific parsing".to_string()),
));
}
if versioned_file.prefix.is_some()
|| versioned_file.fields.is_some()
|| versioned_file.name.is_some()
{
return Err(config_diagnostic(
config_contents,
format!(
"{owner_kind} `{owner_id}` regex versioned_files cannot also set `prefix`, `fields`, or `name`"
),
vec![config_section_label(
config_contents,
owner_kind,
owner_id,
"regex versioned_files cannot mix text and dependency settings",
)],
Some("remove `prefix`, `fields`, and `name` when using `regex`; those options only apply to ecosystem-aware manifest updates".to_string()),
));
}
let compiled = Regex::new(regex).map_err(|error| {
config_diagnostic(
config_contents,
format!("{owner_kind} `{owner_id}` regex versioned_files pattern `{regex}` is invalid"),
vec![config_section_label(
config_contents,
owner_kind,
owner_id,
"invalid regex versioned_files pattern",
)],
Some(error.to_string()),
)
})?;
if compiled.capture_names().any(|name| name == Some("version")) {
return Ok(());
}
Err(config_diagnostic(
config_contents,
format!(
"{owner_kind} `{owner_id}` regex versioned_files pattern `{regex}` must include a named `version` capture"
),
vec![config_section_label(
config_contents,
owner_kind,
owner_id,
"regex versioned_files must capture the version",
)],
Some(
"use a named capture like `(?<version>\\d+\\.\\d+\\.\\d+)` so monochange knows which substring to replace"
.to_string(),
),
))
}
fn validate_cli(cli: &[CliCommandDefinition]) -> MonochangeResult<()> {
let mut seen_names = BTreeSet::new();
for cli_command in cli {
if !seen_names.insert(cli_command.name.clone()) {
return Err(MonochangeError::Config(format!(
"duplicate CLI command `{}`",
cli_command.name
)));
}
if RESERVED_CLI_COMMAND_NAMES.contains(&cli_command.name.as_str()) {
return Err(MonochangeError::Config(format!(
"CLI command `{}` collides with a reserved built-in command",
cli_command.name
)));
}
if cli_command.name.starts_with("step:") {
return Err(MonochangeError::Config(format!(
"CLI command `{}` uses reserved `step:` prefix",
cli_command.name
)));
}
if cli_command.steps.is_empty() {
return Err(MonochangeError::Config(format!(
"CLI command `{}` must define at least one step",
cli_command.name
)));
}
let mut seen_inputs = BTreeSet::new();
for input in &cli_command.inputs {
if input.name.trim().is_empty() {
return Err(MonochangeError::Config(format!(
"CLI command `{}` has an input with an empty name",
cli_command.name
)));
}
if !seen_inputs.insert(input.name.clone()) {
return Err(MonochangeError::Config(format!(
"CLI command `{}` defines duplicate input `{}`",
cli_command.name, input.name
)));
}
if matches!(input.name.as_str(), "help" | "dry-run") {
return Err(MonochangeError::Config(format!(
"CLI command `{}` input `{}` collides with an implicit command flag",
cli_command.name, input.name
)));
}
if matches!(input.kind, CliInputKind::Choice) && input.choices.is_empty() {
return Err(MonochangeError::Config(format!(
"CLI command `{}` input `{}` must define at least one choice",
cli_command.name, input.name
)));
}
if let Some(default) = &input.default {
validate_cli_input_default(cli_command, input, default)?;
}
}
let mut seen_step_ids: BTreeSet<String> = BTreeSet::new();
let mut seen_step_names: BTreeSet<String> = BTreeSet::new();
for step in &cli_command.steps {
if let Some(condition) = step.when()
&& condition.trim().is_empty()
{
return Err(MonochangeError::Config(format!(
"CLI command `{}` step `{}` has an empty `when` condition",
cli_command.name,
step.kind_name()
)));
}
if let Some(name) = step.name() {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(MonochangeError::Config(format!(
"CLI command `{}` step `{}` has an empty `name`",
cli_command.name,
step.kind_name()
)));
}
if !seen_step_names.insert(trimmed.to_string()) {
return Err(MonochangeError::Config(format!(
"CLI command `{}` has duplicate step name `{trimmed}`",
cli_command.name
)));
}
}
for input_name in step.inputs().keys() {
if input_name.trim().is_empty() {
return Err(MonochangeError::Config(format!(
"CLI command `{}` step `{}` has an input override with an empty name",
cli_command.name,
step.kind_name()
)));
}
}
if let CliStepDefinition::Command {
command,
dry_run_command,
id,
..
} = step
{
validate_command_step_definition(
cli_command,
command,
dry_run_command.as_deref(),
id.as_deref(),
&mut seen_step_ids,
)?;
}
}
}
Ok(())
}
fn validate_cli_runtime_requirements(
cli: &[CliCommandDefinition],
changesets: &ChangesetSettings,
source: Option<&SourceConfiguration>,
) -> MonochangeResult<()> {
for cli_command in cli {
if cli_command
.steps
.iter()
.any(|step| matches!(step, CliStepDefinition::PublishRelease { .. }))
{
let source = source.ok_or_else(|| {
MonochangeError::Config(format!(
"CLI command `{}` uses `PublishRelease` but `[source]` is not configured",
cli_command.name
))
})?;
if !source.releases.enabled {
return Err(MonochangeError::Config(format!(
"CLI command `{}` uses `PublishRelease` but `[source.releases].enabled` is false",
cli_command.name
)));
}
}
if cli_command
.steps
.iter()
.any(|step| matches!(step, CliStepDefinition::OpenReleaseRequest { .. }))
{
let source = source.ok_or_else(|| {
MonochangeError::Config(format!(
"CLI command `{}` uses `OpenReleaseRequest` but `[source]` is not configured",
cli_command.name
))
})?;
if !source.pull_requests.enabled {
return Err(MonochangeError::Config(format!(
"CLI command `{}` uses `OpenReleaseRequest` but `[source.pull_requests].enabled` is false",
cli_command.name
)));
}
}
if cli_command
.steps
.iter()
.any(|step| matches!(step, CliStepDefinition::CommentReleasedIssues { .. }))
{
let source = source.ok_or_else(|| {
MonochangeError::Config(format!(
"CLI command `{}` uses `CommentReleasedIssues` but `[source]` is not configured",
cli_command.name
))
})?;
if !source_capabilities(source.provider).released_issue_comments {
return Err(MonochangeError::Config(format!(
"CLI command `{}` uses `CommentReleasedIssues` but `[source].provider = \"{}\"` does not support released-issue comments",
cli_command.name, source.provider
)));
}
}
for step in &cli_command.steps {
validate_step_input_overrides(cli_command, step)?;
if let CliStepDefinition::AffectedPackages { inputs, .. } = step {
validate_affected_packages_step_enabled(cli_command, changesets.affected.enabled)?;
let has_changed_paths = inputs.contains_key("changed_paths");
let has_from = inputs.contains_key("from");
if !has_changed_paths && !has_from {
return Err(MonochangeError::Config(format!(
"CLI command `{}` uses `AffectedPackages` but declares neither a `changed_paths` nor a `from` input and does not override either on the step",
cli_command.name
)));
}
validate_step_override_kind(
cli_command,
step,
"changed_paths",
inputs.get("changed_paths"),
false,
)?;
validate_step_override_kind(cli_command, step, "from", inputs.get("from"), false)?;
validate_step_override_kind(
cli_command,
step,
"label",
inputs.get("label"),
false,
)?;
validate_step_override_kind(
cli_command,
step,
"verify",
inputs.get("verify"),
true,
)?;
}
}
}
Ok(())
}
fn cli_command_input<'a>(
cli_command: &'a CliCommandDefinition,
name: &str,
) -> Option<&'a CliInputDefinition> {
cli_command.inputs.iter().find(|input| input.name == name)
}
fn validate_step_override_kind(
cli_command: &CliCommandDefinition,
step: &CliStepDefinition,
input_name: &str,
value: Option<&CliStepInputValue>,
expect_boolean: bool,
) -> MonochangeResult<()> {
let Some(value) = value else {
return Ok(());
};
let valid = matches!(value, CliStepInputValue::Inherited)
|| if expect_boolean {
matches!(
value,
CliStepInputValue::Boolean(_) | CliStepInputValue::String(_)
)
} else {
matches!(
value,
CliStepInputValue::String(_) | CliStepInputValue::List(_)
)
};
if valid {
return Ok(());
}
Err(MonochangeError::Config(format!(
"CLI command `{}` step `{}` override `{}` must use a {} value",
cli_command.name,
step.kind_name(),
input_name,
if expect_boolean {
"boolean or string template"
} else {
"string or string_list value"
}
)))
}
fn cli_input_kind_matches_step_input(actual: CliInputKind, expected: CliInputKind) -> bool {
match expected {
CliInputKind::Boolean => matches!(actual, CliInputKind::Boolean),
CliInputKind::StringList => matches!(actual, CliInputKind::StringList),
CliInputKind::String | CliInputKind::Path | CliInputKind::Choice => {
actual == CliInputKind::String
|| actual == CliInputKind::Path
|| actual == CliInputKind::Choice
}
}
}
fn validate_step_input_overrides(
cli_command: &CliCommandDefinition,
step: &CliStepDefinition,
) -> MonochangeResult<()> {
let overrides = step.inputs();
if overrides.is_empty() {
return Ok(());
}
let valid_names = step.valid_input_names();
for (name, value) in overrides {
if let Some(names) = valid_names
&& !names.contains(&name.as_str())
{
let available = if names.is_empty() {
"this step accepts no inputs".to_string()
} else {
format!("valid inputs: {}", names.join(", "))
};
return Err(MonochangeError::Config(format!(
"CLI command `{}` step `{}` has unknown input override `{}`; {}",
cli_command.name,
step.kind_name(),
name,
available,
)));
}
if matches!(value, CliStepInputValue::Inherited) {
let Some(command_input) = cli_command_input(cli_command, name) else {
return Err(MonochangeError::Config(format!(
"CLI command `{}` step `{}` inherits input `{}` but the command does not declare it",
cli_command.name,
step.kind_name(),
name,
)));
};
if let Some(expected_kind) = step.expected_input_kind(name)
&& !cli_input_kind_matches_step_input(command_input.kind, expected_kind)
{
return Err(MonochangeError::Config(format!(
"CLI command `{}` step `{}` inherits input `{}` with incompatible type `{:?}`; expected `{:?}`",
cli_command.name,
step.kind_name(),
name,
command_input.kind,
expected_kind,
)));
}
continue;
}
if let Some(expected_kind) = step.expected_input_kind(name) {
let type_ok = match expected_kind {
CliInputKind::Boolean => {
matches!(
value,
CliStepInputValue::Boolean(_) | CliStepInputValue::String(_)
)
}
CliInputKind::StringList => {
matches!(
value,
CliStepInputValue::String(_) | CliStepInputValue::List(_)
)
}
CliInputKind::String | CliInputKind::Path | CliInputKind::Choice => {
matches!(value, CliStepInputValue::String(_))
}
};
if !type_ok {
return Err(MonochangeError::Config(format!(
"CLI command `{}` step `{}` override `{}` must use a {} value",
cli_command.name,
step.kind_name(),
name,
match expected_kind {
CliInputKind::Boolean => "boolean or string template",
CliInputKind::StringList => "string or string_list",
CliInputKind::String | CliInputKind::Path | CliInputKind::Choice =>
"string",
}
)));
}
}
}
Ok(())
}
#[allow(clippy::needless_pass_by_value)]
fn config_diagnostic(
config_contents: &str,
message: String,
labels: Vec<LabeledSpan>,
help: Option<String>,
) -> MonochangeError {
MonochangeError::Diagnostic(render_source_diagnostic(
CONFIG_FILE,
config_contents,
&message,
&labels,
help.as_deref(),
))
}
fn config_section_label(
config_contents: &str,
kind: &str,
id: &str,
label: &'static str,
) -> LabeledSpan {
let span = find_section_header_range(config_contents, kind, id).unwrap_or(0..0);
LabeledSpan::new_with_span(Some(label.to_string()), range_to_span(span))
}
fn config_field_label(
config_contents: &str,
kind: &str,
id: &str,
field: &str,
label: &'static str,
) -> LabeledSpan {
let span = find_section_field_range(config_contents, kind, id, field)
.or_else(|| find_section_header_range(config_contents, kind, id))
.unwrap_or(0..0);
LabeledSpan::new_with_span(Some(label.to_string()), range_to_span(span))
}
fn config_group_member_label(
config_contents: &str,
group_id: &str,
member_id: &str,
label: &'static str,
) -> LabeledSpan {
let span = find_group_member_range(config_contents, group_id, member_id)
.or_else(|| find_section_header_range(config_contents, "group", group_id))
.unwrap_or(0..0);
LabeledSpan::new_with_span(Some(label.to_string()), range_to_span(span))
}
fn config_dependency_label(
config_contents: &str,
owner_kind: &str,
owner_id: &str,
dependency: &str,
label: &'static str,
) -> LabeledSpan {
let span = find_dependency_range(config_contents, owner_kind, owner_id, dependency)
.or_else(|| find_section_header_range(config_contents, owner_kind, owner_id))
.unwrap_or(0..0);
LabeledSpan::new_with_span(Some(label.to_string()), range_to_span(span))
}
fn config_primary_label(config_contents: &str, owner_id: &str) -> LabeledSpan {
let span = find_section_field_range(config_contents, "package", owner_id, "version_format")
.or_else(|| find_section_field_range(config_contents, "group", owner_id, "version_format"))
.or_else(|| find_section_header_range(config_contents, "package", owner_id))
.or_else(|| find_section_header_range(config_contents, "group", owner_id))
.unwrap_or(0..0);
LabeledSpan::new_with_span(
Some("primary release identity".to_string()),
range_to_span(span),
)
}
fn assign_primary_release_owner(
config_contents: &str,
primary_owner: &mut Option<String>,
owner_id: &str,
) -> MonochangeResult<()> {
if let Some(existing_owner) = primary_owner {
return Err(config_diagnostic(
config_contents,
format!("`version_format = \"primary\"` is already used by `{existing_owner}`"),
vec![
config_primary_label(config_contents, existing_owner),
config_primary_label(config_contents, owner_id),
],
Some(
"choose a single package or group as the primary outward release identity"
.to_string(),
),
));
}
*primary_owner = Some(owner_id.to_string());
Ok(())
}
fn render_source_diagnostic(
source_name: &str,
source_contents: &str,
message: &str,
labels: &[LabeledSpan],
help: Option<&str>,
) -> String {
let sorted_labels = sort_labels_by_location(labels);
let primary = sorted_labels.first().map_or(0, LabeledSpan::offset);
let (line_number, column_number) = line_and_column_for_offset(source_contents, primary);
let mut lines = vec![
format!("error: {message}"),
format!(" --> {source_name}:{line_number}:{column_number}"),
];
let snippets = render_source_snippets(source_name, source_contents, &sorted_labels);
if !snippets.is_empty() {
lines.push(String::new());
lines.extend(snippets);
}
if let Some(help) = help {
lines.push(String::new());
lines.push(format!(" = help: {help}"));
}
for note in render_diagnostic_notes(&sorted_labels) {
lines.push(format!(" = note: {note}"));
}
lines.join("\n")
}
fn sort_labels_by_location(labels: &[LabeledSpan]) -> Vec<LabeledSpan> {
let Some((first, rest)) = labels.split_first() else {
return Vec::new();
};
let mut sorted = vec![first.clone()];
let mut remaining = rest.to_vec();
remaining.sort_by(|left, right| {
(left.offset(), left.len(), left.label().unwrap_or("")).cmp(&(
right.offset(),
right.len(),
right.label().unwrap_or(""),
))
});
sorted.extend(remaining);
sorted
}
fn render_source_snippets(
source_name: &str,
source_contents: &str,
labels: &[LabeledSpan],
) -> Vec<String> {
let mut snippets = Vec::new();
for (index, label) in labels.iter().enumerate() {
if index > 0 {
snippets.push(String::new());
}
snippets.extend(render_source_snippet(
source_name,
source_contents,
label,
index == 0,
));
}
snippets
}
fn render_source_snippet(
source_name: &str,
source_contents: &str,
label: &LabeledSpan,
is_primary: bool,
) -> Vec<String> {
let line_index = line_index_for_offset(source_contents, label.offset());
let line = source_contents.lines().nth(line_index).unwrap_or_default();
let (_, column_number) = line_and_column_for_offset(source_contents, label.offset());
let line_number = line_index + 1;
let gutter_width = line_number.to_string().len();
let caret_width = label.len().max(1);
let caret_padding = column_number.saturating_sub(1);
let label_text = label.label().unwrap_or("here");
let mut lines = Vec::new();
if !is_primary {
lines.push(format!(" ::: {source_name}:{line_number}:{column_number}"));
}
lines.push(format!(
"{space:>width$} |",
space = "",
width = gutter_width
));
lines.push(format!("{line_number:>gutter_width$} | {line}"));
lines.push(format!(
"{space:>width$} | {padding}{carets} {label_text}",
space = "",
width = gutter_width,
padding = " ".repeat(caret_padding),
carets = "^".repeat(caret_width),
label_text = label_text,
));
lines
}
fn render_diagnostic_notes(labels: &[LabeledSpan]) -> Vec<&'static str> {
if labels.len() > 1 {
vec![
"the first snippet marks the primary failure location",
"additional snippets show related locations referenced by this error",
]
} else {
Vec::new()
}
}
fn line_index_for_offset(source_contents: &str, offset: usize) -> usize {
let safe_offset = offset.min(source_contents.len());
source_contents[..safe_offset]
.bytes()
.filter(|byte| *byte == b'\n')
.count()
}
fn line_and_column_for_offset(source_contents: &str, offset: usize) -> (usize, usize) {
let line_index = line_index_for_offset(source_contents, offset);
let line_start = source_contents[..offset.min(source_contents.len())]
.rfind('\n')
.map_or(0, |index| index + 1);
(
line_index + 1,
offset.min(source_contents.len()) - line_start + 1,
)
}
fn frontmatter_span_for_line_column(
source_contents: &str,
line_number: usize,
column_number: usize,
) -> Range<usize> {
let mut line_start = 0usize;
for (current_line, line) in (1usize..).zip(source_contents.split_inclusive('\n')) {
let line_end = line_start + line.len();
if current_line == line_number {
let line_contents = line.strip_suffix('\n').unwrap_or(line);
let offset = column_number.saturating_sub(1).min(line_contents.len());
let start = line_start + offset;
let end = start.saturating_add(1).min(source_contents.len());
return start..end;
}
line_start = line_end;
}
let start = line_start.min(source_contents.len());
start..start
}
fn range_to_span(range: Range<usize>) -> SourceSpan {
(range.start, range.end.saturating_sub(range.start)).into()
}
fn find_section_header_range(config_contents: &str, kind: &str, id: &str) -> Option<Range<usize>> {
section_patterns(kind, id).into_iter().find_map(|pattern| {
config_contents
.find(&pattern)
.map(|start| start..start + pattern.len())
})
}
fn find_section_field_range(
config_contents: &str,
kind: &str,
id: &str,
field: &str,
) -> Option<Range<usize>> {
let section = find_section_range(config_contents, kind, id)?;
let needle = format!("{field} =");
config_contents[section.clone()]
.find(&needle)
.map(|offset| section.start + offset..section.start + offset + needle.len())
}
fn find_group_member_range(
config_contents: &str,
group_id: &str,
member_id: &str,
) -> Option<Range<usize>> {
let section = find_section_range(config_contents, "group", group_id)?;
let needle = format!("\"{member_id}\"");
config_contents[section.clone()]
.find(&needle)
.map(|offset| section.start + offset..section.start + offset + needle.len())
}
fn find_dependency_range(
config_contents: &str,
owner_kind: &str,
owner_id: &str,
dependency: &str,
) -> Option<Range<usize>> {
let section = find_section_range(config_contents, owner_kind, owner_id)?;
let needle = format!("dependency = \"{dependency}\"");
config_contents[section.clone()]
.find(&needle)
.map(|offset| section.start + offset..section.start + offset + needle.len())
}
fn find_section_range(config_contents: &str, kind: &str, id: &str) -> Option<Range<usize>> {
section_patterns(kind, id).into_iter().find_map(|pattern| {
config_contents.find(&pattern).map(|start| {
let rest = &config_contents[start + pattern.len()..];
let end = rest.find("\n[").map_or(config_contents.len(), |offset| {
start + pattern.len() + offset + 1
});
start..end
})
})
}
fn section_patterns(kind: &str, id: &str) -> [String; 2] {
if id.is_empty() {
return [format!("[{kind}]"), format!("[{kind}]")];
}
[format!("[{kind}.{id}]"), format!("[{kind}.\"{id}\"]")]
}
#[allow(clippy::needless_pass_by_value)]
fn changeset_diagnostic(
contents: &str,
changeset_path: &Path,
message: String,
labels: Vec<LabeledSpan>,
help: Option<String>,
) -> MonochangeError {
let source_name = changeset_path.display().to_string();
MonochangeError::Diagnostic(render_source_diagnostic(
&source_name,
contents,
&message,
&labels,
help.as_deref(),
))
}
fn changeset_key_label(contents: &str, key: &str, label: &'static str) -> LabeledSpan {
let span = find_changeset_key_range(contents, key).unwrap_or(0..0);
LabeledSpan::new_with_span(Some(label.to_string()), range_to_span(span))
}
fn find_changeset_key_range(contents: &str, key: &str) -> Option<Range<usize>> {
let frontmatter = extract_frontmatter(contents)?;
let needle = format!("{key}:");
frontmatter
.1
.find(&needle)
.map(|offset| frontmatter.0.start + offset..frontmatter.0.start + offset + needle.len())
}
fn extract_frontmatter(contents: &str) -> Option<(Range<usize>, &str)> {
let without_opening = contents.strip_prefix("---")?;
let (frontmatter, _) = without_opening.split_once("\n---\n")?;
let start = 4;
Some((start..start + frontmatter.len(), frontmatter))
}
pub fn apply_version_groups(
packages: &mut [PackageRecord],
configuration: &WorkspaceConfiguration,
) -> MonochangeResult<(Vec<VersionGroup>, Vec<String>)> {
let mut warnings = Vec::new();
let mut assigned = BTreeMap::<String, String>::new();
let mut groups = Vec::new();
let config_packages_by_id = configuration
.packages
.iter()
.map(|package| (package.id.as_str(), package))
.collect::<BTreeMap<_, _>>();
for package_definition in &configuration.packages {
for package_index in find_matching_package_indices_for_definition(
packages,
&configuration.root_path,
package_definition,
) {
if let Some(package) = packages.get_mut(package_index) {
package
.metadata
.insert("config_id".to_string(), package_definition.id.clone());
}
}
}
for group in &configuration.groups {
let group_id = group.id.clone();
let group_members = group.packages.clone();
let mut members = Vec::new();
let mut versions = BTreeSet::new();
for member in &group_members {
let matching_indices =
if let Some(package_definition) = config_packages_by_id.get(member.as_str()) {
find_matching_package_indices_for_definition(
packages,
&configuration.root_path,
package_definition,
)
} else {
find_matching_package_indices(packages, &configuration.root_path, member)
};
if matching_indices.is_empty() {
warnings.push(format!(
"version group `{group_id}` member `{member}` did not match any discovered package"
));
continue;
}
for package_index in matching_indices {
let package = packages.get_mut(package_index).ok_or_else(|| {
MonochangeError::Config(format!(
"matched package index `{package_index}` for version group `{group_id}` is invalid"
))
})?;
if let Some(existing_group) = assigned.get(&package.id) {
return Err(MonochangeError::Config(format!(
"package `{}` belongs to conflicting version groups `{existing_group}` and `{group_id}`",
package.id
)));
}
assigned.insert(package.id.clone(), group_id.clone());
package.version_group_id = Some(group_id.clone());
members.push(package.id.clone());
if let Some(version) = &package.current_version {
versions.insert(version.to_string());
}
}
}
let mismatch_detected = versions.len() > 1;
if mismatch_detected && configuration.defaults.warn_on_group_mismatch {
warnings.push(format!(
"version group `{group_id}` contains packages with mismatched versions"
));
}
groups.push(VersionGroup {
group_id: group_id.clone(),
display_name: group_id,
members,
mismatch_detected,
});
}
Ok((groups, warnings))
}
fn find_matching_package_indices(
packages: &[PackageRecord],
root: &Path,
member: &str,
) -> Vec<usize> {
packages
.iter()
.enumerate()
.filter_map(|(index, package)| {
if package_matches_reference(package, root, member) {
Some(index)
} else {
None
}
})
.collect()
}
fn find_matching_package_indices_for_definition(
packages: &[PackageRecord],
root: &Path,
definition: &PackageDefinition,
) -> Vec<usize> {
packages
.iter()
.enumerate()
.filter_map(|(index, package)| {
if package_matches_definition(package, root, definition) {
Some(index)
} else {
None
}
})
.collect()
}
fn find_matching_package_ids(
reference: &str,
root: &Path,
packages: &[PackageRecord],
) -> Vec<String> {
packages
.iter()
.filter(|package| package_matches_reference(package, root, reference))
.map(|package| package.id.clone())
.collect()
}
fn package_matches_reference(package: &PackageRecord, root: &Path, reference: &str) -> bool {
let manifest_match = relative_to_root(root, &package.manifest_path)
.and_then(|path| path.to_str().map(ToString::to_string))
.is_some_and(|path| path == reference);
let directory_match = package
.manifest_path
.parent()
.and_then(|path| relative_to_root(root, path))
.and_then(|path| path.to_str().map(ToString::to_string))
.is_some_and(|path| path == reference);
let name_match = package.name == reference;
let id_match = package.id == reference;
let config_id_match = package
.metadata
.get("config_id")
.is_some_and(|config_id| config_id == reference);
manifest_match || directory_match || name_match || id_match || config_id_match
}
fn package_matches_definition(
package: &PackageRecord,
root: &Path,
definition: &PackageDefinition,
) -> bool {
let Some(directory) = package.manifest_path.parent() else {
return false;
};
let relative_directory = relative_to_root(root, directory);
relative_directory.as_deref() == Some(definition.path.as_path())
&& ecosystem_matches_package_type(package.ecosystem, definition.package_type)
}
fn ecosystem_matches_package_type(ecosystem: Ecosystem, package_type: PackageType) -> bool {
matches!(
(ecosystem, package_type),
(Ecosystem::Cargo, PackageType::Cargo)
| (Ecosystem::Npm, PackageType::Npm)
| (Ecosystem::Deno, PackageType::Deno)
| (Ecosystem::Dart, PackageType::Dart)
| (Ecosystem::Flutter, PackageType::Flutter)
)
}
#[must_use = "the validation result must be checked"]
pub fn validate_workspace(root: &Path) -> MonochangeResult<()> {
let configuration = load_workspace_configuration(root)?;
let changeset_dir = root.join(".changeset");
if !changeset_dir.exists() {
return Ok(());
}
let changeset_paths = fs::read_dir(&changeset_dir)
.map_err(|error| {
MonochangeError::Io(format!(
"failed to read {}: {error}",
changeset_dir.display()
))
})?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("md"))
.collect::<Vec<_>>();
let mut errors = Vec::new();
for changeset_path in changeset_paths {
if let Err(error) = validate_changeset_targets(&configuration, &changeset_path) {
errors.push(error.render());
}
}
if errors.is_empty() {
Ok(())
} else {
Err(MonochangeError::Diagnostic(format!(
"changeset target validation failed:\n{}",
errors.join("\n\n")
)))
}
}
#[must_use = "the validation result must be checked"]
pub fn validate_versioned_files_content(root: &Path) -> MonochangeResult<Vec<String>> {
let configuration = load_workspace_configuration(root)?;
let mut warnings = Vec::new();
let mut sources: Vec<(&str, String, &[VersionedFileDefinition])> = Vec::new();
for package in &configuration.packages {
sources.push(("package", package.id.clone(), &package.versioned_files));
}
for group in &configuration.groups {
sources.push(("group", group.id.clone(), &group.versioned_files));
}
let ecosystem_entries: &[(&str, &EcosystemSettings)] = &[
("cargo", &configuration.cargo),
("npm", &configuration.npm),
("deno", &configuration.deno),
("dart", &configuration.dart),
("python", &configuration.python),
];
for &(eco_name, settings) in ecosystem_entries {
if !settings.versioned_files.is_empty() {
sources.push(("ecosystem", eco_name.to_string(), &settings.versioned_files));
}
}
let mut errors = Vec::new();
for (owner_kind, owner_id, definitions) in &sources {
for definition in *definitions {
if let Err(error) = validate_single_versioned_file_content(
root,
definition,
owner_kind,
owner_id,
&mut warnings,
) {
errors.push(error.render());
}
}
}
if errors.is_empty() {
Ok(warnings)
} else {
Err(MonochangeError::Diagnostic(format!(
"versioned file validation failed:\n{}",
errors.join("\n\n")
)))
}
}
fn validate_single_versioned_file_content(
root: &Path,
definition: &VersionedFileDefinition,
owner_kind: &str,
owner_id: &str,
warnings: &mut Vec<String>,
) -> MonochangeResult<()> {
if path_uses_glob(&definition.path) {
let pattern = root.join(&definition.path).to_string_lossy().to_string();
let matches = glob::glob(&pattern)
.map_err(|error| {
MonochangeError::Config(format!(
"invalid glob pattern `{}`: {error}",
definition.path
))
})?
.filter_map(Result::ok)
.collect::<Vec<_>>();
if matches.is_empty() {
warnings.push(format!(
"{owner_kind} `{owner_id}` versioned file glob `{}` matches no files",
definition.path
));
}
return Ok(());
}
let full_path = root.join(&definition.path);
if !full_path.exists() {
return Err(MonochangeError::Config(format!(
"{owner_kind} `{owner_id}` versioned file `{}` does not exist",
definition.path
)));
}
if let Some(regex_pattern) = &definition.regex {
let contents = fs::read_to_string(&full_path).map_err(|error| {
MonochangeError::Io(format!("failed to read `{}`: {error}", definition.path))
})?;
let compiled = Regex::new(regex_pattern).map_err(|error| {
MonochangeError::Config(format!(
"{owner_kind} `{owner_id}` regex `{regex_pattern}` is invalid: {error}"
))
})?;
if !compiled.is_match(&contents) {
return Err(MonochangeError::Config(format!(
"{owner_kind} `{owner_id}` versioned file `{}` regex `{regex_pattern}` does not match any content in the file",
definition.path
)));
}
return Ok(());
}
if let Some(ecosystem_type) = definition.ecosystem_type {
validate_ecosystem_version_readable(
&full_path,
&definition.path,
ecosystem_type,
definition.fields.as_deref(),
owner_kind,
owner_id,
)?;
}
Ok(())
}
#[rustfmt::skip]
fn validate_ecosystem_version_readable(
full_path: &Path,
display_path: &str,
ecosystem_type: EcosystemType,
fields: Option<&[String]>,
owner_kind: &str,
owner_id: &str,
) -> MonochangeResult<()> {
if !path_is_supported_for_ecosystem(full_path, ecosystem_type) {
return Err(MonochangeError::Config(format!(
"{owner_kind} `{owner_id}` versioned file `{display_path}` is not supported for ecosystem `{ecosystem_type:?}`"
)));
}
let contents = fs::read_to_string(full_path).map_err(|error| {
MonochangeError::Io(format!("failed to read {}: {error}", full_path.display()))
})?;
let uses_default_version_field = fields.is_none();
let field_names: Vec<&str> = fields.map_or_else(
|| vec!["version"],
|fields| fields.iter().map(String::as_str).collect(),
);
let field_error = |field: &str| {
if uses_default_version_field {
MonochangeError::Config(format!(
"{owner_kind} `{owner_id}` versioned file `{display_path}` does not contain a `version` string field; does not contain a readable version field"
))
} else {
MonochangeError::Config(format!(
"{owner_kind} `{owner_id}` versioned file `{display_path}` does not contain a `{field}` string field"
))
}
};
let value_is_string = |value: &serde_json::Value, field: &str| {
value.get(field).and_then(serde_json::Value::as_str).is_some()
};
match ecosystem_type {
EcosystemType::Npm | EcosystemType::Deno => {
let value: serde_json::Value = serde_json::from_str(&contents).map_err(|error| {
MonochangeError::Config(format!("{owner_kind} `{owner_id}` `{display_path}` is not valid JSON: {error}"))
})?;
for field in field_names {
if value_is_string(&value, field) {
continue;
}
Err(field_error(field))?;
}
}
EcosystemType::Dart => {
let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&contents).map_err(|error| {
MonochangeError::Config(format!("{owner_kind} `{owner_id}` `{display_path}` is not valid YAML: {error}"))
})?;
for field in field_names {
let is_string = value
.get(field)
.and_then(serde_yaml_ng::Value::as_str)
.is_some();
if is_string {
continue;
}
Err(field_error(field))?;
}
}
EcosystemType::Cargo => {
let value: toml::Value = toml::from_str(&contents).map_err(|error| {
MonochangeError::Config(format!(
"{owner_kind} `{owner_id}` `{display_path}` is not valid TOML: {error}"
))
})?;
let value_at_path_is_string = |field: &str| {
field
.split('.')
.try_fold(&value, |current, segment| current.get(segment))
.and_then(toml::Value::as_str)
.is_some()
};
if uses_default_version_field {
let has_version = ["version", "package.version", "workspace.package.version"]
.into_iter()
.any(value_at_path_is_string);
if !has_version {
return Err(field_error("version"));
}
} else {
for field in field_names {
if value_at_path_is_string(field) {
continue;
}
Err(field_error(field))?;
}
}
}
_ => {}
}
Ok(())
}
fn validate_changeset_targets(
configuration: &WorkspaceConfiguration,
changeset_path: &Path,
) -> MonochangeResult<()> {
let contents = fs::read_to_string(changeset_path).map_err(|error| {
MonochangeError::Io(format!(
"failed to read {}: {error}",
changeset_path.display()
))
})?;
let raw = if changeset_path.extension().and_then(|value| value.to_str()) == Some("md") {
parse_markdown_change_file(&contents, changeset_path, configuration)?
} else {
return Ok(());
};
let package_ids = configuration
.packages
.iter()
.map(|package| package.id.as_str())
.collect::<BTreeSet<_>>();
let group_members = configuration
.groups
.iter()
.map(|group| {
(
group.id.as_str(),
group
.packages
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>(),
)
})
.collect::<BTreeMap<_, _>>();
let mut errors = Vec::new();
for change in &raw.changes {
if !package_ids.contains(change.package.as_str())
&& !group_members.contains_key(change.package.as_str())
{
errors.push(
changeset_diagnostic(
&contents,
changeset_path,
format!(
"changeset `{}` references unknown package or group `{}`",
changeset_path.display(),
change.package,
),
vec![changeset_key_label(
&contents,
&change.package,
"unknown package or group",
)],
Some("declare the package or group id in monochange.toml before referencing it in a changeset".to_string()),
)
.render(),
);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(MonochangeError::Diagnostic(errors.join("\n\n")))
}
}
pub mod lints;
#[cfg(feature = "schema")]
pub mod schema {
pub fn workspace_configuration() -> schemars::Schema {
schemars::schema_for!(super::RawWorkspaceConfiguration)
}
}
#[cfg(test)]
#[path = "__tests__/lib_tests.rs"]
mod tests;