Skip to main content

monochange_core/
lib.rs

1#![forbid(clippy::indexing_slicing)]
2
3//! # `monochange_core`
4//!
5//! <!-- {=monochangeCoreCrateDocs|trim|linePrefix:"//! ":true} -->
6//! `monochange_core` is the shared vocabulary for the `monochange` workspace.
7//!
8//! Reach for this crate when you are building ecosystem adapters, release planners, or custom automation and need one set of types for packages, dependency edges, version groups, change signals, and release plans.
9//!
10//! ## Why use it?
11//!
12//! - avoid redefining package and release domain models in each crate
13//! - share one error and result surface across discovery, planning, and command layers
14//! - pass normalized workspace data between adapters and planners without extra translation
15//!
16//! ## Best for
17//!
18//! - implementing new ecosystem adapters against the shared `EcosystemAdapter` contract
19//! - moving normalized package or release data between crates without custom conversion code
20//! - depending on the workspace domain model without pulling in discovery or planning behavior
21//!
22//! ## What it provides
23//!
24//! - normalized package and dependency records
25//! - version-group definitions and planned group outcomes
26//! - change signals and compatibility assessments
27//! - changelog formats, changelog targets, structured release-note types, release-manifest types, source-automation config types, and changeset-policy evaluation types
28//! - shared error and result types
29//!
30//! ## Example
31//!
32//! ```rust
33//! use monochange_core::render_release_notes;
34//! use monochange_core::ChangelogFormat;
35//! use monochange_core::ReleaseNotesDocument;
36//! use monochange_core::ReleaseNotesSection;
37//!
38//! let notes = ReleaseNotesDocument {
39//!     title: "1.2.3".to_string(),
40//!     summary: vec!["Grouped release for `sdk`.".to_string()],
41//!     sections: vec![ReleaseNotesSection {
42//!         title: "Features".to_string(),
43//!         entries: vec!["- add keep-a-changelog output".to_string()],
44//!         collapsed: false,
45//!     }],
46//! };
47//!
48//! let rendered = render_release_notes(ChangelogFormat::KeepAChangelog, &notes);
49//!
50//! assert!(rendered.contains("## 1.2.3"));
51//! assert!(rendered.contains("### Features"));
52//! assert!(rendered.contains("- add keep-a-changelog output"));
53//! ```
54//! <!-- {/monochangeCoreCrateDocs} -->
55
56use std::collections::BTreeMap;
57use std::collections::BTreeSet;
58use std::env;
59use std::fmt;
60use std::fs;
61use std::path::Path;
62use std::path::PathBuf;
63
64pub mod analysis;
65pub mod git;
66pub mod lint;
67
68pub use analysis::*;
69use ignore::gitignore::Gitignore;
70use ignore::gitignore::GitignoreBuilder;
71use semver::Version;
72use serde::Deserialize;
73use serde::Serialize;
74use thiserror::Error;
75
76pub type MonochangeResult<T> = Result<T, MonochangeError>;
77
78/// Default release title template for primary versioning: `1.2.3 (2026-04-06)`.
79pub const DEFAULT_RELEASE_TITLE_PRIMARY: &str = "{{ version }} ({{ date }})";
80/// Default release title template for namespaced versioning: `my-pkg 1.2.3 (2026-04-06)`.
81pub const DEFAULT_RELEASE_TITLE_NAMESPACED: &str = "{{ id }} {{ version }} ({{ date }})";
82/// Default changelog version title for primary versioning (markdown-linked when source configured).
83pub const DEFAULT_CHANGELOG_VERSION_TITLE_PRIMARY: &str =
84	"{% if tag_url %}[{{ version }}]({{ tag_url }}){% else %}{{ version }}{% endif %} ({{ date }})";
85/// Default changelog version title for namespaced versioning (markdown-linked when source configured).
86pub const DEFAULT_CHANGELOG_VERSION_TITLE_NAMESPACED: &str = "{% if tag_url %}{{ id }} [{{ version }}]({{ tag_url }}){% else %}{{ id }} {{ version }}{% endif %} ({{ date }})";
87
88/// Default initial changelog header for the `monochange` changelog format.
89pub const DEFAULT_INITIAL_CHANGELOG_HEADER_MONOCHANGE: &str = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThis changelog is managed by [monochange](https://github.com/monochange/monochange).";
90/// Default initial changelog header for the `keep_a_changelog` changelog format.
91pub const DEFAULT_INITIAL_CHANGELOG_HEADER_KEEP_A_CHANGELOG: &str = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).";
92
93#[derive(Debug, Error)]
94#[non_exhaustive]
95pub enum MonochangeError {
96	#[error("io error: {0}")]
97	Io(String),
98	#[error("config error: {0}")]
99	Config(String),
100	#[error("discovery error: {0}")]
101	Discovery(String),
102	#[error("{0}")]
103	Diagnostic(String),
104	#[error("io error at {path:?}: {source}")]
105	IoSource {
106		path: PathBuf,
107		source: std::io::Error,
108	},
109	#[error("parse error at {path:?}: {source}")]
110	Parse {
111		path: PathBuf,
112		source: Box<dyn std::error::Error + Send + Sync>,
113	},
114	#[cfg(feature = "http")]
115	#[error("http error {context}: {source}")]
116	HttpRequest {
117		context: String,
118		source: reqwest::Error,
119	},
120	#[error("interactive error: {message}")]
121	Interactive { message: String },
122	#[error("cancelled")]
123	Cancelled,
124}
125
126impl MonochangeError {
127	/// Render a stable human-readable diagnostic string for the error.
128	#[must_use]
129	pub fn render(&self) -> String {
130		match self {
131			Self::Diagnostic(report) => report.clone(),
132			Self::IoSource { path, source } => {
133				format!("io error at {}: {source}", path.display())
134			}
135			Self::Parse { path, source } => {
136				format!("parse error at {}: {source}", path.display())
137			}
138			#[cfg(feature = "http")]
139			Self::HttpRequest { context, source } => {
140				format!("http error {context}: {source}")
141			}
142			Self::Interactive { message } => message.clone(),
143			Self::Cancelled => "cancelled".to_string(),
144			_ => self.to_string(),
145		}
146	}
147}
148
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152#[non_exhaustive]
153pub enum BumpSeverity {
154	None,
155	#[default]
156	Patch,
157	Minor,
158	Major,
159}
160
161impl BumpSeverity {
162	/// Return `true` when this severity produces a release.
163	#[must_use]
164	pub fn is_release(self) -> bool {
165		self != Self::None
166	}
167
168	/// Returns `true` when the version is below `1.0.0`.
169	///
170	/// Pre-1.0 packages use a shifted bump policy where major changes bump
171	/// the minor component and minor changes bump the patch component.
172	#[must_use]
173	pub fn is_pre_stable(version: &Version) -> bool {
174		version.major == 0
175	}
176
177	/// Apply the severity to `version`, including pre-1.0 bump shifting.
178	#[must_use]
179	pub fn apply_to_version(self, version: &Version) -> Version {
180		let effective = if Self::is_pre_stable(version) {
181			match self {
182				Self::Major => Self::Minor,
183				Self::Minor => Self::Patch,
184				other => other,
185			}
186		} else {
187			self
188		};
189
190		let mut next = version.clone();
191		match effective {
192			Self::None => next,
193			Self::Patch => {
194				next.patch += 1;
195				next.pre = semver::Prerelease::EMPTY;
196				next.build = semver::BuildMetadata::EMPTY;
197				next
198			}
199			Self::Minor => {
200				next.minor += 1;
201				next.patch = 0;
202				next.pre = semver::Prerelease::EMPTY;
203				next.build = semver::BuildMetadata::EMPTY;
204				next
205			}
206			Self::Major => {
207				next.major += 1;
208				next.minor = 0;
209				next.patch = 0;
210				next.pre = semver::Prerelease::EMPTY;
211				next.build = semver::BuildMetadata::EMPTY;
212				next
213			}
214		}
215	}
216}
217
218impl fmt::Display for BumpSeverity {
219	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
220		formatter.write_str(match self {
221			Self::None => "none",
222			Self::Patch => "patch",
223			Self::Minor => "minor",
224			Self::Major => "major",
225		})
226	}
227}
228
229#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
230#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232#[non_exhaustive]
233pub enum Ecosystem {
234	Cargo,
235	Npm,
236	Deno,
237	Dart,
238	Flutter,
239	Python,
240	Go,
241}
242
243impl Ecosystem {
244	/// Return the canonical config and serialization string for the ecosystem.
245	#[must_use]
246	pub fn as_str(self) -> &'static str {
247		match self {
248			Self::Cargo => "cargo",
249			Self::Npm => "npm",
250			Self::Deno => "deno",
251			Self::Dart => "dart",
252			Self::Flutter => "flutter",
253			Self::Python => "python",
254			Self::Go => "go",
255		}
256	}
257}
258
259impl From<EcosystemType> for Ecosystem {
260	fn from(value: EcosystemType) -> Self {
261		match value {
262			EcosystemType::Cargo => Self::Cargo,
263			EcosystemType::Npm => Self::Npm,
264			EcosystemType::Deno => Self::Deno,
265			EcosystemType::Dart => Self::Dart,
266			EcosystemType::Python => Self::Python,
267			EcosystemType::Go => Self::Go,
268		}
269	}
270}
271
272impl From<PackageType> for Ecosystem {
273	fn from(value: PackageType) -> Self {
274		match value {
275			PackageType::Cargo => Self::Cargo,
276			PackageType::Npm => Self::Npm,
277			PackageType::Deno => Self::Deno,
278			PackageType::Dart => Self::Dart,
279			PackageType::Flutter => Self::Flutter,
280			PackageType::Python => Self::Python,
281			PackageType::Go => Self::Go,
282		}
283	}
284}
285
286impl fmt::Display for Ecosystem {
287	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
288		formatter.write_str(self.as_str())
289	}
290}
291
292impl std::str::FromStr for Ecosystem {
293	type Err = ();
294
295	fn from_str(string: &str) -> Result<Self, Self::Err> {
296		match string {
297			"cargo" => Ok(Self::Cargo),
298			"npm" => Ok(Self::Npm),
299			"deno" => Ok(Self::Deno),
300			"dart" => Ok(Self::Dart),
301			"flutter" => Ok(Self::Flutter),
302			"python" => Ok(Self::Python),
303			"go" => Ok(Self::Go),
304			_ => Err(()),
305		}
306	}
307}
308
309#[must_use]
310pub fn default_registry_kind_for_ecosystem(ecosystem: Ecosystem) -> Option<RegistryKind> {
311	match ecosystem {
312		Ecosystem::Cargo => Some(RegistryKind::CratesIo),
313		Ecosystem::Npm => Some(RegistryKind::Npm),
314		Ecosystem::Deno => Some(RegistryKind::Jsr),
315		Ecosystem::Dart | Ecosystem::Flutter => Some(RegistryKind::PubDev),
316		Ecosystem::Python => Some(RegistryKind::Pypi),
317		Ecosystem::Go => Some(RegistryKind::GoProxy),
318	}
319}
320
321#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
322#[serde(rename_all = "snake_case")]
323#[non_exhaustive]
324pub enum PublishState {
325	Public,
326	Private,
327	Unpublished,
328	Excluded,
329}
330
331#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333#[non_exhaustive]
334pub enum DependencyKind {
335	Runtime,
336	Development,
337	Build,
338	Peer,
339	Workspace,
340	Unknown,
341}
342
343impl fmt::Display for DependencyKind {
344	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
345		formatter.write_str(match self {
346			Self::Runtime => "runtime",
347			Self::Development => "development",
348			Self::Build => "build",
349			Self::Peer => "peer",
350			Self::Workspace => "workspace",
351			Self::Unknown => "unknown",
352		})
353	}
354}
355
356#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
357#[serde(rename_all = "snake_case")]
358#[non_exhaustive]
359pub enum DependencySourceKind {
360	Manifest,
361	Workspace,
362	Transitive,
363}
364
365#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
366pub struct PackageDependency {
367	pub name: String,
368	pub kind: DependencyKind,
369	pub version_constraint: Option<String>,
370	pub optional: bool,
371}
372
373#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
374pub struct PackageRecord {
375	pub id: String,
376	pub name: String,
377	pub ecosystem: Ecosystem,
378	pub manifest_path: PathBuf,
379	pub workspace_root: PathBuf,
380	pub current_version: Option<Version>,
381	pub publish_state: PublishState,
382	pub version_group_id: Option<String>,
383	pub metadata: BTreeMap<String, String>,
384	pub declared_dependencies: Vec<PackageDependency>,
385}
386
387impl PackageRecord {
388	#[allow(clippy::needless_pass_by_value)]
389	/// Construct a normalized package record for a discovered package.
390	#[must_use]
391	pub fn new(
392		ecosystem: Ecosystem,
393		name: impl Into<String>,
394		manifest_path: PathBuf,
395		workspace_root: PathBuf,
396		current_version: Option<Version>,
397		publish_state: PublishState,
398	) -> Self {
399		let name = name.into();
400		let normalized_workspace_root = normalize_path(&workspace_root);
401		let normalized_manifest_path = normalize_path(&manifest_path);
402		let id_path = relative_to_root(&normalized_workspace_root, &normalized_manifest_path)
403			.unwrap_or_else(|| normalized_manifest_path.clone());
404		let id = format!("{}:{}", ecosystem.as_str(), id_path.to_string_lossy());
405
406		Self {
407			id,
408			name,
409			ecosystem,
410			manifest_path: normalized_manifest_path,
411			workspace_root: normalized_workspace_root,
412			current_version,
413			publish_state,
414			version_group_id: None,
415			metadata: BTreeMap::new(),
416			declared_dependencies: Vec::new(),
417		}
418	}
419
420	/// Return the manifest path relative to `root` when possible.
421	#[must_use]
422	pub fn relative_manifest_path(&self, root: &Path) -> Option<PathBuf> {
423		relative_to_root(root, &self.manifest_path)
424	}
425}
426
427/// Normalize a path to an absolute, canonicalized path when possible.
428#[must_use]
429pub fn normalize_path(path: &Path) -> PathBuf {
430	let absolute = if path.is_absolute() {
431		path.to_path_buf()
432	} else {
433		env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
434	};
435	fs::canonicalize(&absolute).unwrap_or(absolute)
436}
437
438/// Return `path` relative to `root` after normalizing both paths.
439#[must_use]
440pub fn relative_to_root(root: &Path, path: &Path) -> Option<PathBuf> {
441	let normalized_root = normalize_path(root);
442	let normalized_path = normalize_path(path);
443	normalized_path
444		.strip_prefix(&normalized_root)
445		.ok()
446		.map(Path::to_path_buf)
447}
448
449#[derive(Clone, Debug)]
450pub struct DiscoveryPathFilter {
451	root: PathBuf,
452	gitignore: Gitignore,
453}
454
455impl DiscoveryPathFilter {
456	/// Build a discovery filter from repository gitignore rules.
457	#[must_use]
458	pub fn new(root: &Path) -> Self {
459		let root = normalize_path(root);
460		let mut builder = GitignoreBuilder::new(&root);
461		for path in [root.join(".gitignore"), root.join(".git/info/exclude")] {
462			if path.is_file() {
463				let _ = builder.add(path);
464			}
465		}
466		let gitignore = builder.build().unwrap_or_else(|_| Gitignore::empty());
467
468		Self { root, gitignore }
469	}
470
471	/// Return `true` when `path` should be considered during discovery.
472	#[must_use]
473	pub fn allows(&self, path: &Path) -> bool {
474		!self.is_ignored(path, path.is_dir())
475	}
476
477	/// Return `true` when directory traversal should continue into `path`.
478	#[must_use]
479	pub fn should_descend(&self, path: &Path) -> bool {
480		!self.is_ignored(path, true)
481	}
482
483	fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
484		if ignored_discovery_dir_name(path) || self.has_nested_git_worktree_ancestor(path, is_dir) {
485			return true;
486		}
487
488		self.matches_gitignore(path, is_dir)
489	}
490
491	fn matches_gitignore(&self, path: &Path, is_dir: bool) -> bool {
492		let normalized_path = normalize_path(path);
493		normalized_path
494			.strip_prefix(&self.root)
495			.ok()
496			.is_some_and(|relative| {
497				self.gitignore
498					.matched_path_or_any_parents(relative, is_dir)
499					.is_ignore()
500			})
501	}
502
503	fn has_nested_git_worktree_ancestor(&self, path: &Path, is_dir: bool) -> bool {
504		let normalized_path = normalize_path(path);
505		let mut current = if is_dir {
506			normalized_path.clone()
507		} else {
508			normalized_path
509				.parent()
510				.unwrap_or(&normalized_path)
511				.to_path_buf()
512		};
513
514		while current.starts_with(&self.root) && current != self.root {
515			if current.join(".git").exists() {
516				return true;
517			}
518			let Some(parent) = current.parent() else {
519				break;
520			};
521			current = parent.to_path_buf();
522		}
523
524		false
525	}
526}
527
528fn ignored_discovery_dir_name(path: &Path) -> bool {
529	path.components().any(|component| {
530		component.as_os_str().to_str().is_some_and(|name| {
531			matches!(
532				name,
533				".git" | "target" | "node_modules" | ".devenv" | ".claude" | "book"
534			)
535		})
536	})
537}
538
539#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
540pub struct DependencyEdge {
541	pub from_package_id: String,
542	pub to_package_id: String,
543	pub dependency_kind: DependencyKind,
544	pub source_kind: DependencySourceKind,
545	pub version_constraint: Option<String>,
546	pub is_optional: bool,
547	pub is_direct: bool,
548}
549
550#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
551#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
552#[serde(rename_all = "snake_case")]
553#[non_exhaustive]
554pub enum PackageType {
555	Cargo,
556	Npm,
557	Deno,
558	Dart,
559	Flutter,
560	Python,
561	Go,
562}
563
564impl PackageType {
565	/// Return the canonical config string for the package type.
566	#[must_use]
567	pub fn as_str(self) -> &'static str {
568		match self {
569			Self::Cargo => "cargo",
570			Self::Npm => "npm",
571			Self::Deno => "deno",
572			Self::Dart => "dart",
573			Self::Flutter => "flutter",
574			Self::Python => "python",
575			Self::Go => "go",
576		}
577	}
578}
579
580#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
581#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
582#[serde(rename_all = "snake_case")]
583#[non_exhaustive]
584pub enum VersionFormat {
585	#[default]
586	Namespaced,
587	Primary,
588}
589
590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
591#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
592#[serde(rename_all = "snake_case")]
593#[non_exhaustive]
594pub enum EcosystemType {
595	Cargo,
596	Npm,
597	Deno,
598	Dart,
599	Python,
600	Go,
601}
602
603impl EcosystemType {
604	/// Return the default dependency-version prefix for this ecosystem.
605	///
606	/// # Deprecation
607	///
608	/// Prefer the ecosystem crate's `default_dependency_version_prefix()`
609	/// to keep ecosystem knowledge out of core.
610	#[must_use]
611	#[deprecated(
612		since = "0.3.5",
613		note = "Use the ecosystem crate's `default_dependency_version_prefix()` instead"
614	)]
615	pub fn default_prefix(self) -> &'static str {
616		match self {
617			Self::Cargo | Self::Go => "",
618			Self::Npm | Self::Deno | Self::Dart => "^",
619			Self::Python => ">=",
620		}
621	}
622
623	/// Return the manifest fields that usually contain dependency versions.
624	///
625	/// # Deprecation
626	///
627	/// Prefer the ecosystem crate's `default_dependency_fields()`
628	/// to keep ecosystem knowledge out of core.
629	#[must_use]
630	#[deprecated(
631		since = "0.3.5",
632		note = "Use the ecosystem crate's `default_dependency_fields()` instead"
633	)]
634	pub fn default_fields(self) -> &'static [&'static str] {
635		match self {
636			Self::Cargo => &["dependencies", "dev-dependencies", "build-dependencies"],
637			Self::Npm => &["dependencies", "devDependencies", "peerDependencies"],
638			Self::Deno => &["imports"],
639			Self::Dart => &["dependencies", "dev_dependencies"],
640			Self::Python => &["dependencies"],
641			Self::Go => &["require"],
642		}
643	}
644}
645
646#[derive(Clone, Copy, Debug, Eq, PartialEq)]
647struct JsonSpan {
648	start: usize,
649	end: usize,
650}
651
652/// Remove `//` and `/* ... */` comments from JSON-like text.
653pub fn strip_json_comments(contents: &str) -> String {
654	let bytes = contents.as_bytes();
655	let mut output = String::with_capacity(contents.len());
656	let mut cursor = 0usize;
657	while let Some(&byte) = bytes.get(cursor) {
658		if byte == b'"' {
659			let start = cursor;
660			cursor += 1;
661			while let Some(&string_byte) = bytes.get(cursor) {
662				cursor += 1;
663				if string_byte == b'\\' {
664					cursor += usize::from(bytes.get(cursor).is_some());
665					continue;
666				}
667				if string_byte == b'"' {
668					break;
669				}
670			}
671			output.push_str(&contents[start..cursor]);
672			continue;
673		}
674		if byte == b'/' && bytes.get(cursor + 1) == Some(&b'/') {
675			cursor += 2;
676			while let Some(&line_byte) = bytes.get(cursor) {
677				if line_byte == b'\n' {
678					break;
679				}
680				cursor += 1;
681			}
682			continue;
683		}
684		if byte == b'/' && bytes.get(cursor + 1) == Some(&b'*') {
685			cursor += 2;
686			while bytes.get(cursor).is_some() {
687				if bytes.get(cursor) == Some(&b'*') && bytes.get(cursor + 1) == Some(&b'/') {
688					cursor += 2;
689					break;
690				}
691				cursor += 1;
692			}
693			continue;
694		}
695		output.push(char::from(byte));
696		cursor += 1;
697	}
698	output
699}
700
701/// Update JSON manifest text while preserving most existing formatting.
702#[must_use = "the manifest update result must be checked"]
703pub fn update_json_manifest_text(
704	contents: &str,
705	owner_version: Option<&str>,
706	fields: &[&str],
707	versioned_deps: &BTreeMap<String, String>,
708) -> MonochangeResult<String> {
709	let root_start = json_root_object_start(contents)?;
710	let mut replacements = Vec::<(JsonSpan, String)>::new();
711	if let Some(owner_version) = owner_version
712		&& let Some(span) = find_json_object_field_value_span(contents, root_start, "version")?
713			.filter(|span| json_span_is_string(contents, *span))
714	{
715		replacements.push((span, render_json_string(owner_version)?));
716	}
717	for field in fields {
718		let Some(field_span) = find_json_path_value_span(contents, root_start, field)? else {
719			continue;
720		};
721		if json_span_is_object(contents, field_span) {
722			for (dep_name, dep_version) in versioned_deps {
723				let Some(dep_span) =
724					find_json_object_field_value_span(contents, field_span.start, dep_name)?
725						.filter(|span| json_span_is_string(contents, *span))
726				else {
727					continue;
728				};
729				replacements.push((dep_span, render_json_string(dep_version)?));
730			}
731			continue;
732		}
733		if let Some(owner_version) = owner_version
734			&& json_span_is_string(contents, field_span)
735		{
736			replacements.push((field_span, render_json_string(owner_version)?));
737		}
738	}
739	apply_json_replacements(contents, replacements)
740}
741
742fn render_json_string(value: &str) -> MonochangeResult<String> {
743	serde_json::to_string(value).map_err(|error| MonochangeError::Config(error.to_string()))
744}
745
746fn apply_json_replacements(
747	contents: &str,
748	mut replacements: Vec<(JsonSpan, String)>,
749) -> MonochangeResult<String> {
750	replacements.sort_by_key(|right| std::cmp::Reverse(right.0.start));
751	let mut updated = contents.to_string();
752	for (span, replacement) in replacements {
753		if span.start > span.end || span.end > updated.len() {
754			return Err(MonochangeError::Config(
755				"json edit range was out of bounds".to_string(),
756			));
757		}
758		updated.replace_range(span.start..span.end, &replacement);
759	}
760	Ok(updated)
761}
762
763fn json_root_object_start(contents: &str) -> MonochangeResult<usize> {
764	let start = skip_json_ws_and_comments(contents, 0);
765	if contents.as_bytes().get(start) == Some(&b'{') {
766		Ok(start)
767	} else {
768		Err(MonochangeError::Config(
769			"expected JSON object at document root".to_string(),
770		))
771	}
772}
773
774fn find_json_path_value_span(
775	contents: &str,
776	root_start: usize,
777	path: &str,
778) -> MonochangeResult<Option<JsonSpan>> {
779	let mut segments = path.split('.').filter(|segment| !segment.is_empty());
780	let Some(first) = segments.next() else {
781		return Ok(None);
782	};
783	let Some(mut span) = find_json_object_field_value_span(contents, root_start, first)? else {
784		return Ok(None);
785	};
786	for segment in segments {
787		if !json_span_is_object(contents, span) {
788			return Ok(None);
789		}
790		let Some(next_span) = find_json_object_field_value_span(contents, span.start, segment)?
791		else {
792			return Ok(None);
793		};
794		span = next_span;
795	}
796	Ok(Some(span))
797}
798
799fn find_json_object_field_value_span(
800	contents: &str,
801	object_start: usize,
802	key: &str,
803) -> MonochangeResult<Option<JsonSpan>> {
804	let bytes = contents.as_bytes();
805	if bytes.get(object_start) != Some(&b'{') {
806		return Err(MonochangeError::Config(
807			"expected JSON object when locating field".to_string(),
808		));
809	}
810	let mut cursor = object_start + 1;
811	loop {
812		cursor = skip_json_ws_and_comments(contents, cursor);
813		match bytes.get(cursor) {
814			Some(b'}') => return Ok(None),
815			Some(b'"') => {}
816			Some(_) => {
817				return Err(MonochangeError::Config(
818					"expected JSON object key".to_string(),
819				));
820			}
821			None => {
822				return Err(MonochangeError::Config(
823					"unterminated JSON object".to_string(),
824				));
825			}
826		}
827		let (key_span, next) = parse_json_string_span(contents, cursor)?;
828		let key_text = &contents[key_span.start..key_span.end];
829		cursor = skip_json_ws_and_comments(contents, next);
830		if bytes.get(cursor) != Some(&b':') {
831			return Err(MonochangeError::Config(
832				"expected `:` after JSON object key".to_string(),
833			));
834		}
835		cursor = skip_json_ws_and_comments(contents, cursor + 1);
836		let value_start = cursor;
837		let value_end = skip_json_value(contents, value_start)?;
838		if key_text == key {
839			return Ok(Some(JsonSpan {
840				start: value_start,
841				end: value_end,
842			}));
843		}
844		cursor = skip_json_ws_and_comments(contents, value_end);
845		match bytes.get(cursor) {
846			Some(b',') => {
847				cursor += 1;
848			}
849			Some(b'}') => return Ok(None),
850			Some(_) => {
851				return Err(MonochangeError::Config(
852					"expected `,` or `}` after JSON object value".to_string(),
853				));
854			}
855			None => {
856				return Err(MonochangeError::Config(
857					"unterminated JSON object".to_string(),
858				));
859			}
860		}
861	}
862}
863
864fn skip_json_value(contents: &str, start: usize) -> MonochangeResult<usize> {
865	let bytes = contents.as_bytes();
866	let cursor = skip_json_ws_and_comments(contents, start);
867	match bytes.get(cursor) {
868		Some(b'"') => parse_json_string_span(contents, cursor).map(|(_, next)| next),
869		Some(b'{') => skip_json_object(contents, cursor),
870		Some(b'[') => skip_json_array(contents, cursor),
871		Some(_) => Ok(skip_json_primitive(contents, cursor)),
872		None => {
873			Err(MonochangeError::Config(
874				"unexpected end of JSON input".to_string(),
875			))
876		}
877	}
878}
879
880fn skip_json_object(contents: &str, object_start: usize) -> MonochangeResult<usize> {
881	let bytes = contents.as_bytes();
882	let mut cursor = object_start + 1;
883	loop {
884		cursor = skip_json_ws_and_comments(contents, cursor);
885		match bytes.get(cursor) {
886			Some(b'}') => return Ok(cursor + 1),
887			Some(b'"') => {}
888			Some(_) => {
889				return Err(MonochangeError::Config(
890					"expected JSON object key".to_string(),
891				));
892			}
893			None => {
894				return Err(MonochangeError::Config(
895					"unterminated JSON object".to_string(),
896				));
897			}
898		}
899		let (_, next) = parse_json_string_span(contents, cursor)?;
900		cursor = skip_json_ws_and_comments(contents, next);
901		if bytes.get(cursor) != Some(&b':') {
902			return Err(MonochangeError::Config(
903				"expected `:` after JSON object key".to_string(),
904			));
905		}
906		cursor = skip_json_value(contents, cursor + 1)?;
907		cursor = skip_json_ws_and_comments(contents, cursor);
908		match bytes.get(cursor) {
909			Some(b',') => {
910				cursor += 1;
911			}
912			Some(b'}') => return Ok(cursor + 1),
913			Some(_) => {
914				return Err(MonochangeError::Config(
915					"expected `,` or `}` after JSON object value".to_string(),
916				));
917			}
918			None => {
919				return Err(MonochangeError::Config(
920					"unterminated JSON object".to_string(),
921				));
922			}
923		}
924	}
925}
926
927fn skip_json_array(contents: &str, array_start: usize) -> MonochangeResult<usize> {
928	let bytes = contents.as_bytes();
929	let mut cursor = array_start + 1;
930	loop {
931		cursor = skip_json_ws_and_comments(contents, cursor);
932		match bytes.get(cursor) {
933			Some(b']') => return Ok(cursor + 1),
934			Some(_) => {
935				cursor = skip_json_value(contents, cursor)?;
936				cursor = skip_json_ws_and_comments(contents, cursor);
937				match bytes.get(cursor) {
938					Some(b',') => {
939						cursor += 1;
940					}
941					Some(b']') => return Ok(cursor + 1),
942					Some(_) => {
943						return Err(MonochangeError::Config(
944							"expected `,` or `]` after JSON array value".to_string(),
945						));
946					}
947					None => {
948						return Err(MonochangeError::Config(
949							"unterminated JSON array".to_string(),
950						));
951					}
952				}
953			}
954			None => {
955				return Err(MonochangeError::Config(
956					"unterminated JSON array".to_string(),
957				));
958			}
959		}
960	}
961}
962
963fn skip_json_primitive(contents: &str, start: usize) -> usize {
964	let bytes = contents.as_bytes();
965	let mut cursor = start;
966	while let Some(&byte) = bytes.get(cursor) {
967		if matches!(byte, b',' | b'}' | b']') || byte.is_ascii_whitespace() {
968			break;
969		}
970		if byte == b'/' && matches!(bytes.get(cursor + 1), Some(b'/' | b'*')) {
971			break;
972		}
973		cursor += 1;
974	}
975	cursor
976}
977
978fn parse_json_string_span(contents: &str, start: usize) -> MonochangeResult<(JsonSpan, usize)> {
979	let bytes = contents.as_bytes();
980	if bytes.get(start) != Some(&b'"') {
981		return Err(MonochangeError::Config("expected JSON string".to_string()));
982	}
983	let mut cursor = start + 1;
984	while let Some(&byte) = bytes.get(cursor) {
985		if byte == b'\\' {
986			// Escape sequence: verify there is a character after the backslash.
987			let Some(&escape_char) = bytes.get(cursor + 1) else {
988				return Err(MonochangeError::Config(
989					"unterminated escape sequence in JSON string".to_string(),
990				));
991			};
992			if escape_char == b'u' {
993				// Unicode escape \uXXXX requires exactly 4 hex digits.
994				for offset in 2..6 {
995					match bytes.get(cursor + offset) {
996						Some(b) if b.is_ascii_hexdigit() => {}
997						Some(_) => {
998							return Err(MonochangeError::Config(format!(
999								"invalid unicode escape sequence in JSON string: expected hex digit at position {}",
1000								cursor + offset
1001							)));
1002						}
1003						None => {
1004							return Err(MonochangeError::Config(
1005								"incomplete unicode escape sequence in JSON string".to_string(),
1006							));
1007						}
1008					}
1009				}
1010				cursor += 6;
1011			} else {
1012				cursor += 2;
1013			}
1014			continue;
1015		}
1016		if byte == b'"' {
1017			return Ok((
1018				JsonSpan {
1019					start: start + 1,
1020					end: cursor,
1021				},
1022				cursor + 1,
1023			));
1024		}
1025		cursor += 1;
1026	}
1027	Err(MonochangeError::Config(
1028		"unterminated JSON string".to_string(),
1029	))
1030}
1031
1032fn skip_json_ws_and_comments(contents: &str, start: usize) -> usize {
1033	let bytes = contents.as_bytes();
1034	let mut cursor = start;
1035	loop {
1036		while let Some(&byte) = bytes.get(cursor) {
1037			if !byte.is_ascii_whitespace() {
1038				break;
1039			}
1040			cursor += 1;
1041		}
1042		if bytes.get(cursor) == Some(&b'/') && bytes.get(cursor + 1) == Some(&b'/') {
1043			cursor += 2;
1044			while let Some(&byte) = bytes.get(cursor) {
1045				if byte == b'\n' {
1046					break;
1047				}
1048				cursor += 1;
1049			}
1050			continue;
1051		}
1052		if bytes.get(cursor) == Some(&b'/') && bytes.get(cursor + 1) == Some(&b'*') {
1053			cursor += 2;
1054			while bytes.get(cursor).is_some() {
1055				if bytes.get(cursor) == Some(&b'*') && bytes.get(cursor + 1) == Some(&b'/') {
1056					cursor += 2;
1057					break;
1058				}
1059				cursor += 1;
1060			}
1061			continue;
1062		}
1063		break;
1064	}
1065	cursor
1066}
1067
1068fn json_span_is_string(contents: &str, span: JsonSpan) -> bool {
1069	contents.as_bytes().get(span.start) == Some(&b'"')
1070		&& span.end > span.start
1071		&& contents.as_bytes().get(span.end - 1) == Some(&b'"')
1072}
1073
1074fn json_span_is_object(contents: &str, span: JsonSpan) -> bool {
1075	contents.as_bytes().get(span.start) == Some(&b'{')
1076		&& span.end > span.start
1077		&& contents.as_bytes().get(span.end - 1) == Some(&b'}')
1078}
1079
1080#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1081#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
1082pub struct VersionedFileDefinition {
1083	pub path: String,
1084	#[serde(rename = "type", default)]
1085	pub ecosystem_type: Option<EcosystemType>,
1086	#[serde(default)]
1087	pub prefix: Option<String>,
1088	#[serde(default)]
1089	pub fields: Option<Vec<String>>,
1090	#[serde(default)]
1091	pub name: Option<String>,
1092	#[serde(default)]
1093	pub regex: Option<String>,
1094}
1095
1096impl VersionedFileDefinition {
1097	/// Return `true` when the definition uses raw regex replacement mode.
1098	#[must_use]
1099	pub fn uses_regex(&self) -> bool {
1100		self.regex.is_some()
1101	}
1102}
1103
1104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1105#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1106pub enum ChangelogDefinition {
1107	Disabled,
1108	PackageDefault,
1109	PathPattern(String),
1110}
1111
1112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1113#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
1114#[serde(rename_all = "snake_case")]
1115#[non_exhaustive]
1116pub enum ChangelogFormat {
1117	#[default]
1118	Monochange,
1119	KeepAChangelog,
1120}
1121
1122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1123#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1124pub struct ChangelogTarget {
1125	pub path: PathBuf,
1126	#[serde(default)]
1127	pub format: ChangelogFormat,
1128	#[serde(default, skip_serializing_if = "Option::is_none")]
1129	pub initial_header: Option<String>,
1130}
1131
1132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1133#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1134pub struct ReleaseNotesSection {
1135	pub title: String,
1136	#[serde(default)]
1137	pub collapsed: bool,
1138	pub entries: Vec<String>,
1139}
1140
1141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1142#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1143pub struct ReleaseNotesDocument {
1144	pub title: String,
1145	pub summary: Vec<String>,
1146	pub sections: Vec<ReleaseNotesSection>,
1147}
1148
1149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1150#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1151pub struct ChangelogSectionDef {
1152	/// Display heading for the section in rendered changelogs.
1153	pub heading: String,
1154	/// Description of when this section should appear.
1155	#[serde(default)]
1156	pub description: Option<String>,
1157	/// Ordering priority for changelog rendering. Lower values appear first.
1158	#[serde(default = "default_changelog_section_priority")]
1159	pub priority: i8,
1160}
1161
1162fn default_changelog_section_priority() -> i8 {
1163	100
1164}
1165
1166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1167#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1168pub struct ChangelogSectionThresholds {
1169	/// Collapse sections whose priority is greater than or equal to this value.
1170	#[serde(default = "default_changelog_collapse_threshold")]
1171	pub collapse: i8,
1172	/// Omit sections whose priority is strictly greater than this value.
1173	#[serde(default = "default_changelog_ignored_threshold")]
1174	pub ignored: i8,
1175}
1176
1177fn default_changelog_collapse_threshold() -> i8 {
1178	i8::MAX
1179}
1180
1181fn default_changelog_ignored_threshold() -> i8 {
1182	i8::MAX
1183}
1184
1185impl Default for ChangelogSectionThresholds {
1186	fn default() -> Self {
1187		Self {
1188			collapse: default_changelog_collapse_threshold(),
1189			ignored: default_changelog_ignored_threshold(),
1190		}
1191	}
1192}
1193
1194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1195#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1196pub struct ChangelogType {
1197	/// Semver bump severity implied by this changeset type.
1198	#[serde(default = "default_changelog_type_bump")]
1199	pub bump: BumpSeverity,
1200	/// Section key this type routes to (references a `[changelog.sections]` key).
1201	pub section: String,
1202	/// Human-readable description of when to use this type.
1203	#[serde(default)]
1204	pub description: Option<String>,
1205}
1206
1207fn default_changelog_type_bump() -> BumpSeverity {
1208	BumpSeverity::None
1209}
1210
1211/// Top-level `[changelog]` configuration combining templates, sections, and types.
1212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1213#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1214pub struct ChangelogSettings {
1215	#[serde(default)]
1216	pub templates: Vec<String>,
1217	#[serde(default)]
1218	pub sections: BTreeMap<String, ChangelogSectionDef>,
1219	#[serde(default)]
1220	pub section_thresholds: ChangelogSectionThresholds,
1221	#[serde(default)]
1222	pub types: BTreeMap<String, ChangelogType>,
1223}
1224
1225impl Default for ChangelogSettings {
1226	fn default() -> Self {
1227		Self::defaults()
1228	}
1229}
1230
1231impl ChangelogSettings {
1232	/// Return the built-in changelog configuration with default sections and types.
1233	#[must_use]
1234	pub fn defaults() -> Self {
1235		let mut sections = BTreeMap::new();
1236		sections.insert(
1237			"major".to_string(),
1238			ChangelogSectionDef {
1239				heading: "Major".to_string(),
1240				description: Some("Major version bumps".to_string()),
1241				priority: 5,
1242			},
1243		);
1244		sections.insert(
1245			"breaking".to_string(),
1246			ChangelogSectionDef {
1247				heading: "Breaking Change".to_string(),
1248				description: Some("API changes requiring migration".to_string()),
1249				priority: 10,
1250			},
1251		);
1252		sections.insert(
1253			"minor".to_string(),
1254			ChangelogSectionDef {
1255				heading: "Minor".to_string(),
1256				description: Some("Minor version bumps".to_string()),
1257				priority: 15,
1258			},
1259		);
1260		sections.insert(
1261			"feat".to_string(),
1262			ChangelogSectionDef {
1263				heading: "Added".to_string(),
1264				description: Some("New features added".to_string()),
1265				priority: 20,
1266			},
1267		);
1268		sections.insert(
1269			"change".to_string(),
1270			ChangelogSectionDef {
1271				heading: "Changed".to_string(),
1272				description: Some("Changes to existing functionality".to_string()),
1273				priority: 25,
1274			},
1275		);
1276		sections.insert(
1277			"fix".to_string(),
1278			ChangelogSectionDef {
1279				heading: "Fixed".to_string(),
1280				description: Some("Bug fixes".to_string()),
1281				priority: 30,
1282			},
1283		);
1284		sections.insert(
1285			"patch".to_string(),
1286			ChangelogSectionDef {
1287				heading: "Patch".to_string(),
1288				description: Some("Patch version bumps".to_string()),
1289				priority: 35,
1290			},
1291		);
1292		sections.insert(
1293			"test".to_string(),
1294			ChangelogSectionDef {
1295				heading: "Testing".to_string(),
1296				description: Some("Changes that only modify tests".to_string()),
1297				priority: 40,
1298			},
1299		);
1300		sections.insert(
1301			"refactor".to_string(),
1302			ChangelogSectionDef {
1303				heading: "Refactor".to_string(),
1304				description: Some("Code refactoring without functional changes".to_string()),
1305				priority: 40,
1306			},
1307		);
1308		sections.insert(
1309			"docs".to_string(),
1310			ChangelogSectionDef {
1311				heading: "Documentation".to_string(),
1312				description: Some("Changes that only modify documentation".to_string()),
1313				priority: 40,
1314			},
1315		);
1316		sections.insert(
1317			"security".to_string(),
1318			ChangelogSectionDef {
1319				heading: "Security".to_string(),
1320				description: Some("Security-related changes".to_string()),
1321				priority: 40,
1322			},
1323		);
1324		sections.insert(
1325			"perf".to_string(),
1326			ChangelogSectionDef {
1327				heading: "Performance".to_string(),
1328				description: Some("Performance improvements".to_string()),
1329				priority: 40,
1330			},
1331		);
1332		sections.insert(
1333			"none".to_string(),
1334			ChangelogSectionDef {
1335				heading: "None".to_string(),
1336				description: Some("No version bump".to_string()),
1337				priority: 50,
1338			},
1339		);
1340
1341		let mut types = BTreeMap::new();
1342		types.insert(
1343			"breaking".to_string(),
1344			ChangelogType {
1345				bump: BumpSeverity::Major,
1346				section: "breaking".to_string(),
1347				description: Some("Breaking change with major bump".to_string()),
1348			},
1349		);
1350		types.insert(
1351			"major".to_string(),
1352			ChangelogType {
1353				bump: BumpSeverity::Major,
1354				section: "major".to_string(),
1355				description: Some("Major version bump".to_string()),
1356			},
1357		);
1358		types.insert(
1359			"feat".to_string(),
1360			ChangelogType {
1361				bump: BumpSeverity::Minor,
1362				section: "feat".to_string(),
1363				description: Some(String::new()),
1364			},
1365		);
1366		types.insert(
1367			"minor".to_string(),
1368			ChangelogType {
1369				bump: BumpSeverity::Minor,
1370				section: "minor".to_string(),
1371				description: Some("Minor version bump".to_string()),
1372			},
1373		);
1374		types.insert(
1375			"change".to_string(),
1376			ChangelogType {
1377				bump: BumpSeverity::Minor,
1378				section: "change".to_string(),
1379				description: Some(String::new()),
1380			},
1381		);
1382		types.insert(
1383			"fix".to_string(),
1384			ChangelogType {
1385				bump: BumpSeverity::Patch,
1386				section: "fix".to_string(),
1387				description: Some(String::new()),
1388			},
1389		);
1390		types.insert(
1391			"patch".to_string(),
1392			ChangelogType {
1393				bump: BumpSeverity::Patch,
1394				section: "patch".to_string(),
1395				description: Some("Patch version bump".to_string()),
1396			},
1397		);
1398		types.insert(
1399			"refactor".to_string(),
1400			ChangelogType {
1401				bump: BumpSeverity::Patch,
1402				section: "refactor".to_string(),
1403				description: Some(String::new()),
1404			},
1405		);
1406		types.insert(
1407			"test".to_string(),
1408			ChangelogType {
1409				bump: BumpSeverity::None,
1410				section: "test".to_string(),
1411				description: Some(String::new()),
1412			},
1413		);
1414		types.insert(
1415			"none".to_string(),
1416			ChangelogType {
1417				bump: BumpSeverity::None,
1418				section: "none".to_string(),
1419				description: Some("No version bump".to_string()),
1420			},
1421		);
1422		types.insert(
1423			"docs".to_string(),
1424			ChangelogType {
1425				bump: BumpSeverity::None,
1426				section: "docs".to_string(),
1427				description: Some(String::new()),
1428			},
1429		);
1430		types.insert(
1431			"security".to_string(),
1432			ChangelogType {
1433				bump: BumpSeverity::None,
1434				section: "security".to_string(),
1435				description: Some(String::new()),
1436			},
1437		);
1438
1439		Self {
1440			templates: vec![
1441				"#### {{ summary }}\n\n{{ details }}\n\n{{ context }}".to_string(),
1442				"#### {{ summary }}\n\n{{ context }}".to_string(),
1443				"#### {{ summary }}\n\n{{ details }}".to_string(),
1444				"- {{ summary }}".to_string(),
1445			],
1446			sections,
1447			section_thresholds: ChangelogSectionThresholds::default(),
1448			types,
1449		}
1450	}
1451}
1452
1453#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1454#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
1455#[serde(rename_all = "snake_case")]
1456#[non_exhaustive]
1457pub enum PublishMode {
1458	#[default]
1459	Builtin,
1460	External,
1461}
1462
1463impl PublishMode {
1464	/// Return the canonical serialized name for the publish mode.
1465	#[must_use]
1466	pub fn as_str(self) -> &'static str {
1467		match self {
1468			Self::Builtin => "builtin",
1469			Self::External => "external",
1470		}
1471	}
1472}
1473
1474impl fmt::Display for PublishMode {
1475	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1476		formatter.write_str(self.as_str())
1477	}
1478}
1479
1480#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1481#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1482#[serde(rename_all = "snake_case")]
1483#[non_exhaustive]
1484pub enum RegistryKind {
1485	CratesIo,
1486	Npm,
1487	Jsr,
1488	PubDev,
1489	Pypi,
1490	GoProxy,
1491}
1492
1493impl RegistryKind {
1494	/// Return the canonical serialized name for the registry.
1495	#[must_use]
1496	pub fn as_str(self) -> &'static str {
1497		match self {
1498			Self::CratesIo => "crates_io",
1499			Self::Npm => "npm",
1500			Self::Jsr => "jsr",
1501			Self::PubDev => "pub_dev",
1502			Self::Pypi => "pypi",
1503			Self::GoProxy => "go_proxy",
1504		}
1505	}
1506}
1507
1508impl fmt::Display for RegistryKind {
1509	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1510		formatter.write_str(self.as_str())
1511	}
1512}
1513
1514#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1515#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1516#[serde(untagged)]
1517pub enum PublishRegistry {
1518	Builtin(RegistryKind),
1519	Custom(String),
1520}
1521
1522#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1523#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1524pub struct PlaceholderSettings {
1525	#[serde(default)]
1526	pub readme: Option<String>,
1527	#[serde(default)]
1528	pub readme_file: Option<PathBuf>,
1529}
1530
1531#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1532#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1533pub struct PublishRateLimitSettings {
1534	#[serde(default)]
1535	pub enforce: bool,
1536}
1537
1538#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1539#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1540pub struct TrustedPublishingSettings {
1541	#[serde(default = "default_true")]
1542	pub enabled: bool,
1543	#[serde(default)]
1544	pub repository: Option<String>,
1545	#[serde(default)]
1546	pub workflow: Option<String>,
1547	#[serde(default)]
1548	pub environment: Option<String>,
1549}
1550
1551#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1552#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1553pub struct PublishAttestationSettings {
1554	#[serde(default)]
1555	pub require_registry_provenance: bool,
1556}
1557
1558impl PublishAttestationSettings {
1559	#[must_use]
1560	pub fn is_default(&self) -> bool {
1561		self == &Self::default()
1562	}
1563}
1564
1565#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1566#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1567pub struct ReleaseAttestationSettings {
1568	#[serde(default)]
1569	pub require_github_artifact_attestations: bool,
1570}
1571
1572impl ReleaseAttestationSettings {
1573	#[must_use]
1574	pub fn is_default(&self) -> bool {
1575		self == &Self::default()
1576	}
1577}
1578
1579impl Default for TrustedPublishingSettings {
1580	fn default() -> Self {
1581		Self {
1582			enabled: true,
1583			repository: None,
1584			workflow: None,
1585			environment: None,
1586		}
1587	}
1588}
1589
1590#[allow(clippy::struct_excessive_bools)]
1591#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1592#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1593pub struct PublishSettings {
1594	#[serde(default = "default_true")]
1595	pub enabled: bool,
1596	#[serde(default)]
1597	pub mode: PublishMode,
1598	#[serde(default)]
1599	pub registry: Option<PublishRegistry>,
1600	#[serde(default)]
1601	pub trusted_publishing: TrustedPublishingSettings,
1602	#[serde(
1603		default,
1604		skip_serializing_if = "PublishAttestationSettings::is_default"
1605	)]
1606	pub attestations: PublishAttestationSettings,
1607	#[serde(default)]
1608	pub rate_limits: PublishRateLimitSettings,
1609	#[serde(default)]
1610	pub placeholder: PlaceholderSettings,
1611}
1612
1613impl Default for PublishSettings {
1614	fn default() -> Self {
1615		Self {
1616			enabled: true,
1617			mode: PublishMode::default(),
1618			registry: None,
1619			trusted_publishing: TrustedPublishingSettings::default(),
1620			attestations: PublishAttestationSettings::default(),
1621			rate_limits: PublishRateLimitSettings::default(),
1622			placeholder: PlaceholderSettings::default(),
1623		}
1624	}
1625}
1626
1627#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1628#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1629pub struct PackageDefinition {
1630	pub id: String,
1631	pub path: PathBuf,
1632	pub package_type: PackageType,
1633	pub changelog: Option<ChangelogTarget>,
1634	pub excluded_changelog_types: Vec<String>,
1635	pub empty_update_message: Option<String>,
1636	#[serde(default)]
1637	pub release_title: Option<String>,
1638	#[serde(default)]
1639	pub changelog_version_title: Option<String>,
1640	pub versioned_files: Vec<VersionedFileDefinition>,
1641	#[serde(default)]
1642	pub ignore_ecosystem_versioned_files: bool,
1643	#[serde(default)]
1644	pub ignored_paths: Vec<String>,
1645	#[serde(default)]
1646	pub additional_paths: Vec<String>,
1647	pub tag: bool,
1648	pub release: bool,
1649	pub version_format: VersionFormat,
1650	#[serde(default)]
1651	pub publish: PublishSettings,
1652}
1653
1654#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1655#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1656pub enum GroupChangelogInclude {
1657	#[default]
1658	All,
1659	GroupOnly,
1660	Selected(BTreeSet<String>),
1661}
1662
1663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1664#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1665pub struct GroupDefinition {
1666	pub id: String,
1667	pub packages: Vec<String>,
1668	pub changelog: Option<ChangelogTarget>,
1669	#[serde(default)]
1670	pub changelog_include: GroupChangelogInclude,
1671	pub excluded_changelog_types: Vec<String>,
1672	pub empty_update_message: Option<String>,
1673	#[serde(default)]
1674	pub release_title: Option<String>,
1675	#[serde(default)]
1676	pub changelog_version_title: Option<String>,
1677	pub versioned_files: Vec<VersionedFileDefinition>,
1678	pub tag: bool,
1679	pub release: bool,
1680	pub version_format: VersionFormat,
1681}
1682
1683#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1684#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1685pub struct WorkspaceDefaults {
1686	pub parent_bump: BumpSeverity,
1687	pub include_private: bool,
1688	pub warn_on_group_mismatch: bool,
1689	pub strict_version_conflicts: bool,
1690	pub package_type: Option<PackageType>,
1691	pub changelog: Option<ChangelogDefinition>,
1692	pub changelog_format: ChangelogFormat,
1693	pub empty_update_message: Option<String>,
1694	pub release_title: Option<String>,
1695	pub changelog_version_title: Option<String>,
1696}
1697
1698impl Default for WorkspaceDefaults {
1699	fn default() -> Self {
1700		Self {
1701			parent_bump: BumpSeverity::Patch,
1702			include_private: false,
1703			warn_on_group_mismatch: true,
1704			strict_version_conflicts: false,
1705			package_type: None,
1706			changelog: None,
1707			changelog_format: ChangelogFormat::Monochange,
1708			empty_update_message: None,
1709			release_title: None,
1710			changelog_version_title: None,
1711		}
1712	}
1713}
1714
1715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1716#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1717pub struct EcosystemSettings {
1718	#[serde(default)]
1719	pub enabled: Option<bool>,
1720	#[serde(default)]
1721	pub roots: Vec<String>,
1722	#[serde(default)]
1723	pub exclude: Vec<String>,
1724	#[serde(default)]
1725	pub dependency_version_prefix: Option<String>,
1726	#[serde(default)]
1727	pub versioned_files: Vec<VersionedFileDefinition>,
1728	#[serde(default)]
1729	pub lockfile_commands: Vec<LockfileCommandDefinition>,
1730	#[serde(default)]
1731	pub publish: PublishSettings,
1732}
1733
1734#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1735#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1736pub struct LockfileCommandDefinition {
1737	pub command: String,
1738	#[serde(default)]
1739	pub cwd: Option<PathBuf>,
1740	#[serde(default)]
1741	pub shell: ShellConfig,
1742}
1743
1744#[derive(Debug, Clone, Eq, PartialEq)]
1745pub struct LockfileCommandExecution {
1746	pub command: String,
1747	pub cwd: PathBuf,
1748	pub shell: ShellConfig,
1749}
1750
1751#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1752#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
1753#[serde(rename_all = "snake_case")]
1754pub enum CliInputKind {
1755	String,
1756	StringList,
1757	Path,
1758	Choice,
1759	Boolean,
1760}
1761
1762#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1763#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1764pub struct CliInputDefinition {
1765	pub name: String,
1766	#[serde(rename = "type")]
1767	pub kind: CliInputKind,
1768	#[serde(default)]
1769	pub help_text: Option<String>,
1770	#[serde(default)]
1771	pub required: bool,
1772	#[serde(default, deserialize_with = "deserialize_cli_input_default")]
1773	#[cfg_attr(feature = "schema", schemars(with = "Option<CliInputDefault>"))]
1774	pub default: Option<String>,
1775	#[serde(default)]
1776	pub choices: Vec<String>,
1777	#[serde(default)]
1778	pub short: Option<char>,
1779}
1780
1781#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1782#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1783#[serde(untagged)]
1784pub enum CliInputDefault {
1785	String(String),
1786	Boolean(bool),
1787	Integer(i64),
1788	Number(f64),
1789}
1790
1791fn deserialize_cli_input_default<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
1792where
1793	D: serde::Deserializer<'de>,
1794{
1795	let value = Option::<CliInputDefault>::deserialize(deserializer)?;
1796	Ok(value.map(|value| {
1797		match value {
1798			CliInputDefault::String(value) => value,
1799			CliInputDefault::Boolean(value) => value.to_string(),
1800			CliInputDefault::Integer(value) => value.to_string(),
1801			CliInputDefault::Number(value) => value.to_string(),
1802		}
1803	}))
1804}
1805
1806#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1807#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1808#[serde(untagged)]
1809pub enum CliStepInputValue {
1810	String(String),
1811	Boolean(bool),
1812	List(Vec<String>),
1813}
1814#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1815#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
1816#[serde(rename_all = "snake_case")]
1817pub enum CommandVariable {
1818	Version,
1819	GroupVersion,
1820	ReleasedPackages,
1821	ChangedFiles,
1822	Changesets,
1823}
1824
1825/// Shell configuration for `Command` steps.
1826#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1827#[derive(Debug, Clone, Eq, PartialEq, Default)]
1828pub enum ShellConfig {
1829	#[default]
1830	None,
1831	Default,
1832	Custom(String),
1833}
1834
1835impl ShellConfig {
1836	/// Return the shell binary used to execute a `Command` step, if any.
1837	#[must_use]
1838	pub fn shell_binary(&self) -> Option<&str> {
1839		match self {
1840			Self::None => None,
1841			Self::Default => Some("sh"),
1842			Self::Custom(shell) => Some(shell),
1843		}
1844	}
1845}
1846
1847impl Serialize for ShellConfig {
1848	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1849		match self {
1850			Self::None => serializer.serialize_bool(false),
1851			Self::Default => serializer.serialize_bool(true),
1852			Self::Custom(shell) => serializer.serialize_str(shell),
1853		}
1854	}
1855}
1856
1857impl<'de> Deserialize<'de> for ShellConfig {
1858	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1859		use serde::de;
1860
1861		struct ShellConfigVisitor;
1862
1863		impl de::Visitor<'_> for ShellConfigVisitor {
1864			type Value = ShellConfig;
1865
1866			fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1867				formatter.write_str("a boolean or a shell name string")
1868			}
1869
1870			fn visit_bool<E: de::Error>(self, value: bool) -> Result<ShellConfig, E> {
1871				Ok(if value {
1872					ShellConfig::Default
1873				} else {
1874					ShellConfig::None
1875				})
1876			}
1877
1878			fn visit_str<E: de::Error>(self, value: &str) -> Result<ShellConfig, E> {
1879				if value.is_empty() {
1880					return Err(de::Error::invalid_value(
1881						de::Unexpected::Str(value),
1882						&"a non-empty shell name",
1883					));
1884				}
1885				Ok(ShellConfig::Custom(value.to_string()))
1886			}
1887
1888			fn visit_string<E: de::Error>(self, value: String) -> Result<ShellConfig, E> {
1889				self.visit_str(&value)
1890			}
1891		}
1892
1893		deserializer.deserialize_any(ShellConfigVisitor)
1894	}
1895}
1896
1897/// Built-in execution units for `[[cli.<command>.steps]]`.
1898///
1899/// `monochange` runs steps in order and lets later steps consume state created by
1900/// earlier ones. Use standalone steps such as `Validate`, `Discover`,
1901/// `AffectedPackages`, `DiagnoseChangesets`, and `RetargetRelease` when you want
1902/// inspection or repair. Use `PrepareRelease` when later steps need structured
1903/// release state.
1904///
1905/// See the CLI step reference in the book for full workflow guidance.
1906#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1907#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1908#[serde(tag = "type", deny_unknown_fields)]
1909#[non_exhaustive]
1910pub enum CliStepDefinition {
1911	/// Expose the resolved `monochange` configuration and workspace root.
1912	Config {
1913		#[serde(default)]
1914		name: Option<String>,
1915		#[serde(default)]
1916		when: Option<String>,
1917		#[serde(default)]
1918		always_run: bool,
1919		#[serde(default)]
1920		inputs: BTreeMap<String, CliStepInputValue>,
1921	},
1922	/// Validate `monochange` configuration and changesets, and run lint rules
1923	/// on package manifests.
1924	Validate {
1925		#[serde(default)]
1926		name: Option<String>,
1927		#[serde(default)]
1928		when: Option<String>,
1929		#[serde(default)]
1930		always_run: bool,
1931		#[serde(default)]
1932		inputs: BTreeMap<String, CliStepInputValue>,
1933	},
1934	/// Discover packages across supported ecosystems and render the result.
1935	Discover {
1936		#[serde(default)]
1937		name: Option<String>,
1938		#[serde(default)]
1939		when: Option<String>,
1940		#[serde(default)]
1941		always_run: bool,
1942		#[serde(default)]
1943		inputs: BTreeMap<String, CliStepInputValue>,
1944	},
1945	/// Display planned package and group versions without mutating release files.
1946	DisplayVersions {
1947		#[serde(default)]
1948		name: Option<String>,
1949		#[serde(default)]
1950		when: Option<String>,
1951		#[serde(default)]
1952		always_run: bool,
1953		#[serde(default)]
1954		inputs: BTreeMap<String, CliStepInputValue>,
1955	},
1956	/// Create a `.changeset/*.md` file from typed CLI inputs or interactive
1957	/// prompts.
1958	CreateChangeFile {
1959		#[serde(default)]
1960		name: Option<String>,
1961		#[serde(default)]
1962		when: Option<String>,
1963		#[serde(default)]
1964		always_run: bool,
1965		#[serde(default)]
1966		show_progress: Option<bool>,
1967		#[serde(default)]
1968		inputs: BTreeMap<String, CliStepInputValue>,
1969	},
1970	/// Prepare a release and expose structured `release.*` context to later
1971	/// steps.
1972	PrepareRelease {
1973		#[serde(default)]
1974		name: Option<String>,
1975		#[serde(default)]
1976		when: Option<String>,
1977		#[serde(default)]
1978		always_run: bool,
1979		#[serde(default)]
1980		inputs: BTreeMap<String, CliStepInputValue>,
1981		/// When true, do not error when there are no pending changesets.
1982		/// Instead, succeed with zero changesets, allowing downstream steps
1983		/// to check `number_of_changesets` in their `when` conditions.
1984		#[serde(default)]
1985		allow_empty_changesets: bool,
1986	},
1987	/// Create a local release commit with an embedded durable `ReleaseRecord`.
1988	///
1989	/// Requires a previous `PrepareRelease` step.
1990	CommitRelease {
1991		#[serde(default)]
1992		name: Option<String>,
1993		#[serde(default)]
1994		when: Option<String>,
1995		#[serde(default)]
1996		always_run: bool,
1997		#[serde(default)]
1998		no_verify: bool,
1999		#[serde(default)]
2000		update_release_json: bool,
2001		#[serde(default)]
2002		inputs: BTreeMap<String, CliStepInputValue>,
2003	},
2004	/// Verify a commit is reachable from one of the configured release branches.
2005	VerifyReleaseBranch {
2006		#[serde(default)]
2007		name: Option<String>,
2008		#[serde(default)]
2009		when: Option<String>,
2010		#[serde(default)]
2011		always_run: bool,
2012		#[serde(default)]
2013		inputs: BTreeMap<String, CliStepInputValue>,
2014	},
2015	/// Publish hosted releases from a prepared `monochange` release.
2016	///
2017	/// Requires a previous `PrepareRelease` step and `[source]`
2018	/// configuration.
2019	PublishRelease {
2020		#[serde(default)]
2021		name: Option<String>,
2022		#[serde(default)]
2023		when: Option<String>,
2024		#[serde(default)]
2025		always_run: bool,
2026		#[serde(default)]
2027		inputs: BTreeMap<String, CliStepInputValue>,
2028	},
2029	/// Publish placeholder package versions for missing registry packages.
2030	PlaceholderPublish {
2031		#[serde(default)]
2032		name: Option<String>,
2033		#[serde(default)]
2034		when: Option<String>,
2035		#[serde(default)]
2036		always_run: bool,
2037		#[serde(default)]
2038		inputs: BTreeMap<String, CliStepInputValue>,
2039	},
2040	/// Publish package versions from durable monochange release state.
2041	PublishPackages {
2042		#[serde(default)]
2043		name: Option<String>,
2044		#[serde(default)]
2045		when: Option<String>,
2046		#[serde(default)]
2047		always_run: bool,
2048		#[serde(default)]
2049		inputs: BTreeMap<String, CliStepInputValue>,
2050	},
2051	/// Plan package-registry rate-limit windows for publish operations.
2052	PlanPublishRateLimits {
2053		#[serde(default)]
2054		name: Option<String>,
2055		#[serde(default)]
2056		when: Option<String>,
2057		#[serde(default)]
2058		always_run: bool,
2059		#[serde(default)]
2060		inputs: BTreeMap<String, CliStepInputValue>,
2061	},
2062	/// Open or update a hosted release request from prepared release state.
2063	///
2064	/// Requires a previous `PrepareRelease` step and `[source]`
2065	/// configuration.
2066	OpenReleaseRequest {
2067		#[serde(default)]
2068		name: Option<String>,
2069		#[serde(default)]
2070		when: Option<String>,
2071		#[serde(default)]
2072		always_run: bool,
2073		#[serde(default)]
2074		no_verify: bool,
2075		#[serde(default)]
2076		inputs: BTreeMap<String, CliStepInputValue>,
2077	},
2078	/// Comment on linked released issues after a prepared release.
2079	///
2080	/// Requires a previous `PrepareRelease` step and currently expects
2081	/// `[source].provider = "github"`.
2082	CommentReleasedIssues {
2083		#[serde(default)]
2084		name: Option<String>,
2085		#[serde(default)]
2086		when: Option<String>,
2087		#[serde(default)]
2088		always_run: bool,
2089		#[serde(default)]
2090		inputs: BTreeMap<String, CliStepInputValue>,
2091	},
2092	/// Evaluate affected packages and changeset coverage for changed files.
2093	///
2094	/// Standalone CI-oriented step.
2095	AffectedPackages {
2096		#[serde(default)]
2097		name: Option<String>,
2098		#[serde(default)]
2099		when: Option<String>,
2100		#[serde(default)]
2101		always_run: bool,
2102		#[serde(default)]
2103		inputs: BTreeMap<String, CliStepInputValue>,
2104	},
2105	/// Inspect parsed changeset data, provenance, and linked metadata.
2106	DiagnoseChangesets {
2107		#[serde(default)]
2108		name: Option<String>,
2109		#[serde(default)]
2110		when: Option<String>,
2111		#[serde(default)]
2112		always_run: bool,
2113		#[serde(default)]
2114		inputs: BTreeMap<String, CliStepInputValue>,
2115	},
2116	/// Repair a recent release by retargeting its stored release tag set.
2117	///
2118	/// This step is independent from `PrepareRelease` and exposes structured
2119	/// `retarget.*` state to later commands.
2120	RetargetRelease {
2121		#[serde(default)]
2122		name: Option<String>,
2123		#[serde(default)]
2124		when: Option<String>,
2125		#[serde(default)]
2126		always_run: bool,
2127		#[serde(default)]
2128		inputs: BTreeMap<String, CliStepInputValue>,
2129	},
2130	/// Run an arbitrary command with `monochange` template context.
2131	///
2132	/// Use this to bridge built-in `monochange` state into external tooling.
2133	Command {
2134		#[serde(default)]
2135		name: Option<String>,
2136		#[serde(default)]
2137		when: Option<String>,
2138		#[serde(default)]
2139		always_run: bool,
2140		#[serde(default)]
2141		show_progress: Option<bool>,
2142		command: String,
2143		#[serde(default)]
2144		dry_run_command: Option<String>,
2145		#[serde(default)]
2146		shell: ShellConfig,
2147		#[serde(default)]
2148		id: Option<String>,
2149		#[serde(default)]
2150		variables: Option<BTreeMap<String, CommandVariable>>,
2151		#[serde(default)]
2152		inputs: BTreeMap<String, CliStepInputValue>,
2153	},
2154}
2155
2156impl CliStepDefinition {
2157	/// Return the step-local input overrides configured for this step.
2158	#[must_use]
2159	pub fn inputs(&self) -> &BTreeMap<String, CliStepInputValue> {
2160		match self {
2161			Self::Config { inputs, .. }
2162			| Self::Validate { inputs, .. }
2163			| Self::Discover { inputs, .. }
2164			| Self::DisplayVersions { inputs, .. }
2165			| Self::CreateChangeFile { inputs, .. }
2166			| Self::PrepareRelease { inputs, .. }
2167			| Self::CommitRelease { inputs, .. }
2168			| Self::VerifyReleaseBranch { inputs, .. }
2169			| Self::PublishRelease { inputs, .. }
2170			| Self::PlaceholderPublish { inputs, .. }
2171			| Self::PublishPackages { inputs, .. }
2172			| Self::PlanPublishRateLimits { inputs, .. }
2173			| Self::OpenReleaseRequest { inputs, .. }
2174			| Self::CommentReleasedIssues { inputs, .. }
2175			| Self::AffectedPackages { inputs, .. }
2176			| Self::DiagnoseChangesets { inputs, .. }
2177			| Self::RetargetRelease { inputs, .. }
2178			| Self::Command { inputs, .. } => inputs,
2179		}
2180	}
2181
2182	/// Return the optional configured display name for this step.
2183	#[must_use]
2184	pub fn name(&self) -> Option<&str> {
2185		match self {
2186			Self::Config { name, .. }
2187			| Self::Validate { name, .. }
2188			| Self::Discover { name, .. }
2189			| Self::DisplayVersions { name, .. }
2190			| Self::CreateChangeFile { name, .. }
2191			| Self::PrepareRelease { name, .. }
2192			| Self::CommitRelease { name, .. }
2193			| Self::VerifyReleaseBranch { name, .. }
2194			| Self::PublishRelease { name, .. }
2195			| Self::PlaceholderPublish { name, .. }
2196			| Self::PublishPackages { name, .. }
2197			| Self::PlanPublishRateLimits { name, .. }
2198			| Self::OpenReleaseRequest { name, .. }
2199			| Self::CommentReleasedIssues { name, .. }
2200			| Self::AffectedPackages { name, .. }
2201			| Self::DiagnoseChangesets { name, .. }
2202			| Self::RetargetRelease { name, .. }
2203			| Self::Command { name, .. } => name.as_deref(),
2204		}
2205	}
2206
2207	/// Return the label shown in human-readable progress output.
2208	#[must_use]
2209	pub fn display_name(&self) -> &str {
2210		self.name().unwrap_or(self.kind_name())
2211	}
2212
2213	/// Return the optional `when` condition for this step.
2214	#[must_use]
2215	pub fn when(&self) -> Option<&str> {
2216		match self {
2217			Self::Config { when, .. }
2218			| Self::Validate { when, .. }
2219			| Self::Discover { when, .. }
2220			| Self::DisplayVersions { when, .. }
2221			| Self::CreateChangeFile { when, .. }
2222			| Self::PrepareRelease { when, .. }
2223			| Self::CommitRelease { when, .. }
2224			| Self::VerifyReleaseBranch { when, .. }
2225			| Self::PublishRelease { when, .. }
2226			| Self::PlaceholderPublish { when, .. }
2227			| Self::PublishPackages { when, .. }
2228			| Self::PlanPublishRateLimits { when, .. }
2229			| Self::OpenReleaseRequest { when, .. }
2230			| Self::CommentReleasedIssues { when, .. }
2231			| Self::AffectedPackages { when, .. }
2232			| Self::DiagnoseChangesets { when, .. }
2233			| Self::RetargetRelease { when, .. }
2234			| Self::Command { when, .. } => when.as_deref(),
2235		}
2236	}
2237
2238	/// Return whether this step should always execute even when earlier
2239	/// steps fail.
2240	#[must_use]
2241	pub fn always_run(&self) -> bool {
2242		match self {
2243			Self::Config { always_run, .. }
2244			| Self::Validate { always_run, .. }
2245			| Self::Discover { always_run, .. }
2246			| Self::DisplayVersions { always_run, .. }
2247			| Self::CreateChangeFile { always_run, .. }
2248			| Self::PrepareRelease { always_run, .. }
2249			| Self::CommitRelease { always_run, .. }
2250			| Self::VerifyReleaseBranch { always_run, .. }
2251			| Self::PublishRelease { always_run, .. }
2252			| Self::PlaceholderPublish { always_run, .. }
2253			| Self::PublishPackages { always_run, .. }
2254			| Self::PlanPublishRateLimits { always_run, .. }
2255			| Self::OpenReleaseRequest { always_run, .. }
2256			| Self::CommentReleasedIssues { always_run, .. }
2257			| Self::AffectedPackages { always_run, .. }
2258			| Self::DiagnoseChangesets { always_run, .. }
2259			| Self::RetargetRelease { always_run, .. }
2260			| Self::Command { always_run, .. } => *always_run,
2261		}
2262	}
2263
2264	/// Return whether progress output is explicitly enabled or disabled.
2265	#[must_use]
2266	pub fn show_progress(&self) -> Option<bool> {
2267		match self {
2268			Self::CreateChangeFile { show_progress, .. } | Self::Command { show_progress, .. } => {
2269				*show_progress
2270			}
2271			_ => None,
2272		}
2273	}
2274
2275	/// Return the built-in step kind name.
2276	#[must_use]
2277	pub fn kind_name(&self) -> &'static str {
2278		match self {
2279			Self::Config { .. } => "Config",
2280			Self::Validate { .. } => "Validate",
2281			Self::Discover { .. } => "Discover",
2282			Self::DisplayVersions { .. } => "DisplayVersions",
2283			Self::CreateChangeFile { .. } => "CreateChangeFile",
2284			Self::PrepareRelease { .. } => "PrepareRelease",
2285			Self::CommitRelease { .. } => "CommitRelease",
2286			Self::VerifyReleaseBranch { .. } => "VerifyReleaseBranch",
2287			Self::PublishRelease { .. } => "PublishRelease",
2288			Self::PlaceholderPublish { .. } => "PlaceholderPublish",
2289			Self::PublishPackages { .. } => "PublishPackages",
2290			Self::PlanPublishRateLimits { .. } => "PlanPublishRateLimits",
2291			Self::OpenReleaseRequest { .. } => "OpenReleaseRequest",
2292			Self::CommentReleasedIssues { .. } => "CommentReleasedIssues",
2293			Self::AffectedPackages { .. } => "AffectedPackages",
2294			Self::DiagnoseChangesets { .. } => "DiagnoseChangesets",
2295			Self::RetargetRelease { .. } => "RetargetRelease",
2296			Self::Command { .. } => "Command",
2297		}
2298	}
2299
2300	/// Returns the set of input names that this step kind recognises.
2301	///
2302	/// `Command` steps accept any input (returns `None`).
2303	/// All built-in step kinds return `Some(…)` with the exhaustive set of
2304	/// input names they consume at runtime.
2305	#[must_use]
2306	pub fn valid_input_names(&self) -> Option<&'static [&'static str]> {
2307		match self {
2308			Self::Config { .. } => Some(&[]),
2309			Self::Validate { .. } => Some(&["fix"]),
2310			Self::CommitRelease { .. } => Some(&["no_verify", "update_release_json"]),
2311			Self::VerifyReleaseBranch { .. } => Some(&["from"]),
2312			Self::Discover { .. } | Self::DisplayVersions { .. } | Self::PrepareRelease { .. } => {
2313				Some(&["format"])
2314			}
2315			Self::CommentReleasedIssues { .. } => {
2316				Some(&["format", "from-ref", "auto-close-issues"])
2317			}
2318			Self::PublishRelease { .. } => Some(&["format", "from-ref", "draft"]),
2319			Self::OpenReleaseRequest { .. } => Some(&["format", "no_verify"]),
2320			Self::PlaceholderPublish { .. } => Some(&["format", "package", "show-all"]),
2321			Self::PublishPackages { .. } => {
2322				Some(&[
2323					"format",
2324					"output",
2325					"package",
2326					"group",
2327					"ecosystem",
2328					"resume",
2329				])
2330			}
2331			Self::PlanPublishRateLimits { .. } => {
2332				Some(&["format", "mode", "package", "ci", "readiness"])
2333			}
2334			Self::CreateChangeFile { .. } => {
2335				Some(&[
2336					"interactive",
2337					"package",
2338					"bump",
2339					"version",
2340					"reason",
2341					"type",
2342					"details",
2343					"output",
2344				])
2345			}
2346			Self::AffectedPackages { .. } => {
2347				Some(&["format", "changed_paths", "from", "verify", "label"])
2348			}
2349			Self::DiagnoseChangesets { .. } => Some(&["format", "changeset"]),
2350			Self::RetargetRelease { .. } => Some(&["from", "target", "force", "sync_provider"]),
2351			Self::Command { .. } => None,
2352		}
2353	}
2354
2355	/// Returns the valid choice values for a named input on this step, if any.
2356	#[must_use]
2357	pub fn valid_input_choices(&self, name: &str) -> Option<&'static [&'static str]> {
2358		match self {
2359			Self::Discover { .. }
2360			| Self::DisplayVersions { .. }
2361			| Self::PrepareRelease { .. }
2362			| Self::PublishRelease { .. }
2363			| Self::CommentReleasedIssues { .. }
2364			| Self::OpenReleaseRequest { .. }
2365			| Self::AffectedPackages { .. }
2366			| Self::DiagnoseChangesets { .. }
2367			| Self::PlaceholderPublish { .. }
2368			| Self::PublishPackages { .. } => {
2369				match name {
2370					"format" => Some(&["text", "json", "md"]),
2371					_ => None,
2372				}
2373			}
2374			Self::PlanPublishRateLimits { .. } => {
2375				match name {
2376					"format" => Some(&["text", "json", "md"]),
2377					"mode" => Some(&["local", "ci"]),
2378					"ci" => Some(&["github", "gitlab", "generic"]),
2379					_ => None,
2380				}
2381			}
2382			Self::CreateChangeFile { .. } => {
2383				match name {
2384					"bump" => Some(&["major", "minor", "patch", "none"]),
2385					_ => None,
2386				}
2387			}
2388			Self::RetargetRelease { .. } | _ => None,
2389		}
2390	}
2391
2392	/// Returns the expected [`CliInputKind`] for a named input on this step,
2393	/// or `None` when the step is a `Command` (accepts anything) or the name
2394	/// is unrecognised.
2395	#[must_use]
2396	pub fn expected_input_kind(&self, name: &str) -> Option<CliInputKind> {
2397		match self {
2398			Self::Validate { .. } => {
2399				match name {
2400					"fix" => Some(CliInputKind::Boolean),
2401					_ => None,
2402				}
2403			}
2404			Self::CommitRelease { .. } => {
2405				match name {
2406					"no_verify" | "update_release_json" => Some(CliInputKind::Boolean),
2407					_ => None,
2408				}
2409			}
2410			Self::VerifyReleaseBranch { .. } => {
2411				match name {
2412					"from" => Some(CliInputKind::String),
2413					_ => None,
2414				}
2415			}
2416			Self::Config { .. } | Self::Command { .. } => None,
2417			Self::Discover { .. } | Self::DisplayVersions { .. } | Self::PrepareRelease { .. } => {
2418				matches!(name, "format").then_some(CliInputKind::Choice)
2419			}
2420			Self::CommentReleasedIssues { .. } => {
2421				match name {
2422					"format" => Some(CliInputKind::Choice),
2423					"from-ref" => Some(CliInputKind::String),
2424					"auto-close-issues" => Some(CliInputKind::Boolean),
2425					_ => None,
2426				}
2427			}
2428			Self::PublishRelease { .. } => {
2429				match name {
2430					"format" => Some(CliInputKind::Choice),
2431					"from-ref" => Some(CliInputKind::String),
2432					"draft" => Some(CliInputKind::Boolean),
2433					_ => None,
2434				}
2435			}
2436			Self::OpenReleaseRequest { .. } => {
2437				match name {
2438					"format" => Some(CliInputKind::Choice),
2439					"no_verify" => Some(CliInputKind::Boolean),
2440					_ => None,
2441				}
2442			}
2443			Self::PlaceholderPublish { .. } => {
2444				match name {
2445					"format" => Some(CliInputKind::Choice),
2446					"package" => Some(CliInputKind::StringList),
2447					"show-all" => Some(CliInputKind::Boolean),
2448					_ => None,
2449				}
2450			}
2451			Self::PublishPackages { .. } => {
2452				match name {
2453					"format" => Some(CliInputKind::Choice),
2454					"package" => Some(CliInputKind::StringList),
2455					"output" | "resume" => Some(CliInputKind::Path),
2456					_ => None,
2457				}
2458			}
2459			Self::PlanPublishRateLimits { .. } => {
2460				match name {
2461					"package" => Some(CliInputKind::StringList),
2462					"readiness" => Some(CliInputKind::Path),
2463					"format" | "mode" | "ci" => Some(CliInputKind::Choice),
2464					_ => None,
2465				}
2466			}
2467			Self::CreateChangeFile { .. } => {
2468				match name {
2469					"interactive" => Some(CliInputKind::Boolean),
2470					"package" => Some(CliInputKind::StringList),
2471					"bump" => Some(CliInputKind::Choice),
2472					"version" | "reason" | "type" | "details" => Some(CliInputKind::String),
2473					"output" => Some(CliInputKind::Path),
2474					_ => None,
2475				}
2476			}
2477			Self::AffectedPackages { .. } => {
2478				match name {
2479					"format" => Some(CliInputKind::Choice),
2480					"changed_paths" | "label" => Some(CliInputKind::StringList),
2481					"from" => Some(CliInputKind::String),
2482					"verify" => Some(CliInputKind::Boolean),
2483					_ => None,
2484				}
2485			}
2486			Self::DiagnoseChangesets { .. } => {
2487				match name {
2488					"format" => Some(CliInputKind::Choice),
2489					"changeset" => Some(CliInputKind::StringList),
2490					_ => None,
2491				}
2492			}
2493			Self::RetargetRelease { .. } => {
2494				match name {
2495					"from" | "target" => Some(CliInputKind::String),
2496					"force" | "sync_provider" => Some(CliInputKind::Boolean),
2497					_ => None,
2498				}
2499			}
2500		}
2501	}
2502
2503	pub fn step_kebab_name(&self) -> String {
2504		let name = self.kind_name();
2505		let mut result = String::new();
2506		let mut prev_upper = false;
2507		for ch in name.chars() {
2508			if ch.is_uppercase() {
2509				if !result.is_empty() && !prev_upper {
2510					result.push('-');
2511				}
2512				result.push(ch.to_ascii_lowercase());
2513				prev_upper = true;
2514			} else {
2515				result.push(ch);
2516				prev_upper = false;
2517			}
2518		}
2519		result
2520	}
2521
2522	/// Return the set of input definitions for this step kind.
2523	#[must_use]
2524	pub fn step_inputs_schema(&self) -> Vec<CliInputDefinition> {
2525		let Some(names) = self.valid_input_names() else {
2526			return Vec::new();
2527		};
2528		names
2529			.iter()
2530			.map(|name| {
2531				let kind = self
2532					.expected_input_kind(name)
2533					.unwrap_or(CliInputKind::String);
2534				let choices = self
2535					.valid_input_choices(name)
2536					.map(|c| {
2537						#[allow(clippy::redundant_closure_for_method_calls)]
2538						c.iter().map(|s| s.to_string()).collect::<Vec<_>>()
2539					})
2540					.unwrap_or_default();
2541				let default = if *name == "sync_provider" {
2542					Some("true".to_string())
2543				} else {
2544					None
2545				};
2546				CliInputDefinition {
2547					name: name.to_string(),
2548					kind,
2549					help_text: None,
2550					required: false,
2551					default,
2552					choices,
2553					short: None,
2554				}
2555			})
2556			.collect()
2557	}
2558}
2559
2560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2561#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
2562pub struct CliCommandDefinition {
2563	pub name: String,
2564	#[serde(default)]
2565	pub help_text: Option<String>,
2566	#[serde(default)]
2567	pub inputs: Vec<CliInputDefinition>,
2568	#[serde(default)]
2569	pub steps: Vec<CliStepDefinition>,
2570	/// When true, this command always runs in dry-run mode regardless of
2571	/// whether `--dry-run` is passed on the CLI.
2572	#[serde(default)]
2573	pub dry_run: bool,
2574}
2575
2576/// Render release notes in the selected changelog format.
2577#[must_use]
2578pub fn render_release_notes(format: ChangelogFormat, document: &ReleaseNotesDocument) -> String {
2579	match format {
2580		ChangelogFormat::Monochange => render_monochange_release_notes(document),
2581		ChangelogFormat::KeepAChangelog => render_keep_a_changelog_release_notes(document),
2582	}
2583}
2584
2585fn render_monochange_release_notes(document: &ReleaseNotesDocument) -> String {
2586	let mut lines = vec![format!("## {}", document.title), String::new()];
2587	for (index, paragraph) in document.summary.iter().enumerate() {
2588		if index > 0 {
2589			lines.push(String::new());
2590		}
2591		lines.push(paragraph.clone());
2592	}
2593	for section in &document.sections {
2594		if section.entries.is_empty() {
2595			continue;
2596		}
2597		if !lines.last().is_some_and(String::is_empty) {
2598			lines.push(String::new());
2599		}
2600		if section.collapsed {
2601			push_collapsed_release_note_section(&mut lines, section);
2602			continue;
2603		}
2604		lines.push(format!("### {}", section.title));
2605		lines.push(String::new());
2606		push_release_note_entries(&mut lines, &section.entries);
2607	}
2608	lines.join("\n")
2609}
2610
2611fn render_keep_a_changelog_release_notes(document: &ReleaseNotesDocument) -> String {
2612	let mut lines = vec![format!("## {}", document.title), String::new()];
2613	for (index, paragraph) in document.summary.iter().enumerate() {
2614		if index > 0 {
2615			lines.push(String::new());
2616		}
2617		lines.push(paragraph.clone());
2618	}
2619	for section in &document.sections {
2620		if section.entries.is_empty() {
2621			continue;
2622		}
2623		if !lines.last().is_some_and(String::is_empty) {
2624			lines.push(String::new());
2625		}
2626		if section.collapsed {
2627			push_collapsed_release_note_section(&mut lines, section);
2628			continue;
2629		}
2630		lines.push(format!("### {}", section.title));
2631		lines.push(String::new());
2632		push_release_note_entries(&mut lines, &section.entries);
2633	}
2634	lines.join("\n")
2635}
2636
2637fn push_collapsed_release_note_section(lines: &mut Vec<String>, section: &ReleaseNotesSection) {
2638	lines.push("<details>".to_string());
2639	lines.push(format!(
2640		"<summary><strong>{}</strong></summary>",
2641		section.title
2642	));
2643	lines.push(String::new());
2644	push_release_note_entries(lines, &section.entries);
2645	lines.push("</details>".to_string());
2646}
2647
2648fn push_release_note_entries(lines: &mut Vec<String>, entries: &[String]) {
2649	for (index, entry) in entries.iter().enumerate() {
2650		let trimmed = entry.trim();
2651		if trimmed.contains('\n') {
2652			lines.extend(trimmed.lines().map(ToString::to_string));
2653			if index + 1 < entries.len() {
2654				lines.push(String::new());
2655			}
2656			continue;
2657		}
2658		if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with('#') {
2659			lines.push(trimmed.to_string());
2660		} else {
2661			lines.push(format!("- {trimmed}"));
2662		}
2663	}
2664}
2665
2666#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2667#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
2668#[serde(rename_all = "snake_case")]
2669pub enum ReleaseOwnerKind {
2670	Package,
2671	Group,
2672}
2673
2674impl ReleaseOwnerKind {
2675	/// Return the canonical serialized name for the release-owner kind.
2676	#[must_use]
2677	pub fn as_str(self) -> &'static str {
2678		match self {
2679			Self::Package => "package",
2680			Self::Group => "group",
2681		}
2682	}
2683}
2684
2685impl fmt::Display for ReleaseOwnerKind {
2686	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2687		formatter.write_str(self.as_str())
2688	}
2689}
2690
2691#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2692#[serde(rename_all = "camelCase")]
2693pub struct ReleaseManifestTarget {
2694	pub id: String,
2695	pub kind: ReleaseOwnerKind,
2696	pub version: String,
2697	pub tag: bool,
2698	pub release: bool,
2699	pub version_format: VersionFormat,
2700	pub tag_name: String,
2701	pub members: Vec<String>,
2702	#[serde(default)]
2703	pub rendered_title: String,
2704	#[serde(default)]
2705	pub rendered_changelog_title: String,
2706}
2707
2708#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2709#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2710#[serde(rename_all = "camelCase")]
2711pub struct ReleaseManifestChangelog {
2712	pub owner_id: String,
2713	pub owner_kind: ReleaseOwnerKind,
2714	pub path: PathBuf,
2715	pub format: ChangelogFormat,
2716	pub notes: ReleaseNotesDocument,
2717	pub rendered: String,
2718}
2719
2720#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2721#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2722#[serde(rename_all = "camelCase")]
2723pub struct PackagePublicationTarget {
2724	pub package: String,
2725	pub ecosystem: Ecosystem,
2726	#[serde(default)]
2727	pub registry: Option<PublishRegistry>,
2728	pub version: String,
2729	#[serde(default)]
2730	pub mode: PublishMode,
2731	#[serde(default)]
2732	pub trusted_publishing: TrustedPublishingSettings,
2733	#[serde(
2734		default,
2735		skip_serializing_if = "PublishAttestationSettings::is_default"
2736	)]
2737	pub attestations: PublishAttestationSettings,
2738}
2739
2740#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
2741#[serde(rename_all = "snake_case")]
2742pub enum RateLimitOperation {
2743	PlaceholderPublish,
2744	Publish,
2745	Update,
2746}
2747
2748impl RateLimitOperation {
2749	#[must_use]
2750	pub fn as_str(self) -> &'static str {
2751		match self {
2752			Self::PlaceholderPublish => "placeholder_publish",
2753			Self::Publish => "publish",
2754			Self::Update => "update",
2755		}
2756	}
2757}
2758
2759impl fmt::Display for RateLimitOperation {
2760	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2761		formatter.write_str(self.as_str())
2762	}
2763}
2764
2765#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
2766#[serde(rename_all = "snake_case")]
2767pub enum RateLimitEvidenceKind {
2768	Official,
2769	SourceCode,
2770	Secondary,
2771	ConservativeDefault,
2772}
2773
2774#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
2775#[serde(rename_all = "snake_case")]
2776pub enum RateLimitConfidence {
2777	High,
2778	Medium,
2779	Low,
2780}
2781
2782#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2783#[serde(rename_all = "camelCase")]
2784pub struct RateLimitEvidence {
2785	pub title: String,
2786	pub url: String,
2787	pub kind: RateLimitEvidenceKind,
2788	pub notes: String,
2789}
2790
2791#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2792#[serde(rename_all = "camelCase")]
2793pub struct RegistryRateLimitPolicy {
2794	pub registry: RegistryKind,
2795	pub operation: RateLimitOperation,
2796	pub limit: Option<u32>,
2797	pub window_seconds: Option<u64>,
2798	pub confidence: RateLimitConfidence,
2799	pub notes: String,
2800	pub evidence: Vec<RateLimitEvidence>,
2801}
2802
2803#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2804#[serde(rename_all = "camelCase")]
2805pub struct RegistryRateLimitWindowPlan {
2806	pub registry: RegistryKind,
2807	pub operation: RateLimitOperation,
2808	pub limit: Option<u32>,
2809	pub window_seconds: Option<u64>,
2810	pub pending: usize,
2811	pub batches_required: usize,
2812	pub fits_single_window: bool,
2813	pub confidence: RateLimitConfidence,
2814	pub notes: String,
2815	pub evidence: Vec<RateLimitEvidence>,
2816}
2817
2818#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2819#[serde(rename_all = "camelCase")]
2820pub struct PublishRateLimitBatch {
2821	pub registry: RegistryKind,
2822	pub operation: RateLimitOperation,
2823	pub batch_index: usize,
2824	pub total_batches: usize,
2825	pub packages: Vec<String>,
2826	pub recommended_wait_seconds: Option<u64>,
2827}
2828
2829#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2830#[serde(rename_all = "camelCase")]
2831pub struct PublishRateLimitReport {
2832	pub dry_run: bool,
2833	pub windows: Vec<RegistryRateLimitWindowPlan>,
2834	pub batches: Vec<PublishRateLimitBatch>,
2835	pub warnings: Vec<String>,
2836}
2837
2838#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2839#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
2840#[serde(rename_all = "snake_case")]
2841pub enum HostingProviderKind {
2842	#[default]
2843	#[serde(rename = "generic_git")]
2844	GenericGit,
2845	#[serde(rename = "github")]
2846	GitHub,
2847	#[serde(rename = "gitlab")]
2848	GitLab,
2849	#[serde(rename = "gitea")]
2850	Gitea,
2851	#[serde(rename = "forgejo")]
2852	Forgejo,
2853	#[serde(rename = "bitbucket")]
2854	Bitbucket,
2855}
2856
2857impl HostingProviderKind {
2858	/// Return the canonical serialized name for the hosting provider.
2859	#[must_use]
2860	pub fn as_str(self) -> &'static str {
2861		match self {
2862			Self::GenericGit => "generic_git",
2863			Self::GitHub => "github",
2864			Self::GitLab => "gitlab",
2865			Self::Gitea => "gitea",
2866			Self::Forgejo => "forgejo",
2867			Self::Bitbucket => "bitbucket",
2868		}
2869	}
2870}
2871
2872impl fmt::Display for HostingProviderKind {
2873	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2874		formatter.write_str(self.as_str())
2875	}
2876}
2877
2878#[allow(clippy::struct_excessive_bools)]
2879#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2880#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
2881#[serde(rename_all = "camelCase")]
2882pub struct HostingCapabilities {
2883	pub commit_web_urls: bool,
2884	pub actor_profiles: bool,
2885	pub review_request_lookup: bool,
2886	pub related_issues: bool,
2887	pub issue_comments: bool,
2888}
2889
2890#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2891#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
2892#[serde(rename_all = "snake_case")]
2893pub enum HostedActorSourceKind {
2894	#[default]
2895	CommitAuthor,
2896	CommitCommitter,
2897	ReviewRequestAuthor,
2898}
2899
2900#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2901#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
2902#[serde(rename_all = "camelCase")]
2903pub struct HostedActorRef {
2904	pub provider: HostingProviderKind,
2905	#[serde(default)]
2906	pub host: Option<String>,
2907	#[serde(default)]
2908	pub id: Option<String>,
2909	#[serde(default)]
2910	pub login: Option<String>,
2911	#[serde(default)]
2912	pub display_name: Option<String>,
2913	#[serde(default)]
2914	pub url: Option<String>,
2915	pub source: HostedActorSourceKind,
2916}
2917
2918#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2919#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
2920#[serde(rename_all = "camelCase")]
2921pub struct HostedCommitRef {
2922	pub provider: HostingProviderKind,
2923	#[serde(default)]
2924	pub host: Option<String>,
2925	pub sha: String,
2926	pub short_sha: String,
2927	#[serde(default)]
2928	pub url: Option<String>,
2929	#[serde(default)]
2930	pub authored_at: Option<String>,
2931	#[serde(default)]
2932	pub committed_at: Option<String>,
2933	#[serde(default)]
2934	pub author_name: Option<String>,
2935	#[serde(default)]
2936	pub author_email: Option<String>,
2937}
2938
2939#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2940#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
2941#[serde(rename_all = "snake_case")]
2942pub enum HostedReviewRequestKind {
2943	#[default]
2944	PullRequest,
2945	MergeRequest,
2946}
2947
2948impl HostedReviewRequestKind {
2949	/// Return the canonical serialized name for the review-request kind.
2950	#[must_use]
2951	pub fn as_str(self) -> &'static str {
2952		match self {
2953			Self::PullRequest => "pull_request",
2954			Self::MergeRequest => "merge_request",
2955		}
2956	}
2957}
2958
2959impl fmt::Display for HostedReviewRequestKind {
2960	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2961		formatter.write_str(self.as_str())
2962	}
2963}
2964
2965#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2966#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
2967#[serde(rename_all = "camelCase")]
2968pub struct HostedReviewRequestRef {
2969	pub provider: HostingProviderKind,
2970	#[serde(default)]
2971	pub host: Option<String>,
2972	pub kind: HostedReviewRequestKind,
2973	pub id: String,
2974	#[serde(default)]
2975	pub title: Option<String>,
2976	#[serde(default)]
2977	pub url: Option<String>,
2978	#[serde(default)]
2979	pub author: Option<HostedActorRef>,
2980}
2981
2982#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2983#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
2984#[serde(rename_all = "snake_case")]
2985pub enum HostedIssueRelationshipKind {
2986	#[default]
2987	ClosedByReviewRequest,
2988	ReferencedByReviewRequest,
2989	Mentioned,
2990	Manual,
2991}
2992
2993impl HostedIssueRelationshipKind {
2994	/// Return the canonical serialized name for the issue relationship kind.
2995	#[must_use]
2996	pub fn as_str(self) -> &'static str {
2997		match self {
2998			Self::ClosedByReviewRequest => "closed_by_review_request",
2999			Self::ReferencedByReviewRequest => "referenced_by_review_request",
3000			Self::Mentioned => "mentioned",
3001			Self::Manual => "manual",
3002		}
3003	}
3004}
3005
3006impl fmt::Display for HostedIssueRelationshipKind {
3007	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3008		formatter.write_str(self.as_str())
3009	}
3010}
3011
3012#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3013#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
3014#[serde(rename_all = "camelCase")]
3015pub struct HostedIssueRef {
3016	pub provider: HostingProviderKind,
3017	#[serde(default)]
3018	pub host: Option<String>,
3019	pub id: String,
3020	#[serde(default)]
3021	pub title: Option<String>,
3022	#[serde(default)]
3023	pub url: Option<String>,
3024	pub relationship: HostedIssueRelationshipKind,
3025}
3026
3027#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3028#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
3029#[serde(rename_all = "camelCase")]
3030pub struct ChangesetRevision {
3031	#[serde(default)]
3032	pub actor: Option<HostedActorRef>,
3033	#[serde(default)]
3034	pub commit: Option<HostedCommitRef>,
3035	#[serde(default)]
3036	pub review_request: Option<HostedReviewRequestRef>,
3037}
3038
3039#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3040#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
3041#[serde(rename_all = "camelCase")]
3042pub struct ChangesetContext {
3043	pub provider: HostingProviderKind,
3044	#[serde(default)]
3045	pub host: Option<String>,
3046	#[serde(default)]
3047	pub capabilities: HostingCapabilities,
3048	#[serde(default)]
3049	pub introduced: Option<ChangesetRevision>,
3050	#[serde(default)]
3051	pub last_updated: Option<ChangesetRevision>,
3052	#[serde(default)]
3053	pub related_issues: Vec<HostedIssueRef>,
3054}
3055
3056#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3057#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
3058#[serde(rename_all = "snake_case")]
3059pub enum ChangesetTargetKind {
3060	Package,
3061	Group,
3062}
3063
3064impl ChangesetTargetKind {
3065	/// Return the canonical serialized name for the changeset target kind.
3066	#[must_use]
3067	pub fn as_str(self) -> &'static str {
3068		match self {
3069			Self::Package => "package",
3070			Self::Group => "group",
3071		}
3072	}
3073}
3074
3075impl fmt::Display for ChangesetTargetKind {
3076	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3077		formatter.write_str(self.as_str())
3078	}
3079}
3080
3081#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3082#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3083#[serde(rename_all = "camelCase")]
3084pub struct PreparedChangesetTarget {
3085	pub id: String,
3086	pub kind: ChangesetTargetKind,
3087	#[serde(default)]
3088	pub bump: Option<BumpSeverity>,
3089	pub origin: String,
3090	#[serde(default)]
3091	pub evidence_refs: Vec<String>,
3092	#[serde(default)]
3093	pub change_type: Option<String>,
3094	#[serde(default, skip_serializing_if = "Vec::is_empty")]
3095	pub caused_by: Vec<String>,
3096}
3097
3098#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3099#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3100#[serde(rename_all = "camelCase")]
3101pub struct PreparedChangeset {
3102	pub path: PathBuf,
3103	#[serde(default)]
3104	pub summary: Option<String>,
3105	#[serde(default)]
3106	pub details: Option<String>,
3107	pub targets: Vec<PreparedChangesetTarget>,
3108	#[serde(default, alias = "context")]
3109	pub context: Option<ChangesetContext>,
3110}
3111
3112#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3113#[serde(rename_all = "camelCase")]
3114pub struct ReleaseManifestPlanDecision {
3115	pub package: String,
3116	pub bump: BumpSeverity,
3117	pub trigger: String,
3118	pub planned_version: Option<String>,
3119	pub reasons: Vec<String>,
3120	pub upstream_sources: Vec<String>,
3121}
3122
3123#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3124#[serde(rename_all = "camelCase")]
3125pub struct ReleaseManifestPlanGroup {
3126	pub id: String,
3127	pub planned_version: Option<String>,
3128	pub members: Vec<String>,
3129	pub bump: BumpSeverity,
3130}
3131
3132#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3133#[serde(rename_all = "camelCase")]
3134pub struct ReleaseManifestCompatibilityEvidence {
3135	pub package: String,
3136	pub provider: String,
3137	pub severity: BumpSeverity,
3138	pub summary: String,
3139	pub confidence: String,
3140	pub evidence_location: Option<String>,
3141}
3142
3143#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3144#[serde(rename_all = "camelCase")]
3145pub struct ReleaseManifestPlan {
3146	pub workspace_root: PathBuf,
3147	pub decisions: Vec<ReleaseManifestPlanDecision>,
3148	pub groups: Vec<ReleaseManifestPlanGroup>,
3149	pub warnings: Vec<String>,
3150	pub unresolved_items: Vec<String>,
3151	pub compatibility_evidence: Vec<ReleaseManifestCompatibilityEvidence>,
3152}
3153
3154#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3155#[serde(rename_all = "camelCase")]
3156pub struct ReleaseManifest {
3157	pub command: String,
3158	pub dry_run: bool,
3159	#[serde(default)]
3160	pub version: Option<String>,
3161	#[serde(default)]
3162	pub group_version: Option<String>,
3163	pub release_targets: Vec<ReleaseManifestTarget>,
3164	pub released_packages: Vec<String>,
3165	pub changed_files: Vec<PathBuf>,
3166	pub changelogs: Vec<ReleaseManifestChangelog>,
3167	#[serde(default)]
3168	pub package_publications: Vec<PackagePublicationTarget>,
3169	#[serde(default)]
3170	pub changesets: Vec<PreparedChangeset>,
3171	#[serde(default)]
3172	pub deleted_changesets: Vec<PathBuf>,
3173	pub plan: ReleaseManifestPlan,
3174}
3175
3176/// Current supported `ReleaseRecord` schema version text.
3177pub const RELEASE_RECORD_SCHEMA_VERSION: &str = monochange_schema::CURRENT_SCHEMA_VERSION_TEXT;
3178/// Required `ReleaseRecord.kind` discriminator.
3179pub const RELEASE_RECORD_KIND: &str = "monochange.releaseRecord";
3180/// Human-readable heading used for commit-embedded release records.
3181pub const RELEASE_RECORD_HEADING: &str = "## monochange Release Record";
3182/// Opening marker for a commit-embedded release record block.
3183pub const RELEASE_RECORD_START_MARKER: &str = "<!-- monochange:release-record:start -->";
3184/// Closing marker for a commit-embedded release record block.
3185pub const RELEASE_RECORD_END_MARKER: &str = "<!-- monochange:release-record:end -->";
3186
3187fn release_record_schema_version() -> String {
3188	monochange_schema::CURRENT_SCHEMA_VERSION_TEXT.to_string()
3189}
3190
3191fn default_release_record_kind() -> String {
3192	RELEASE_RECORD_KIND.to_string()
3193}
3194
3195fn default_true() -> bool {
3196	true
3197}
3198
3199fn default_pull_request_branch_prefix() -> String {
3200	"monochange/release".to_string()
3201}
3202
3203fn default_pull_request_base() -> String {
3204	"main".to_string()
3205}
3206
3207fn default_pull_request_title() -> String {
3208	"chore(release): prepare release".to_string()
3209}
3210
3211fn default_pull_request_labels() -> Vec<String> {
3212	vec!["release".to_string(), "automated".to_string()]
3213}
3214
3215/// Normalize legacy schema-version fields before passing to `migrate_value`.
3216///
3217/// - Moves `"v"` values to `"schemaVersion"`.
3218/// - Replaces the pre-public integer `schemaVersion: 1` with the current
3219///   `RELEASE_RECORD_SCHEMA_VERSION` text.
3220fn normalize_legacy_schema_version(raw: &mut serde_json::Value) {
3221	let Some(object) = raw.as_object_mut() else {
3222		return;
3223	};
3224	if let Some(version) = object.get("schemaVersion")
3225		&& version.is_u64()
3226		&& version.as_u64() == Some(1)
3227	{
3228		object.insert(
3229			"schemaVersion".to_string(),
3230			serde_json::Value::String(RELEASE_RECORD_SCHEMA_VERSION.to_string()),
3231		);
3232	}
3233	if !object.contains_key("schemaVersion")
3234		&& object.contains_key("v")
3235		&& let Some(v) = object.remove("v")
3236	{
3237		object.insert("schemaVersion".to_string(), v);
3238	}
3239}
3240
3241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3242#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3243#[serde(rename_all = "camelCase")]
3244pub struct ReleaseRecordTarget {
3245	pub id: String,
3246	pub kind: ReleaseOwnerKind,
3247	pub version: String,
3248	pub version_format: VersionFormat,
3249	pub tag: bool,
3250	pub release: bool,
3251	pub tag_name: String,
3252	#[serde(default)]
3253	pub members: Vec<String>,
3254}
3255
3256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3257#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3258#[serde(rename_all = "camelCase")]
3259pub struct ReleaseRecordProvider {
3260	pub kind: SourceProvider,
3261	pub owner: String,
3262	pub repo: String,
3263	#[serde(default)]
3264	pub host: Option<String>,
3265}
3266
3267#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3268#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3269#[serde(rename_all = "camelCase")]
3270pub struct ReleaseRecord {
3271	#[serde(default = "release_record_schema_version")]
3272	pub schema_version: String,
3273	#[serde(default = "default_release_record_kind")]
3274	pub kind: String,
3275	pub created_at: String,
3276	pub command: String,
3277	#[serde(default)]
3278	pub version: Option<String>,
3279	#[serde(default)]
3280	pub versions: BTreeMap<String, String>,
3281	pub release_targets: Vec<ReleaseRecordTarget>,
3282	pub released_packages: Vec<String>,
3283	pub changed_files: Vec<PathBuf>,
3284	#[serde(default)]
3285	pub package_publications: Vec<PackagePublicationTarget>,
3286	#[serde(default)]
3287	pub updated_changelogs: Vec<PathBuf>,
3288	#[serde(default)]
3289	pub deleted_changesets: Vec<PathBuf>,
3290	#[serde(default)]
3291	pub changesets: Vec<PreparedChangeset>,
3292	#[serde(default)]
3293	pub changelogs: Vec<ReleaseManifestChangelog>,
3294	#[serde(default)]
3295	pub provider: Option<ReleaseRecordProvider>,
3296}
3297
3298#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3299#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3300#[serde(rename_all = "camelCase")]
3301pub struct ReleaseRecordDiscovery {
3302	pub input_ref: String,
3303	pub resolved_commit: String,
3304	pub record_commit: String,
3305	pub distance: usize,
3306	pub record: ReleaseRecord,
3307}
3308
3309#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3310#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
3311#[serde(rename_all = "snake_case")]
3312pub enum RetargetOperation {
3313	Planned,
3314	Moved,
3315	AlreadyUpToDate,
3316	Skipped,
3317	Failed,
3318}
3319
3320#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3321#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3322#[serde(rename_all = "camelCase")]
3323pub struct RetargetTagResult {
3324	pub tag_name: String,
3325	pub from_commit: String,
3326	pub to_commit: String,
3327	pub operation: RetargetOperation,
3328	#[serde(default)]
3329	pub message: Option<String>,
3330}
3331
3332#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
3333#[serde(rename_all = "snake_case")]
3334pub enum RetargetProviderOperation {
3335	Planned,
3336	Synced,
3337	AlreadyAligned,
3338	Unsupported,
3339	Skipped,
3340	Failed,
3341}
3342
3343#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3344#[serde(rename_all = "camelCase")]
3345pub struct RetargetProviderResult {
3346	pub provider: SourceProvider,
3347	pub tag_name: String,
3348	pub target_commit: String,
3349	pub operation: RetargetProviderOperation,
3350	#[serde(default)]
3351	pub url: Option<String>,
3352	#[serde(default)]
3353	pub message: Option<String>,
3354}
3355
3356#[allow(clippy::struct_excessive_bools)]
3357#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3358#[serde(rename_all = "camelCase")]
3359pub struct RetargetPlan {
3360	pub record_commit: String,
3361	pub target_commit: String,
3362	pub is_descendant: bool,
3363	pub force: bool,
3364	pub git_tag_updates: Vec<RetargetTagResult>,
3365	pub provider_updates: Vec<RetargetProviderResult>,
3366	pub sync_provider: bool,
3367	pub dry_run: bool,
3368}
3369
3370#[allow(clippy::struct_excessive_bools)]
3371#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3372#[serde(rename_all = "camelCase")]
3373pub struct RetargetResult {
3374	pub record_commit: String,
3375	pub target_commit: String,
3376	pub force: bool,
3377	pub git_tag_results: Vec<RetargetTagResult>,
3378	pub provider_results: Vec<RetargetProviderResult>,
3379	pub sync_provider: bool,
3380	pub dry_run: bool,
3381}
3382
3383/// Return all tag names owned by the release record, deduplicated and sorted.
3384#[must_use]
3385pub fn release_record_tag_names(record: &ReleaseRecord) -> Vec<String> {
3386	record
3387		.release_targets
3388		.iter()
3389		.filter(|target| target.tag)
3390		.map(|target| target.tag_name.clone())
3391		.collect::<BTreeSet<_>>()
3392		.into_iter()
3393		.collect()
3394}
3395
3396/// Return tag names that correspond to outward hosted releases.
3397#[must_use]
3398pub fn release_record_release_tag_names(record: &ReleaseRecord) -> Vec<String> {
3399	record
3400		.release_targets
3401		.iter()
3402		.filter(|target| target.release)
3403		.map(|target| target.tag_name.clone())
3404		.collect::<BTreeSet<_>>()
3405		.into_iter()
3406		.collect()
3407}
3408
3409#[derive(Debug, Error)]
3410pub enum ReleaseRecordError {
3411	#[error("no monochange release record block found")]
3412	NotFound,
3413	#[error("found multiple monochange release record blocks")]
3414	MultipleBlocks,
3415	#[error("found a release record start marker without a matching end marker")]
3416	MissingEndMarker,
3417	#[error("found a malformed release record block without a fenced json payload")]
3418	MissingJsonBlock,
3419	#[error("release record is missing required `kind`")]
3420	MissingKind,
3421	#[error("release record is missing required `schemaVersion`")]
3422	MissingSchemaVersion,
3423	#[error("release record uses unsupported kind `{0}`")]
3424	UnsupportedKind(String),
3425	#[error("release record uses unsupported schema version `{0}`")]
3426	UnsupportedSchemaVersionValue(String),
3427	#[error("release record schema error: {0}")]
3428	Schema(String),
3429	#[error("release record json error: {0}")]
3430	InvalidJson(#[from] serde_json::Error),
3431}
3432
3433/// Result type used by release-record parsing and rendering helpers.
3434pub type ReleaseRecordResult<T> = Result<T, ReleaseRecordError>;
3435
3436fn release_record_schema_error_to_error(
3437	error: monochange_schema::SchemaError,
3438) -> ReleaseRecordError {
3439	match error {
3440		monochange_schema::SchemaError::MissingKind => ReleaseRecordError::MissingKind,
3441		monochange_schema::SchemaError::UnsupportedKind { actual, .. } => {
3442			ReleaseRecordError::UnsupportedKind(actual)
3443		}
3444		monochange_schema::SchemaError::MissingVersion => ReleaseRecordError::MissingSchemaVersion,
3445		monochange_schema::SchemaError::UnsupportedVersion { actual, .. }
3446		| monochange_schema::SchemaError::InvalidVersion {
3447			version: actual, ..
3448		} => ReleaseRecordError::UnsupportedSchemaVersionValue(actual),
3449		other => ReleaseRecordError::Schema(other.to_string()),
3450	}
3451}
3452
3453/// Render a `ReleaseRecord` into the reserved commit-message block format.
3454#[must_use = "the rendered record result must be checked"]
3455pub fn render_release_record_block(record: &ReleaseRecord) -> ReleaseRecordResult<String> {
3456	if record.kind != RELEASE_RECORD_KIND {
3457		return Err(ReleaseRecordError::UnsupportedKind(record.kind.clone()));
3458	}
3459	if record.schema_version != RELEASE_RECORD_SCHEMA_VERSION {
3460		return Err(ReleaseRecordError::UnsupportedSchemaVersionValue(
3461			record.schema_version.clone(),
3462		));
3463	}
3464	let raw = serde_json::to_value(record)?;
3465	let current = monochange_schema::release_record::render_current_value(raw)
3466		.map_err(release_record_schema_error_to_error)?;
3467	let json = serde_json::to_string_pretty(&current)?;
3468	Ok(format!(
3469		"{RELEASE_RECORD_HEADING}\n\n{RELEASE_RECORD_START_MARKER}\n```json\n{json}\n```\n{RELEASE_RECORD_END_MARKER}"
3470	))
3471}
3472
3473/// Parse a `ReleaseRecord` from a full commit message body.
3474#[must_use = "the parsed record result must be checked"]
3475pub fn parse_release_record_json(json_text: &str) -> ReleaseRecordResult<ReleaseRecord> {
3476	let mut raw = serde_json::from_str::<serde_json::Value>(json_text)
3477		.map_err(ReleaseRecordError::InvalidJson)?;
3478	normalize_legacy_schema_version(&mut raw);
3479	let kind = raw
3480		.get("kind")
3481		.and_then(serde_json::Value::as_str)
3482		.ok_or(ReleaseRecordError::MissingKind)?;
3483	if kind != RELEASE_RECORD_KIND {
3484		return Err(ReleaseRecordError::UnsupportedKind(kind.to_string()));
3485	}
3486	let current = monochange_schema::release_record::migrate_value(raw)
3487		.map_err(release_record_schema_error_to_error)?;
3488	serde_json::from_value(current).map_err(ReleaseRecordError::InvalidJson)
3489}
3490
3491pub fn parse_release_record_block(commit_message: &str) -> ReleaseRecordResult<ReleaseRecord> {
3492	let start_matches = commit_message
3493		.match_indices(RELEASE_RECORD_START_MARKER)
3494		.collect::<Vec<_>>();
3495	if start_matches.is_empty() {
3496		return Err(ReleaseRecordError::NotFound);
3497	}
3498	let end_matches = commit_message
3499		.match_indices(RELEASE_RECORD_END_MARKER)
3500		.collect::<Vec<_>>();
3501	if end_matches.is_empty() {
3502		return Err(ReleaseRecordError::MissingEndMarker);
3503	}
3504	if start_matches.len() > 1 || end_matches.len() > 1 {
3505		return Err(ReleaseRecordError::MultipleBlocks);
3506	}
3507	let (start_index, _) = start_matches
3508		.first()
3509		.copied()
3510		.unwrap_or_else(|| unreachable!("start marker count was validated"));
3511	let (end_index, _) = end_matches
3512		.first()
3513		.copied()
3514		.unwrap_or_else(|| unreachable!("end marker count was validated"));
3515	if end_index <= start_index {
3516		return Err(ReleaseRecordError::MissingEndMarker);
3517	}
3518	let block_contents =
3519		&commit_message[start_index + RELEASE_RECORD_START_MARKER.len()..end_index];
3520	let json_text = extract_release_record_json(block_contents)?;
3521	let mut raw = serde_json::from_str::<serde_json::Value>(&json_text)?;
3522	normalize_legacy_schema_version(&mut raw);
3523	normalize_legacy_schema_version(&mut raw);
3524	let kind = raw
3525		.get("kind")
3526		.and_then(serde_json::Value::as_str)
3527		.ok_or(ReleaseRecordError::MissingKind)?;
3528	if kind != RELEASE_RECORD_KIND {
3529		return Err(ReleaseRecordError::UnsupportedKind(kind.to_string()));
3530	}
3531	let current = monochange_schema::release_record::migrate_value(raw)
3532		.map_err(release_record_schema_error_to_error)?;
3533	serde_json::from_value(current).map_err(ReleaseRecordError::InvalidJson)
3534}
3535
3536fn extract_release_record_json(block_contents: &str) -> ReleaseRecordResult<String> {
3537	let lines = block_contents.trim().lines().collect::<Vec<_>>();
3538	if lines.first().map(|line| line.trim_end()) != Some("```json") {
3539		return Err(ReleaseRecordError::MissingJsonBlock);
3540	}
3541	let Some(closing_index) = lines
3542		.iter()
3543		.enumerate()
3544		.skip(1)
3545		.find_map(|(index, line)| (line.trim_end() == "```").then_some(index))
3546	else {
3547		return Err(ReleaseRecordError::MissingJsonBlock);
3548	};
3549	if lines
3550		.iter()
3551		.skip(closing_index + 1)
3552		.any(|line| !line.trim().is_empty())
3553	{
3554		return Err(ReleaseRecordError::MissingJsonBlock);
3555	}
3556	let json = lines
3557		.iter()
3558		.skip(1)
3559		.take(closing_index.saturating_sub(1))
3560		.copied()
3561		.collect::<Vec<_>>()
3562		.join("\n");
3563	if json.trim().is_empty() {
3564		return Err(ReleaseRecordError::MissingJsonBlock);
3565	}
3566	Ok(json)
3567}
3568
3569#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3570#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
3571#[serde(rename_all = "snake_case")]
3572pub enum ProviderReleaseNotesSource {
3573	#[default]
3574	Monochange,
3575	#[serde(rename = "github_generated")]
3576	GitHubGenerated,
3577}
3578
3579#[allow(clippy::struct_excessive_bools)]
3580#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3581#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3582pub struct ProviderReleaseSettings {
3583	#[serde(default = "default_true")]
3584	pub enabled: bool,
3585	#[serde(default)]
3586	pub draft: bool,
3587	#[serde(default)]
3588	pub prerelease: bool,
3589	#[serde(default)]
3590	pub generate_notes: bool,
3591	#[serde(default)]
3592	pub source: ProviderReleaseNotesSource,
3593	#[serde(default = "default_release_branch_patterns")]
3594	pub branches: Vec<String>,
3595	#[serde(default = "default_true")]
3596	pub enforce_for_tags: bool,
3597	#[serde(default = "default_true")]
3598	pub enforce_for_publish: bool,
3599	#[serde(default)]
3600	pub enforce_for_commit: bool,
3601	#[serde(
3602		default,
3603		skip_serializing_if = "ReleaseAttestationSettings::is_default"
3604	)]
3605	pub attestations: ReleaseAttestationSettings,
3606}
3607
3608impl Default for ProviderReleaseSettings {
3609	fn default() -> Self {
3610		Self {
3611			enabled: true,
3612			draft: false,
3613			prerelease: false,
3614			generate_notes: false,
3615			source: ProviderReleaseNotesSource::default(),
3616			branches: default_release_branch_patterns(),
3617			enforce_for_tags: true,
3618			enforce_for_publish: true,
3619			enforce_for_commit: false,
3620			attestations: ReleaseAttestationSettings::default(),
3621		}
3622	}
3623}
3624
3625#[allow(clippy::struct_excessive_bools)]
3626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3627#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3628pub struct ProviderMergeRequestSettings {
3629	#[serde(default = "default_true")]
3630	pub enabled: bool,
3631	#[serde(default = "default_pull_request_branch_prefix")]
3632	pub branch_prefix: String,
3633	#[serde(default = "default_pull_request_base")]
3634	pub base: String,
3635	#[serde(default = "default_pull_request_title")]
3636	pub title: String,
3637	#[serde(default = "default_pull_request_labels")]
3638	pub labels: Vec<String>,
3639	#[serde(default)]
3640	pub auto_merge: bool,
3641	#[serde(default)]
3642	pub verified_commits: bool,
3643}
3644
3645impl Default for ProviderMergeRequestSettings {
3646	fn default() -> Self {
3647		Self {
3648			enabled: true,
3649			branch_prefix: default_pull_request_branch_prefix(),
3650			base: default_pull_request_base(),
3651			title: default_pull_request_title(),
3652			labels: default_pull_request_labels(),
3653			auto_merge: false,
3654			verified_commits: false,
3655		}
3656	}
3657}
3658
3659#[allow(clippy::struct_excessive_bools)]
3660#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3661#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3662pub struct ChangesetAffectedSettings {
3663	#[serde(default = "default_true")]
3664	pub enabled: bool,
3665	#[serde(default = "default_true")]
3666	pub required: bool,
3667	#[serde(default)]
3668	pub skip_labels: Vec<String>,
3669	#[serde(default = "default_true")]
3670	pub comment_on_failure: bool,
3671	#[serde(default)]
3672	pub changed_paths: Vec<String>,
3673	#[serde(default)]
3674	pub ignored_paths: Vec<String>,
3675}
3676
3677impl Default for ChangesetAffectedSettings {
3678	fn default() -> Self {
3679		Self {
3680			enabled: true,
3681			required: true,
3682			skip_labels: Vec::new(),
3683			comment_on_failure: true,
3684			changed_paths: Vec::new(),
3685			ignored_paths: Vec::new(),
3686		}
3687	}
3688}
3689
3690#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3691#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
3692pub struct ChangesetSettings {
3693	#[serde(default)]
3694	pub affected: ChangesetAffectedSettings,
3695}
3696
3697#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
3698#[serde(rename_all = "snake_case")]
3699pub enum ChangesetPolicyStatus {
3700	Passed,
3701	Failed,
3702	Skipped,
3703	NotRequired,
3704}
3705
3706impl ChangesetPolicyStatus {
3707	/// Return the canonical serialized name for the policy status.
3708	#[must_use]
3709	pub fn as_str(self) -> &'static str {
3710		match self {
3711			Self::Passed => "passed",
3712			Self::Failed => "failed",
3713			Self::Skipped => "skipped",
3714			Self::NotRequired => "not_required",
3715		}
3716	}
3717}
3718
3719impl fmt::Display for ChangesetPolicyStatus {
3720	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3721		formatter.write_str(self.as_str())
3722	}
3723}
3724
3725fn default_release_branch_patterns() -> Vec<String> {
3726	vec!["main".to_string()]
3727}
3728
3729#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3730#[serde(rename_all = "camelCase")]
3731pub struct ChangesetPolicyEvaluation {
3732	pub status: ChangesetPolicyStatus,
3733	pub required: bool,
3734	#[serde(default)]
3735	pub enforce: bool,
3736	pub summary: String,
3737	#[serde(default)]
3738	pub comment: Option<String>,
3739	#[serde(default)]
3740	pub labels: Vec<String>,
3741	#[serde(default)]
3742	pub matched_skip_labels: Vec<String>,
3743	#[serde(default)]
3744	pub changed_paths: Vec<String>,
3745	#[serde(default)]
3746	pub matched_paths: Vec<String>,
3747	#[serde(default)]
3748	pub ignored_paths: Vec<String>,
3749	#[serde(default)]
3750	pub changeset_paths: Vec<String>,
3751	#[serde(default)]
3752	pub affected_package_ids: Vec<String>,
3753	#[serde(default)]
3754	pub covered_package_ids: Vec<String>,
3755	#[serde(default)]
3756	pub uncovered_package_ids: Vec<String>,
3757	#[serde(default)]
3758	pub errors: Vec<String>,
3759}
3760
3761#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3762#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
3763pub enum SourceProvider {
3764	#[default]
3765	#[serde(rename = "github")]
3766	GitHub,
3767	#[serde(rename = "gitlab")]
3768	GitLab,
3769	#[serde(rename = "gitea")]
3770	Gitea,
3771	#[serde(rename = "forgejo")]
3772	Forgejo,
3773}
3774
3775impl SourceProvider {
3776	/// Return the canonical serialized name for the source provider.
3777	#[must_use]
3778	pub fn as_str(self) -> &'static str {
3779		match self {
3780			Self::GitHub => "github",
3781			Self::GitLab => "gitlab",
3782			Self::Gitea => "gitea",
3783			Self::Forgejo => "forgejo",
3784		}
3785	}
3786}
3787
3788impl fmt::Display for SourceProvider {
3789	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3790		formatter.write_str(self.as_str())
3791	}
3792}
3793
3794#[allow(clippy::struct_excessive_bools)]
3795#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
3796pub struct SourceCapabilities {
3797	pub draft_releases: bool,
3798	pub prereleases: bool,
3799	pub generated_release_notes: bool,
3800	pub auto_merge_change_requests: bool,
3801	pub released_issue_comments: bool,
3802	pub requires_host: bool,
3803}
3804
3805#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3806#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3807pub struct SourceConfiguration {
3808	#[serde(default)]
3809	pub provider: SourceProvider,
3810	pub owner: String,
3811	pub repo: String,
3812	#[serde(default)]
3813	pub host: Option<String>,
3814	#[serde(default)]
3815	pub api_url: Option<String>,
3816	#[serde(default)]
3817	pub releases: ProviderReleaseSettings,
3818	#[serde(default)]
3819	pub pull_requests: ProviderMergeRequestSettings,
3820}
3821
3822#[allow(clippy::struct_excessive_bools)]
3823#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
3824#[serde(rename_all = "camelCase")]
3825pub struct HostedSourceFeatures {
3826	pub batched_changeset_context_lookup: bool,
3827	pub released_issue_comments: bool,
3828	pub release_retarget_sync: bool,
3829}
3830
3831#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3832#[serde(rename_all = "camelCase")]
3833pub struct HostedIssueCommentPlan {
3834	pub repository: String,
3835	pub issue_id: String,
3836	pub issue_url: Option<String>,
3837	pub body: String,
3838	#[serde(default)]
3839	pub close: bool,
3840}
3841
3842#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
3843#[serde(rename_all = "snake_case")]
3844pub enum HostedIssueCommentOperation {
3845	Created,
3846	SkippedExisting,
3847	Closed,
3848}
3849
3850#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3851#[serde(rename_all = "camelCase")]
3852pub struct HostedIssueCommentOutcome {
3853	pub repository: String,
3854	pub issue_id: String,
3855	pub operation: HostedIssueCommentOperation,
3856	pub url: Option<String>,
3857}
3858
3859pub trait HostedSourceAdapter: Sync {
3860	fn provider(&self) -> SourceProvider;
3861
3862	fn features(&self) -> HostedSourceFeatures {
3863		HostedSourceFeatures::default()
3864	}
3865
3866	fn annotate_changeset_context(
3867		&self,
3868		source: &SourceConfiguration,
3869		changesets: &mut [PreparedChangeset],
3870	);
3871
3872	fn enrich_changeset_context(
3873		&self,
3874		source: &SourceConfiguration,
3875		changesets: &mut [PreparedChangeset],
3876	) {
3877		self.annotate_changeset_context(source, changesets);
3878	}
3879
3880	fn plan_released_issue_comments(
3881		&self,
3882		_source: &SourceConfiguration,
3883		_manifest: &ReleaseManifest,
3884	) -> Vec<HostedIssueCommentPlan> {
3885		Vec::new()
3886	}
3887
3888	fn comment_released_issues(
3889		&self,
3890		source: &SourceConfiguration,
3891		manifest: &ReleaseManifest,
3892	) -> MonochangeResult<Vec<HostedIssueCommentOutcome>> {
3893		let plans = self.plan_released_issue_comments(source, manifest);
3894		if plans.is_empty() {
3895			return Ok(Vec::new());
3896		}
3897		Err(MonochangeError::Config(format!(
3898			"released issue comments are not yet supported for {}",
3899			self.provider()
3900		)))
3901	}
3902
3903	fn plan_retargeted_releases(
3904		&self,
3905		tag_results: &[RetargetTagResult],
3906	) -> Vec<RetargetProviderResult> {
3907		let provider = self.provider();
3908		let supports_sync = self.features().release_retarget_sync;
3909		tag_results
3910			.iter()
3911			.map(|update| {
3912				RetargetProviderResult {
3913					provider,
3914					tag_name: update.tag_name.clone(),
3915					target_commit: update.to_commit.clone(),
3916					operation: if supports_sync {
3917						RetargetProviderOperation::Planned
3918					} else {
3919						RetargetProviderOperation::Unsupported
3920					},
3921					url: None,
3922					message: (!supports_sync).then_some(format!(
3923						"provider sync is not yet supported for {provider} release retargeting"
3924					)),
3925				}
3926			})
3927			.collect()
3928	}
3929
3930	fn sync_retargeted_releases(
3931		&self,
3932		source: &SourceConfiguration,
3933		tag_results: &[RetargetTagResult],
3934		dry_run: bool,
3935	) -> MonochangeResult<Vec<RetargetProviderResult>> {
3936		if dry_run {
3937			return Ok(self.plan_retargeted_releases(tag_results));
3938		}
3939		Err(MonochangeError::Config(format!(
3940			"provider sync is not yet supported for {} release retargeting",
3941			source.provider
3942		)))
3943	}
3944}
3945
3946#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3947#[serde(rename_all = "camelCase")]
3948pub struct SourceReleaseRequest {
3949	pub provider: SourceProvider,
3950	pub repository: String,
3951	pub owner: String,
3952	pub repo: String,
3953	pub target_id: String,
3954	pub target_kind: ReleaseOwnerKind,
3955	pub tag_name: String,
3956	pub name: String,
3957	pub body: Option<String>,
3958	pub draft: bool,
3959	pub prerelease: bool,
3960	pub generate_release_notes: bool,
3961}
3962
3963#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3964#[serde(rename_all = "snake_case")]
3965pub enum SourceReleaseOperation {
3966	Created,
3967	Updated,
3968}
3969
3970#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3971#[serde(rename_all = "camelCase")]
3972pub struct SourceReleaseOutcome {
3973	pub provider: SourceProvider,
3974	pub repository: String,
3975	pub tag_name: String,
3976	pub operation: SourceReleaseOperation,
3977	pub url: Option<String>,
3978}
3979
3980#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3981#[serde(rename_all = "camelCase")]
3982pub struct CommitMessage {
3983	pub subject: String,
3984	#[serde(default)]
3985	pub body: Option<String>,
3986}
3987
3988#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3989#[serde(rename_all = "camelCase")]
3990pub struct SourceChangeRequest {
3991	pub provider: SourceProvider,
3992	pub repository: String,
3993	pub owner: String,
3994	pub repo: String,
3995	pub base_branch: String,
3996	pub head_branch: String,
3997	pub title: String,
3998	pub body: String,
3999	pub labels: Vec<String>,
4000	pub auto_merge: bool,
4001	pub commit_message: CommitMessage,
4002}
4003
4004#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4005#[serde(rename_all = "snake_case")]
4006pub enum SourceChangeRequestOperation {
4007	Created,
4008	Updated,
4009	Skipped,
4010}
4011
4012#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4013#[serde(rename_all = "camelCase")]
4014pub struct SourceChangeRequestOutcome {
4015	pub provider: SourceProvider,
4016	pub repository: String,
4017	pub number: u64,
4018	pub head_branch: String,
4019	pub operation: SourceChangeRequestOperation,
4020	pub url: Option<String>,
4021}
4022
4023#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4024pub struct EffectiveReleaseIdentity {
4025	pub owner_id: String,
4026	pub owner_kind: ReleaseOwnerKind,
4027	pub group_id: Option<String>,
4028	pub tag: bool,
4029	pub release: bool,
4030	pub version_format: VersionFormat,
4031	pub members: Vec<String>,
4032}
4033
4034#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
4035#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4036pub struct WorkspaceConfiguration {
4037	pub root_path: PathBuf,
4038	pub defaults: WorkspaceDefaults,
4039	pub changelog: ChangelogSettings,
4040	pub packages: Vec<PackageDefinition>,
4041	pub groups: Vec<GroupDefinition>,
4042	pub cli: Vec<CliCommandDefinition>,
4043	pub changesets: ChangesetSettings,
4044	pub source: Option<SourceConfiguration>,
4045	#[cfg_attr(feature = "schema", schemars(skip))]
4046	pub lints: lint::WorkspaceLintSettings,
4047	pub cargo: EcosystemSettings,
4048	pub npm: EcosystemSettings,
4049	pub deno: EcosystemSettings,
4050	pub dart: EcosystemSettings,
4051	pub python: EcosystemSettings,
4052	pub go: EcosystemSettings,
4053}
4054
4055impl WorkspaceConfiguration {
4056	/// Look up a configured package by its package id.
4057	#[must_use]
4058	pub fn package_by_id(&self, package_id: &str) -> Option<&PackageDefinition> {
4059		self.packages
4060			.iter()
4061			.find(|package| package.id == package_id)
4062	}
4063
4064	/// Look up a configured group by its group id.
4065	#[must_use]
4066	pub fn group_by_id(&self, group_id: &str) -> Option<&GroupDefinition> {
4067		self.groups.iter().find(|group| group.id == group_id)
4068	}
4069
4070	/// Return the configured group that directly owns `package_id`, if any.
4071	#[must_use]
4072	pub fn group_for_package(&self, package_id: &str) -> Option<&GroupDefinition> {
4073		self.groups
4074			.iter()
4075			.find(|group| group.packages.iter().any(|member| member == package_id))
4076	}
4077
4078	/// Resolve the effective outward release identity for a package.
4079	#[must_use]
4080	pub fn effective_release_identity(&self, package_id: &str) -> Option<EffectiveReleaseIdentity> {
4081		let package = self.package_by_id(package_id)?;
4082		if let Some(group) = self.group_for_package(package_id) {
4083			return Some(EffectiveReleaseIdentity {
4084				owner_id: group.id.clone(),
4085				owner_kind: ReleaseOwnerKind::Group,
4086				group_id: Some(group.id.clone()),
4087				tag: group.tag,
4088				release: group.release,
4089				version_format: group.version_format,
4090				members: group.packages.clone(),
4091			});
4092		}
4093
4094		Some(EffectiveReleaseIdentity {
4095			owner_id: package.id.clone(),
4096			owner_kind: ReleaseOwnerKind::Package,
4097			group_id: None,
4098			tag: package.tag,
4099			release: package.release,
4100			version_format: package.version_format,
4101			members: vec![package.id.clone()],
4102		})
4103	}
4104}
4105
4106/// Return the built-in CLI command definitions used when config omits them.
4107#[must_use]
4108pub fn default_cli_commands() -> Vec<CliCommandDefinition> {
4109	vec![]
4110}
4111
4112/// Return all built-in step variants except `Command`.
4113#[must_use]
4114pub fn all_step_variants() -> Vec<CliStepDefinition> {
4115	vec![
4116		CliStepDefinition::Config {
4117			name: None,
4118			when: None,
4119			always_run: false,
4120			inputs: BTreeMap::new(),
4121		},
4122		CliStepDefinition::Validate {
4123			name: None,
4124			when: None,
4125			always_run: false,
4126			inputs: BTreeMap::new(),
4127		},
4128		CliStepDefinition::Discover {
4129			name: None,
4130			when: None,
4131			always_run: false,
4132			inputs: BTreeMap::new(),
4133		},
4134		CliStepDefinition::DisplayVersions {
4135			name: None,
4136			when: None,
4137			always_run: false,
4138			inputs: BTreeMap::new(),
4139		},
4140		CliStepDefinition::CreateChangeFile {
4141			name: None,
4142			when: None,
4143			always_run: false,
4144			show_progress: None,
4145			inputs: BTreeMap::new(),
4146		},
4147		CliStepDefinition::PrepareRelease {
4148			name: None,
4149			when: None,
4150			always_run: false,
4151			inputs: BTreeMap::new(),
4152			allow_empty_changesets: false,
4153		},
4154		CliStepDefinition::CommitRelease {
4155			name: None,
4156			when: None,
4157			always_run: false,
4158			no_verify: false,
4159			update_release_json: false,
4160			inputs: BTreeMap::new(),
4161		},
4162		CliStepDefinition::VerifyReleaseBranch {
4163			name: None,
4164			when: None,
4165			always_run: false,
4166			inputs: BTreeMap::new(),
4167		},
4168		CliStepDefinition::PublishRelease {
4169			name: None,
4170			when: None,
4171			always_run: false,
4172			inputs: BTreeMap::new(),
4173		},
4174		CliStepDefinition::PlaceholderPublish {
4175			name: None,
4176			when: None,
4177			always_run: false,
4178			inputs: BTreeMap::new(),
4179		},
4180		CliStepDefinition::PublishPackages {
4181			name: None,
4182			when: None,
4183			always_run: false,
4184			inputs: BTreeMap::new(),
4185		},
4186		CliStepDefinition::PlanPublishRateLimits {
4187			name: None,
4188			when: None,
4189			always_run: false,
4190			inputs: BTreeMap::new(),
4191		},
4192		CliStepDefinition::OpenReleaseRequest {
4193			name: None,
4194			when: None,
4195			always_run: false,
4196			no_verify: false,
4197			inputs: BTreeMap::new(),
4198		},
4199		CliStepDefinition::CommentReleasedIssues {
4200			name: None,
4201			when: None,
4202			always_run: false,
4203			inputs: BTreeMap::new(),
4204		},
4205		CliStepDefinition::AffectedPackages {
4206			name: None,
4207			when: None,
4208			always_run: false,
4209			inputs: BTreeMap::new(),
4210		},
4211		CliStepDefinition::DiagnoseChangesets {
4212			name: None,
4213			when: None,
4214			always_run: false,
4215			inputs: BTreeMap::new(),
4216		},
4217		CliStepDefinition::RetargetRelease {
4218			name: None,
4219			when: None,
4220			always_run: false,
4221			inputs: BTreeMap::new(),
4222		},
4223	]
4224}
4225
4226#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4227pub struct VersionGroup {
4228	pub group_id: String,
4229	pub display_name: String,
4230	pub members: Vec<String>,
4231	pub mismatch_detected: bool,
4232}
4233
4234#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4235pub struct PlannedVersionGroup {
4236	pub group_id: String,
4237	pub display_name: String,
4238	pub members: Vec<String>,
4239	pub mismatch_detected: bool,
4240	pub planned_version: Option<Version>,
4241	pub recommended_bump: BumpSeverity,
4242}
4243
4244#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4245pub struct ChangeSignal {
4246	pub package_id: String,
4247	pub requested_bump: Option<BumpSeverity>,
4248	pub explicit_version: Option<Version>,
4249	pub change_origin: String,
4250	pub evidence_refs: Vec<String>,
4251	pub notes: Option<String>,
4252	pub details: Option<String>,
4253	pub change_type: Option<String>,
4254	pub caused_by: Vec<String>,
4255	pub source_path: PathBuf,
4256}
4257
4258#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4259pub struct CompatibilityAssessment {
4260	pub package_id: String,
4261	pub provider_id: String,
4262	pub severity: BumpSeverity,
4263	pub confidence: String,
4264	pub summary: String,
4265	pub evidence_location: Option<String>,
4266}
4267
4268#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4269pub struct ReleaseDecision {
4270	pub package_id: String,
4271	pub trigger_type: String,
4272	pub recommended_bump: BumpSeverity,
4273	pub planned_version: Option<Version>,
4274	pub group_id: Option<String>,
4275	pub reasons: Vec<String>,
4276	pub upstream_sources: Vec<String>,
4277	pub warnings: Vec<String>,
4278}
4279
4280#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4281pub struct ReleasePlan {
4282	pub workspace_root: PathBuf,
4283	pub decisions: Vec<ReleaseDecision>,
4284	pub groups: Vec<PlannedVersionGroup>,
4285	pub warnings: Vec<String>,
4286	pub unresolved_items: Vec<String>,
4287	pub compatibility_evidence: Vec<CompatibilityAssessment>,
4288}
4289
4290#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
4291pub struct DiscoveryReport {
4292	pub workspace_root: PathBuf,
4293	pub packages: Vec<PackageRecord>,
4294	pub dependencies: Vec<DependencyEdge>,
4295	pub version_groups: Vec<VersionGroup>,
4296	pub warnings: Vec<String>,
4297}
4298
4299#[derive(Debug, Clone, Eq, PartialEq)]
4300pub struct AdapterDiscovery {
4301	pub packages: Vec<PackageRecord>,
4302	pub warnings: Vec<String>,
4303}
4304
4305pub trait EcosystemAdapter {
4306	fn ecosystem(&self) -> Ecosystem;
4307
4308	fn discover(&self, root: &Path) -> MonochangeResult<AdapterDiscovery>;
4309
4310	fn load_configured(
4311		&self,
4312		root: &Path,
4313		package_path: &Path,
4314	) -> MonochangeResult<Option<PackageRecord>>;
4315
4316	fn supported_versioned_file_kind(&self, path: &Path) -> bool;
4317
4318	fn validate_versioned_file(
4319		&self,
4320		full_path: &Path,
4321		display_path: &str,
4322		custom_fields: Option<&[String]>,
4323	) -> MonochangeResult<()>;
4324}
4325
4326/// Build dependency edges by matching declared dependency names to known packages.
4327#[must_use]
4328/// A registry of ecosystem adapters used to dispatch ecosystem-specific operations.
4329///
4330/// This replaces hardcoded match arms in workspace discovery, versioned-file validation,
4331/// publish command construction, and lockfile command planning.
4332#[derive(Default)]
4333pub struct EcosystemRegistry {
4334	adapters: Vec<Box<dyn EcosystemAdapter>>,
4335}
4336
4337impl EcosystemRegistry {
4338	pub fn new() -> Self {
4339		Self::default()
4340	}
4341
4342	pub fn with_adapter(mut self, adapter: Box<dyn EcosystemAdapter>) -> Self {
4343		self.adapters.push(adapter);
4344		self
4345	}
4346
4347	pub fn push_adapter(&mut self, adapter: Box<dyn EcosystemAdapter>) {
4348		self.adapters.push(adapter);
4349	}
4350
4351	pub fn discover_all(&self, root: &Path) -> MonochangeResult<AdapterDiscovery> {
4352		let mut packages = Vec::new();
4353		let mut warnings = Vec::new();
4354		for adapter in &self.adapters {
4355			let mut result = adapter.discover(root)?;
4356			packages.append(&mut result.packages);
4357			warnings.append(&mut result.warnings);
4358		}
4359		Ok(AdapterDiscovery { packages, warnings })
4360	}
4361
4362	pub fn adapter_for_ecosystem(&self, ecosystem: Ecosystem) -> Option<&dyn EcosystemAdapter> {
4363		self.adapters
4364			.iter()
4365			.find(|a| a.ecosystem() == ecosystem)
4366			.map(AsRef::as_ref)
4367	}
4368
4369	pub fn load_configured(
4370		&self,
4371		root: &Path,
4372		package_path: &Path,
4373		ecosystem: Ecosystem,
4374	) -> MonochangeResult<Option<PackageRecord>> {
4375		match self.adapter_for_ecosystem(ecosystem) {
4376			Some(adapter) => adapter.load_configured(root, package_path),
4377			None => Ok(None),
4378		}
4379	}
4380
4381	pub fn supported_versioned_file_kind(&self, path: &Path, ecosystem: Ecosystem) -> bool {
4382		self.adapter_for_ecosystem(ecosystem)
4383			.is_some_and(|adapter| adapter.supported_versioned_file_kind(path))
4384	}
4385
4386	pub fn validate_versioned_file(
4387		&self,
4388		full_path: &Path,
4389		display_path: &str,
4390		ecosystem: Ecosystem,
4391		custom_fields: Option<&[String]>,
4392	) -> MonochangeResult<()> {
4393		match self.adapter_for_ecosystem(ecosystem) {
4394			Some(adapter) => {
4395				adapter.validate_versioned_file(full_path, display_path, custom_fields)
4396			}
4397			None => {
4398				Err(MonochangeError::Config(format!(
4399					"no adapter registered for ecosystem `{ecosystem}`"
4400				)))
4401			}
4402		}
4403	}
4404}
4405
4406pub fn materialize_dependency_edges(packages: &[PackageRecord]) -> Vec<DependencyEdge> {
4407	let mut package_ids_by_name = BTreeMap::<String, Vec<String>>::new();
4408	for package in packages {
4409		package_ids_by_name
4410			.entry(package.name.clone())
4411			.or_default()
4412			.push(package.id.clone());
4413	}
4414
4415	let mut edges = Vec::new();
4416	for package in packages {
4417		for dependency in &package.declared_dependencies {
4418			if let Some(target_package_ids) = package_ids_by_name.get(&dependency.name) {
4419				for target_package_id in target_package_ids {
4420					edges.push(DependencyEdge {
4421						from_package_id: package.id.clone(),
4422						to_package_id: target_package_id.clone(),
4423						dependency_kind: dependency.kind,
4424						source_kind: DependencySourceKind::Manifest,
4425						version_constraint: dependency.version_constraint.clone(),
4426						is_optional: dependency.optional,
4427						is_direct: true,
4428					});
4429				}
4430			}
4431		}
4432	}
4433
4434	edges
4435}
4436
4437#[cfg(test)]
4438#[path = "__tests__/proptest_bump_severity_tests.rs"]
4439mod proptest_bump_severity;
4440
4441#[cfg(feature = "schema")]
4442pub mod schema {
4443	/// Generate the JSON Schema for the monochange release record.
4444	pub fn release_record() -> schemars::Schema {
4445		schemars::schema_for!(super::ReleaseRecord)
4446	}
4447}
4448
4449#[cfg(test)]
4450#[path = "__tests__/lib_tests.rs"]
4451mod tests;