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//!     }],
45//! };
46//!
47//! let rendered = render_release_notes(ChangelogFormat::KeepAChangelog, &notes);
48//!
49//! assert!(rendered.contains("## 1.2.3"));
50//! assert!(rendered.contains("### Features"));
51//! assert!(rendered.contains("- add keep-a-changelog output"));
52//! ```
53//! <!-- {/monochangeCoreCrateDocs} -->
54
55use std::collections::BTreeMap;
56use std::collections::BTreeSet;
57use std::env;
58use std::fmt;
59use std::fs;
60use std::path::Path;
61use std::path::PathBuf;
62
63pub mod git;
64
65use ignore::gitignore::Gitignore;
66use ignore::gitignore::GitignoreBuilder;
67use semver::Version;
68use serde::Deserialize;
69use serde::Serialize;
70use thiserror::Error;
71
72pub type MonochangeResult<T> = Result<T, MonochangeError>;
73
74/// Default release title template for primary versioning: `1.2.3 (2026-04-06)`.
75pub const DEFAULT_RELEASE_TITLE_PRIMARY: &str = "{{ version }} ({{ date }})";
76/// Default release title template for namespaced versioning: `my-pkg 1.2.3 (2026-04-06)`.
77pub const DEFAULT_RELEASE_TITLE_NAMESPACED: &str = "{{ id }} {{ version }} ({{ date }})";
78/// Default changelog version title for primary versioning (markdown-linked when source configured).
79pub const DEFAULT_CHANGELOG_VERSION_TITLE_PRIMARY: &str =
80	"{% if tag_url %}[{{ version }}]({{ tag_url }}){% else %}{{ version }}{% endif %} ({{ date }})";
81/// Default changelog version title for namespaced versioning (markdown-linked when source configured).
82pub const DEFAULT_CHANGELOG_VERSION_TITLE_NAMESPACED: &str = "{% if tag_url %}{{ id }} [{{ version }}]({{ tag_url }}){% else %}{{ id }} {{ version }}{% endif %} ({{ date }})";
83
84#[derive(Debug, Error)]
85#[non_exhaustive]
86pub enum MonochangeError {
87	#[error("io error: {0}")]
88	Io(String),
89	#[error("config error: {0}")]
90	Config(String),
91	#[error("discovery error: {0}")]
92	Discovery(String),
93	#[error("{0}")]
94	Diagnostic(String),
95	#[error("io error at {path:?}: {source}")]
96	IoSource {
97		path: PathBuf,
98		source: std::io::Error,
99	},
100	#[error("parse error at {path:?}: {source}")]
101	Parse {
102		path: PathBuf,
103		source: Box<dyn std::error::Error + Send + Sync>,
104	},
105	#[cfg(feature = "http")]
106	#[error("http error {context}: {source}")]
107	HttpRequest {
108		context: String,
109		source: reqwest::Error,
110	},
111	#[error("interactive error: {message}")]
112	Interactive { message: String },
113	#[error("cancelled")]
114	Cancelled,
115}
116
117impl MonochangeError {
118	/// Render a stable human-readable diagnostic string for the error.
119	#[must_use]
120	pub fn render(&self) -> String {
121		match self {
122			Self::Diagnostic(report) => report.clone(),
123			Self::IoSource { path, source } => {
124				format!("io error at {}: {source}", path.display())
125			}
126			Self::Parse { path, source } => {
127				format!("parse error at {}: {source}", path.display())
128			}
129			#[cfg(feature = "http")]
130			Self::HttpRequest { context, source } => {
131				format!("http error {context}: {source}")
132			}
133			Self::Interactive { message } => message.clone(),
134			Self::Cancelled => "cancelled".to_string(),
135			_ => self.to_string(),
136		}
137	}
138}
139
140#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142#[non_exhaustive]
143pub enum BumpSeverity {
144	None,
145	#[default]
146	Patch,
147	Minor,
148	Major,
149}
150
151impl BumpSeverity {
152	/// Return `true` when this severity produces a release.
153	#[must_use]
154	pub fn is_release(self) -> bool {
155		self != Self::None
156	}
157
158	/// Returns `true` when the version is below `1.0.0`.
159	///
160	/// Pre-1.0 packages use a shifted bump policy where major changes bump
161	/// the minor component and minor changes bump the patch component.
162	#[must_use]
163	pub fn is_pre_stable(version: &Version) -> bool {
164		version.major == 0
165	}
166
167	/// Apply the severity to `version`, including pre-1.0 bump shifting.
168	#[must_use]
169	pub fn apply_to_version(self, version: &Version) -> Version {
170		let effective = if Self::is_pre_stable(version) {
171			match self {
172				Self::Major => Self::Minor,
173				Self::Minor => Self::Patch,
174				other => other,
175			}
176		} else {
177			self
178		};
179
180		let mut next = version.clone();
181		match effective {
182			Self::None => next,
183			Self::Patch => {
184				next.patch += 1;
185				next.pre = semver::Prerelease::EMPTY;
186				next.build = semver::BuildMetadata::EMPTY;
187				next
188			}
189			Self::Minor => {
190				next.minor += 1;
191				next.patch = 0;
192				next.pre = semver::Prerelease::EMPTY;
193				next.build = semver::BuildMetadata::EMPTY;
194				next
195			}
196			Self::Major => {
197				next.major += 1;
198				next.minor = 0;
199				next.patch = 0;
200				next.pre = semver::Prerelease::EMPTY;
201				next.build = semver::BuildMetadata::EMPTY;
202				next
203			}
204		}
205	}
206}
207
208impl fmt::Display for BumpSeverity {
209	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
210		formatter.write_str(match self {
211			Self::None => "none",
212			Self::Patch => "patch",
213			Self::Minor => "minor",
214			Self::Major => "major",
215		})
216	}
217}
218
219#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221#[non_exhaustive]
222pub enum Ecosystem {
223	Cargo,
224	Npm,
225	Deno,
226	Dart,
227	Flutter,
228}
229
230impl Ecosystem {
231	/// Return the canonical config and serialization string for the ecosystem.
232	#[must_use]
233	pub fn as_str(self) -> &'static str {
234		match self {
235			Self::Cargo => "cargo",
236			Self::Npm => "npm",
237			Self::Deno => "deno",
238			Self::Dart => "dart",
239			Self::Flutter => "flutter",
240		}
241	}
242}
243
244impl fmt::Display for Ecosystem {
245	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
246		formatter.write_str(self.as_str())
247	}
248}
249
250#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252#[non_exhaustive]
253pub enum PublishState {
254	Public,
255	Private,
256	Unpublished,
257	Excluded,
258}
259
260#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
261#[serde(rename_all = "snake_case")]
262#[non_exhaustive]
263pub enum DependencyKind {
264	Runtime,
265	Development,
266	Build,
267	Peer,
268	Workspace,
269	Unknown,
270}
271
272impl fmt::Display for DependencyKind {
273	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
274		formatter.write_str(match self {
275			Self::Runtime => "runtime",
276			Self::Development => "development",
277			Self::Build => "build",
278			Self::Peer => "peer",
279			Self::Workspace => "workspace",
280			Self::Unknown => "unknown",
281		})
282	}
283}
284
285#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287#[non_exhaustive]
288pub enum DependencySourceKind {
289	Manifest,
290	Workspace,
291	Transitive,
292}
293
294#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
295pub struct PackageDependency {
296	pub name: String,
297	pub kind: DependencyKind,
298	pub version_constraint: Option<String>,
299	pub optional: bool,
300}
301
302#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
303pub struct PackageRecord {
304	pub id: String,
305	pub name: String,
306	pub ecosystem: Ecosystem,
307	pub manifest_path: PathBuf,
308	pub workspace_root: PathBuf,
309	pub current_version: Option<Version>,
310	pub publish_state: PublishState,
311	pub version_group_id: Option<String>,
312	pub metadata: BTreeMap<String, String>,
313	pub declared_dependencies: Vec<PackageDependency>,
314}
315
316impl PackageRecord {
317	#[allow(clippy::needless_pass_by_value)]
318	/// Construct a normalized package record for a discovered package.
319	#[must_use]
320	pub fn new(
321		ecosystem: Ecosystem,
322		name: impl Into<String>,
323		manifest_path: PathBuf,
324		workspace_root: PathBuf,
325		current_version: Option<Version>,
326		publish_state: PublishState,
327	) -> Self {
328		let name = name.into();
329		let normalized_workspace_root = normalize_path(&workspace_root);
330		let normalized_manifest_path = normalize_path(&manifest_path);
331		let id_path = relative_to_root(&normalized_workspace_root, &normalized_manifest_path)
332			.unwrap_or_else(|| normalized_manifest_path.clone());
333		let id = format!("{}:{}", ecosystem.as_str(), id_path.to_string_lossy());
334
335		Self {
336			id,
337			name,
338			ecosystem,
339			manifest_path: normalized_manifest_path,
340			workspace_root: normalized_workspace_root,
341			current_version,
342			publish_state,
343			version_group_id: None,
344			metadata: BTreeMap::new(),
345			declared_dependencies: Vec::new(),
346		}
347	}
348
349	/// Return the manifest path relative to `root` when possible.
350	#[must_use]
351	pub fn relative_manifest_path(&self, root: &Path) -> Option<PathBuf> {
352		relative_to_root(root, &self.manifest_path)
353	}
354}
355
356/// Normalize a path to an absolute, canonicalized path when possible.
357#[must_use]
358pub fn normalize_path(path: &Path) -> PathBuf {
359	let absolute = if path.is_absolute() {
360		path.to_path_buf()
361	} else {
362		env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
363	};
364	fs::canonicalize(&absolute).unwrap_or(absolute)
365}
366
367/// Return `path` relative to `root` after normalizing both paths.
368#[must_use]
369pub fn relative_to_root(root: &Path, path: &Path) -> Option<PathBuf> {
370	let normalized_root = normalize_path(root);
371	let normalized_path = normalize_path(path);
372	normalized_path
373		.strip_prefix(&normalized_root)
374		.ok()
375		.map(Path::to_path_buf)
376}
377
378#[derive(Clone, Debug)]
379pub struct DiscoveryPathFilter {
380	root: PathBuf,
381	gitignore: Gitignore,
382}
383
384impl DiscoveryPathFilter {
385	/// Build a discovery filter from repository gitignore rules.
386	#[must_use]
387	pub fn new(root: &Path) -> Self {
388		let root = normalize_path(root);
389		let mut builder = GitignoreBuilder::new(&root);
390		for path in [root.join(".gitignore"), root.join(".git/info/exclude")] {
391			if path.is_file() {
392				let _ = builder.add(path);
393			}
394		}
395		let gitignore = builder.build().unwrap_or_else(|_| Gitignore::empty());
396
397		Self { root, gitignore }
398	}
399
400	/// Return `true` when `path` should be considered during discovery.
401	#[must_use]
402	pub fn allows(&self, path: &Path) -> bool {
403		!self.is_ignored(path, path.is_dir())
404	}
405
406	/// Return `true` when directory traversal should continue into `path`.
407	#[must_use]
408	pub fn should_descend(&self, path: &Path) -> bool {
409		!self.is_ignored(path, true)
410	}
411
412	fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
413		if ignored_discovery_dir_name(path) || self.has_nested_git_worktree_ancestor(path, is_dir) {
414			return true;
415		}
416
417		self.matches_gitignore(path, is_dir)
418	}
419
420	fn matches_gitignore(&self, path: &Path, is_dir: bool) -> bool {
421		let normalized_path = normalize_path(path);
422		normalized_path
423			.strip_prefix(&self.root)
424			.ok()
425			.is_some_and(|relative| {
426				self.gitignore
427					.matched_path_or_any_parents(relative, is_dir)
428					.is_ignore()
429			})
430	}
431
432	fn has_nested_git_worktree_ancestor(&self, path: &Path, is_dir: bool) -> bool {
433		let normalized_path = normalize_path(path);
434		let mut current = if is_dir {
435			normalized_path.clone()
436		} else {
437			normalized_path
438				.parent()
439				.unwrap_or(&normalized_path)
440				.to_path_buf()
441		};
442
443		while current.starts_with(&self.root) && current != self.root {
444			if current.join(".git").exists() {
445				return true;
446			}
447			let Some(parent) = current.parent() else {
448				break;
449			};
450			current = parent.to_path_buf();
451		}
452
453		false
454	}
455}
456
457fn ignored_discovery_dir_name(path: &Path) -> bool {
458	path.components().any(|component| {
459		component.as_os_str().to_str().is_some_and(|name| {
460			matches!(
461				name,
462				".git" | "target" | "node_modules" | ".devenv" | ".claude" | "book"
463			)
464		})
465	})
466}
467
468#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
469pub struct DependencyEdge {
470	pub from_package_id: String,
471	pub to_package_id: String,
472	pub dependency_kind: DependencyKind,
473	pub source_kind: DependencySourceKind,
474	pub version_constraint: Option<String>,
475	pub is_optional: bool,
476	pub is_direct: bool,
477}
478
479#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481#[non_exhaustive]
482pub enum PackageType {
483	Cargo,
484	Npm,
485	Deno,
486	Dart,
487	Flutter,
488}
489
490impl PackageType {
491	/// Return the canonical config string for the package type.
492	#[must_use]
493	pub fn as_str(self) -> &'static str {
494		match self {
495			Self::Cargo => "cargo",
496			Self::Npm => "npm",
497			Self::Deno => "deno",
498			Self::Dart => "dart",
499			Self::Flutter => "flutter",
500		}
501	}
502}
503
504#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
505#[serde(rename_all = "snake_case")]
506#[non_exhaustive]
507pub enum VersionFormat {
508	#[default]
509	Namespaced,
510	Primary,
511}
512
513#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515#[non_exhaustive]
516pub enum EcosystemType {
517	Cargo,
518	Npm,
519	Deno,
520	Dart,
521}
522
523impl EcosystemType {
524	/// Return the default dependency-version prefix for this ecosystem.
525	#[must_use]
526	pub fn default_prefix(self) -> &'static str {
527		match self {
528			Self::Cargo => "",
529			Self::Npm | Self::Deno | Self::Dart => "^",
530		}
531	}
532
533	/// Return the manifest fields that usually contain dependency versions.
534	#[must_use]
535	pub fn default_fields(self) -> &'static [&'static str] {
536		match self {
537			Self::Cargo => &["dependencies", "dev-dependencies", "build-dependencies"],
538			Self::Npm => &["dependencies", "devDependencies", "peerDependencies"],
539			Self::Deno => &["imports"],
540			Self::Dart => &["dependencies", "dev_dependencies"],
541		}
542	}
543}
544
545#[derive(Clone, Copy, Debug, Eq, PartialEq)]
546struct JsonSpan {
547	start: usize,
548	end: usize,
549}
550
551/// Remove `//` and `/* ... */` comments from JSON-like text.
552pub fn strip_json_comments(contents: &str) -> String {
553	let bytes = contents.as_bytes();
554	let mut output = String::with_capacity(contents.len());
555	let mut cursor = 0usize;
556	while let Some(&byte) = bytes.get(cursor) {
557		if byte == b'"' {
558			let start = cursor;
559			cursor += 1;
560			while let Some(&string_byte) = bytes.get(cursor) {
561				cursor += 1;
562				if string_byte == b'\\' {
563					cursor += usize::from(bytes.get(cursor).is_some());
564					continue;
565				}
566				if string_byte == b'"' {
567					break;
568				}
569			}
570			output.push_str(&contents[start..cursor]);
571			continue;
572		}
573		if byte == b'/' && bytes.get(cursor + 1) == Some(&b'/') {
574			cursor += 2;
575			while let Some(&line_byte) = bytes.get(cursor) {
576				if line_byte == b'\n' {
577					break;
578				}
579				cursor += 1;
580			}
581			continue;
582		}
583		if byte == b'/' && bytes.get(cursor + 1) == Some(&b'*') {
584			cursor += 2;
585			while bytes.get(cursor).is_some() {
586				if bytes.get(cursor) == Some(&b'*') && bytes.get(cursor + 1) == Some(&b'/') {
587					cursor += 2;
588					break;
589				}
590				cursor += 1;
591			}
592			continue;
593		}
594		output.push(char::from(byte));
595		cursor += 1;
596	}
597	output
598}
599
600/// Update JSON manifest text while preserving most existing formatting.
601#[must_use = "the manifest update result must be checked"]
602pub fn update_json_manifest_text(
603	contents: &str,
604	owner_version: Option<&str>,
605	fields: &[&str],
606	versioned_deps: &BTreeMap<String, String>,
607) -> MonochangeResult<String> {
608	let root_start = json_root_object_start(contents)?;
609	let mut replacements = Vec::<(JsonSpan, String)>::new();
610	if let Some(owner_version) = owner_version
611		&& let Some(span) = find_json_object_field_value_span(contents, root_start, "version")?
612			.filter(|span| json_span_is_string(contents, *span))
613	{
614		replacements.push((span, render_json_string(owner_version)?));
615	}
616	for field in fields {
617		let Some(field_span) = find_json_path_value_span(contents, root_start, field)? else {
618			continue;
619		};
620		if json_span_is_object(contents, field_span) {
621			for (dep_name, dep_version) in versioned_deps {
622				let Some(dep_span) =
623					find_json_object_field_value_span(contents, field_span.start, dep_name)?
624						.filter(|span| json_span_is_string(contents, *span))
625				else {
626					continue;
627				};
628				replacements.push((dep_span, render_json_string(dep_version)?));
629			}
630			continue;
631		}
632		if let Some(owner_version) = owner_version
633			&& json_span_is_string(contents, field_span)
634		{
635			replacements.push((field_span, render_json_string(owner_version)?));
636		}
637	}
638	apply_json_replacements(contents, replacements)
639}
640
641fn render_json_string(value: &str) -> MonochangeResult<String> {
642	serde_json::to_string(value).map_err(|error| MonochangeError::Config(error.to_string()))
643}
644
645fn apply_json_replacements(
646	contents: &str,
647	mut replacements: Vec<(JsonSpan, String)>,
648) -> MonochangeResult<String> {
649	replacements.sort_by_key(|right| std::cmp::Reverse(right.0.start));
650	let mut updated = contents.to_string();
651	for (span, replacement) in replacements {
652		if span.start > span.end || span.end > updated.len() {
653			return Err(MonochangeError::Config(
654				"json edit range was out of bounds".to_string(),
655			));
656		}
657		updated.replace_range(span.start..span.end, &replacement);
658	}
659	Ok(updated)
660}
661
662fn json_root_object_start(contents: &str) -> MonochangeResult<usize> {
663	let start = skip_json_ws_and_comments(contents, 0);
664	if contents.as_bytes().get(start) == Some(&b'{') {
665		Ok(start)
666	} else {
667		Err(MonochangeError::Config(
668			"expected JSON object at document root".to_string(),
669		))
670	}
671}
672
673fn find_json_path_value_span(
674	contents: &str,
675	root_start: usize,
676	path: &str,
677) -> MonochangeResult<Option<JsonSpan>> {
678	let mut segments = path.split('.').filter(|segment| !segment.is_empty());
679	let Some(first) = segments.next() else {
680		return Ok(None);
681	};
682	let Some(mut span) = find_json_object_field_value_span(contents, root_start, first)? else {
683		return Ok(None);
684	};
685	for segment in segments {
686		if !json_span_is_object(contents, span) {
687			return Ok(None);
688		}
689		let Some(next_span) = find_json_object_field_value_span(contents, span.start, segment)?
690		else {
691			return Ok(None);
692		};
693		span = next_span;
694	}
695	Ok(Some(span))
696}
697
698fn find_json_object_field_value_span(
699	contents: &str,
700	object_start: usize,
701	key: &str,
702) -> MonochangeResult<Option<JsonSpan>> {
703	let bytes = contents.as_bytes();
704	if bytes.get(object_start) != Some(&b'{') {
705		return Err(MonochangeError::Config(
706			"expected JSON object when locating field".to_string(),
707		));
708	}
709	let mut cursor = object_start + 1;
710	loop {
711		cursor = skip_json_ws_and_comments(contents, cursor);
712		match bytes.get(cursor) {
713			Some(b'}') => return Ok(None),
714			Some(b'"') => {}
715			Some(_) => {
716				return Err(MonochangeError::Config(
717					"expected JSON object key".to_string(),
718				));
719			}
720			None => {
721				return Err(MonochangeError::Config(
722					"unterminated JSON object".to_string(),
723				));
724			}
725		}
726		let (key_span, next) = parse_json_string_span(contents, cursor)?;
727		let key_text = &contents[key_span.start..key_span.end];
728		cursor = skip_json_ws_and_comments(contents, next);
729		if bytes.get(cursor) != Some(&b':') {
730			return Err(MonochangeError::Config(
731				"expected `:` after JSON object key".to_string(),
732			));
733		}
734		cursor = skip_json_ws_and_comments(contents, cursor + 1);
735		let value_start = cursor;
736		let value_end = skip_json_value(contents, value_start)?;
737		if key_text == key {
738			return Ok(Some(JsonSpan {
739				start: value_start,
740				end: value_end,
741			}));
742		}
743		cursor = skip_json_ws_and_comments(contents, value_end);
744		match bytes.get(cursor) {
745			Some(b',') => {
746				cursor += 1;
747			}
748			Some(b'}') => return Ok(None),
749			Some(_) => {
750				return Err(MonochangeError::Config(
751					"expected `,` or `}` after JSON object value".to_string(),
752				));
753			}
754			None => {
755				return Err(MonochangeError::Config(
756					"unterminated JSON object".to_string(),
757				));
758			}
759		}
760	}
761}
762
763fn skip_json_value(contents: &str, start: usize) -> MonochangeResult<usize> {
764	let bytes = contents.as_bytes();
765	let cursor = skip_json_ws_and_comments(contents, start);
766	match bytes.get(cursor) {
767		Some(b'"') => parse_json_string_span(contents, cursor).map(|(_, next)| next),
768		Some(b'{') => skip_json_object(contents, cursor),
769		Some(b'[') => skip_json_array(contents, cursor),
770		Some(_) => Ok(skip_json_primitive(contents, cursor)),
771		None => {
772			Err(MonochangeError::Config(
773				"unexpected end of JSON input".to_string(),
774			))
775		}
776	}
777}
778
779fn skip_json_object(contents: &str, object_start: usize) -> MonochangeResult<usize> {
780	let bytes = contents.as_bytes();
781	let mut cursor = object_start + 1;
782	loop {
783		cursor = skip_json_ws_and_comments(contents, cursor);
784		match bytes.get(cursor) {
785			Some(b'}') => return Ok(cursor + 1),
786			Some(b'"') => {}
787			Some(_) => {
788				return Err(MonochangeError::Config(
789					"expected JSON object key".to_string(),
790				));
791			}
792			None => {
793				return Err(MonochangeError::Config(
794					"unterminated JSON object".to_string(),
795				));
796			}
797		}
798		let (_, next) = parse_json_string_span(contents, cursor)?;
799		cursor = skip_json_ws_and_comments(contents, next);
800		if bytes.get(cursor) != Some(&b':') {
801			return Err(MonochangeError::Config(
802				"expected `:` after JSON object key".to_string(),
803			));
804		}
805		cursor = skip_json_value(contents, cursor + 1)?;
806		cursor = skip_json_ws_and_comments(contents, cursor);
807		match bytes.get(cursor) {
808			Some(b',') => {
809				cursor += 1;
810			}
811			Some(b'}') => return Ok(cursor + 1),
812			Some(_) => {
813				return Err(MonochangeError::Config(
814					"expected `,` or `}` after JSON object value".to_string(),
815				));
816			}
817			None => {
818				return Err(MonochangeError::Config(
819					"unterminated JSON object".to_string(),
820				));
821			}
822		}
823	}
824}
825
826fn skip_json_array(contents: &str, array_start: usize) -> MonochangeResult<usize> {
827	let bytes = contents.as_bytes();
828	let mut cursor = array_start + 1;
829	loop {
830		cursor = skip_json_ws_and_comments(contents, cursor);
831		match bytes.get(cursor) {
832			Some(b']') => return Ok(cursor + 1),
833			Some(_) => {
834				cursor = skip_json_value(contents, cursor)?;
835				cursor = skip_json_ws_and_comments(contents, cursor);
836				match bytes.get(cursor) {
837					Some(b',') => {
838						cursor += 1;
839					}
840					Some(b']') => return Ok(cursor + 1),
841					Some(_) => {
842						return Err(MonochangeError::Config(
843							"expected `,` or `]` after JSON array value".to_string(),
844						));
845					}
846					None => {
847						return Err(MonochangeError::Config(
848							"unterminated JSON array".to_string(),
849						));
850					}
851				}
852			}
853			None => {
854				return Err(MonochangeError::Config(
855					"unterminated JSON array".to_string(),
856				));
857			}
858		}
859	}
860}
861
862fn skip_json_primitive(contents: &str, start: usize) -> usize {
863	let bytes = contents.as_bytes();
864	let mut cursor = start;
865	while let Some(&byte) = bytes.get(cursor) {
866		if matches!(byte, b',' | b'}' | b']') || byte.is_ascii_whitespace() {
867			break;
868		}
869		if byte == b'/' && matches!(bytes.get(cursor + 1), Some(b'/' | b'*')) {
870			break;
871		}
872		cursor += 1;
873	}
874	cursor
875}
876
877fn parse_json_string_span(contents: &str, start: usize) -> MonochangeResult<(JsonSpan, usize)> {
878	let bytes = contents.as_bytes();
879	if bytes.get(start) != Some(&b'"') {
880		return Err(MonochangeError::Config("expected JSON string".to_string()));
881	}
882	let mut cursor = start + 1;
883	while let Some(&byte) = bytes.get(cursor) {
884		if byte == b'\\' {
885			// Escape sequence: verify there is a character after the backslash.
886			let Some(&escape_char) = bytes.get(cursor + 1) else {
887				return Err(MonochangeError::Config(
888					"unterminated escape sequence in JSON string".to_string(),
889				));
890			};
891			if escape_char == b'u' {
892				// Unicode escape \uXXXX requires exactly 4 hex digits.
893				for offset in 2..6 {
894					match bytes.get(cursor + offset) {
895						Some(b) if b.is_ascii_hexdigit() => {}
896						Some(_) => {
897							return Err(MonochangeError::Config(format!(
898								"invalid unicode escape sequence in JSON string: expected hex digit at position {}",
899								cursor + offset
900							)));
901						}
902						None => {
903							return Err(MonochangeError::Config(
904								"incomplete unicode escape sequence in JSON string".to_string(),
905							));
906						}
907					}
908				}
909				cursor += 6;
910			} else {
911				cursor += 2;
912			}
913			continue;
914		}
915		if byte == b'"' {
916			return Ok((
917				JsonSpan {
918					start: start + 1,
919					end: cursor,
920				},
921				cursor + 1,
922			));
923		}
924		cursor += 1;
925	}
926	Err(MonochangeError::Config(
927		"unterminated JSON string".to_string(),
928	))
929}
930
931fn skip_json_ws_and_comments(contents: &str, start: usize) -> usize {
932	let bytes = contents.as_bytes();
933	let mut cursor = start;
934	loop {
935		while let Some(&byte) = bytes.get(cursor) {
936			if !byte.is_ascii_whitespace() {
937				break;
938			}
939			cursor += 1;
940		}
941		if bytes.get(cursor) == Some(&b'/') && bytes.get(cursor + 1) == Some(&b'/') {
942			cursor += 2;
943			while let Some(&byte) = bytes.get(cursor) {
944				if byte == b'\n' {
945					break;
946				}
947				cursor += 1;
948			}
949			continue;
950		}
951		if bytes.get(cursor) == Some(&b'/') && bytes.get(cursor + 1) == Some(&b'*') {
952			cursor += 2;
953			while bytes.get(cursor).is_some() {
954				if bytes.get(cursor) == Some(&b'*') && bytes.get(cursor + 1) == Some(&b'/') {
955					cursor += 2;
956					break;
957				}
958				cursor += 1;
959			}
960			continue;
961		}
962		break;
963	}
964	cursor
965}
966
967fn json_span_is_string(contents: &str, span: JsonSpan) -> bool {
968	contents.as_bytes().get(span.start) == Some(&b'"')
969		&& span.end > span.start
970		&& contents.as_bytes().get(span.end - 1) == Some(&b'"')
971}
972
973fn json_span_is_object(contents: &str, span: JsonSpan) -> bool {
974	contents.as_bytes().get(span.start) == Some(&b'{')
975		&& span.end > span.start
976		&& contents.as_bytes().get(span.end - 1) == Some(&b'}')
977}
978
979#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
980pub struct VersionedFileDefinition {
981	pub path: String,
982	#[serde(rename = "type", default)]
983	pub ecosystem_type: Option<EcosystemType>,
984	#[serde(default)]
985	pub prefix: Option<String>,
986	#[serde(default)]
987	pub fields: Option<Vec<String>>,
988	#[serde(default)]
989	pub name: Option<String>,
990	#[serde(default)]
991	pub regex: Option<String>,
992}
993
994impl VersionedFileDefinition {
995	/// Return `true` when the definition uses raw regex replacement mode.
996	#[must_use]
997	pub fn uses_regex(&self) -> bool {
998		self.regex.is_some()
999	}
1000}
1001
1002#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1003pub enum ChangelogDefinition {
1004	Disabled,
1005	PackageDefault,
1006	PathPattern(String),
1007}
1008
1009#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
1010#[serde(rename_all = "snake_case")]
1011#[non_exhaustive]
1012pub enum ChangelogFormat {
1013	#[default]
1014	Monochange,
1015	KeepAChangelog,
1016}
1017
1018#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1019pub struct ChangelogTarget {
1020	pub path: PathBuf,
1021	#[serde(default)]
1022	pub format: ChangelogFormat,
1023}
1024
1025#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1026pub struct ReleaseNotesSection {
1027	pub title: String,
1028	pub entries: Vec<String>,
1029}
1030
1031#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1032pub struct ReleaseNotesDocument {
1033	pub title: String,
1034	pub summary: Vec<String>,
1035	pub sections: Vec<ReleaseNotesSection>,
1036}
1037
1038#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1039pub struct ExtraChangelogSection {
1040	pub name: String,
1041	#[serde(default)]
1042	pub types: Vec<String>,
1043	#[serde(default)]
1044	pub default_bump: Option<BumpSeverity>,
1045	/// Description of when this change type should be used.
1046	/// Helpful for LLMs and users to understand the purpose of this section.
1047	#[serde(default)]
1048	pub description: Option<String>,
1049}
1050
1051#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1052pub struct ReleaseNotesSettings {
1053	#[serde(default)]
1054	pub change_templates: Vec<String>,
1055}
1056
1057#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1058pub struct PackageDefinition {
1059	pub id: String,
1060	pub path: PathBuf,
1061	pub package_type: PackageType,
1062	pub changelog: Option<ChangelogTarget>,
1063	pub extra_changelog_sections: Vec<ExtraChangelogSection>,
1064	pub empty_update_message: Option<String>,
1065	#[serde(default)]
1066	pub release_title: Option<String>,
1067	#[serde(default)]
1068	pub changelog_version_title: Option<String>,
1069	pub versioned_files: Vec<VersionedFileDefinition>,
1070	#[serde(default)]
1071	pub ignore_ecosystem_versioned_files: bool,
1072	#[serde(default)]
1073	pub ignored_paths: Vec<String>,
1074	#[serde(default)]
1075	pub additional_paths: Vec<String>,
1076	pub tag: bool,
1077	pub release: bool,
1078	pub version_format: VersionFormat,
1079}
1080
1081#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1082pub enum GroupChangelogInclude {
1083	#[default]
1084	All,
1085	GroupOnly,
1086	Selected(BTreeSet<String>),
1087}
1088
1089#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1090pub struct GroupDefinition {
1091	pub id: String,
1092	pub packages: Vec<String>,
1093	pub changelog: Option<ChangelogTarget>,
1094	#[serde(default)]
1095	pub changelog_include: GroupChangelogInclude,
1096	pub extra_changelog_sections: Vec<ExtraChangelogSection>,
1097	pub empty_update_message: Option<String>,
1098	#[serde(default)]
1099	pub release_title: Option<String>,
1100	#[serde(default)]
1101	pub changelog_version_title: Option<String>,
1102	pub versioned_files: Vec<VersionedFileDefinition>,
1103	pub tag: bool,
1104	pub release: bool,
1105	pub version_format: VersionFormat,
1106}
1107
1108#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1109pub struct WorkspaceDefaults {
1110	pub parent_bump: BumpSeverity,
1111	pub include_private: bool,
1112	pub warn_on_group_mismatch: bool,
1113	pub strict_version_conflicts: bool,
1114	pub package_type: Option<PackageType>,
1115	pub changelog: Option<ChangelogDefinition>,
1116	pub changelog_format: ChangelogFormat,
1117	pub extra_changelog_sections: Vec<ExtraChangelogSection>,
1118	pub empty_update_message: Option<String>,
1119	pub release_title: Option<String>,
1120	pub changelog_version_title: Option<String>,
1121}
1122
1123impl Default for WorkspaceDefaults {
1124	fn default() -> Self {
1125		Self {
1126			parent_bump: BumpSeverity::Patch,
1127			include_private: false,
1128			warn_on_group_mismatch: true,
1129			strict_version_conflicts: false,
1130			package_type: None,
1131			changelog: None,
1132			changelog_format: ChangelogFormat::Monochange,
1133			extra_changelog_sections: Vec::new(),
1134			empty_update_message: None,
1135			release_title: None,
1136			changelog_version_title: None,
1137		}
1138	}
1139}
1140
1141#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1142pub struct EcosystemSettings {
1143	#[serde(default)]
1144	pub enabled: Option<bool>,
1145	#[serde(default)]
1146	pub roots: Vec<String>,
1147	#[serde(default)]
1148	pub exclude: Vec<String>,
1149	#[serde(default)]
1150	pub dependency_version_prefix: Option<String>,
1151	#[serde(default)]
1152	pub versioned_files: Vec<VersionedFileDefinition>,
1153	#[serde(default)]
1154	pub lockfile_commands: Vec<LockfileCommandDefinition>,
1155}
1156
1157#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1158pub struct LockfileCommandDefinition {
1159	pub command: String,
1160	#[serde(default)]
1161	pub cwd: Option<PathBuf>,
1162	#[serde(default)]
1163	pub shell: ShellConfig,
1164}
1165
1166#[derive(Debug, Clone, Eq, PartialEq)]
1167pub struct LockfileCommandExecution {
1168	pub command: String,
1169	pub cwd: PathBuf,
1170	pub shell: ShellConfig,
1171}
1172
1173#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
1174#[serde(rename_all = "snake_case")]
1175pub enum CliInputKind {
1176	String,
1177	StringList,
1178	Path,
1179	Choice,
1180	Boolean,
1181}
1182
1183#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1184pub struct CliInputDefinition {
1185	pub name: String,
1186	#[serde(rename = "type")]
1187	pub kind: CliInputKind,
1188	#[serde(default)]
1189	pub help_text: Option<String>,
1190	#[serde(default)]
1191	pub required: bool,
1192	#[serde(default, deserialize_with = "deserialize_cli_input_default")]
1193	pub default: Option<String>,
1194	#[serde(default)]
1195	pub choices: Vec<String>,
1196	#[serde(default)]
1197	pub short: Option<char>,
1198}
1199
1200#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1201#[serde(untagged)]
1202enum CliInputDefault {
1203	String(String),
1204	Boolean(bool),
1205}
1206
1207fn deserialize_cli_input_default<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
1208where
1209	D: serde::Deserializer<'de>,
1210{
1211	let value = Option::<CliInputDefault>::deserialize(deserializer)?;
1212	Ok(value.map(|value| {
1213		match value {
1214			CliInputDefault::String(value) => value,
1215			CliInputDefault::Boolean(value) => value.to_string(),
1216		}
1217	}))
1218}
1219
1220#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1221#[serde(untagged)]
1222pub enum CliStepInputValue {
1223	String(String),
1224	Boolean(bool),
1225	List(Vec<String>),
1226}
1227#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
1228#[serde(rename_all = "snake_case")]
1229pub enum CommandVariable {
1230	Version,
1231	GroupVersion,
1232	ReleasedPackages,
1233	ChangedFiles,
1234	Changesets,
1235}
1236
1237/// Shell configuration for `Command` steps.
1238#[derive(Debug, Clone, Eq, PartialEq, Default)]
1239pub enum ShellConfig {
1240	#[default]
1241	None,
1242	Default,
1243	Custom(String),
1244}
1245
1246impl ShellConfig {
1247	/// Return the shell binary used to execute a `Command` step, if any.
1248	#[must_use]
1249	pub fn shell_binary(&self) -> Option<&str> {
1250		match self {
1251			Self::None => None,
1252			Self::Default => Some("sh"),
1253			Self::Custom(shell) => Some(shell),
1254		}
1255	}
1256}
1257
1258impl Serialize for ShellConfig {
1259	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1260		match self {
1261			Self::None => serializer.serialize_bool(false),
1262			Self::Default => serializer.serialize_bool(true),
1263			Self::Custom(shell) => serializer.serialize_str(shell),
1264		}
1265	}
1266}
1267
1268impl<'de> Deserialize<'de> for ShellConfig {
1269	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1270		use serde::de;
1271
1272		struct ShellConfigVisitor;
1273
1274		impl de::Visitor<'_> for ShellConfigVisitor {
1275			type Value = ShellConfig;
1276
1277			fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1278				formatter.write_str("a boolean or a shell name string")
1279			}
1280
1281			fn visit_bool<E: de::Error>(self, value: bool) -> Result<ShellConfig, E> {
1282				Ok(if value {
1283					ShellConfig::Default
1284				} else {
1285					ShellConfig::None
1286				})
1287			}
1288
1289			fn visit_str<E: de::Error>(self, value: &str) -> Result<ShellConfig, E> {
1290				if value.is_empty() {
1291					return Err(de::Error::invalid_value(
1292						de::Unexpected::Str(value),
1293						&"a non-empty shell name",
1294					));
1295				}
1296				Ok(ShellConfig::Custom(value.to_string()))
1297			}
1298
1299			fn visit_string<E: de::Error>(self, value: String) -> Result<ShellConfig, E> {
1300				self.visit_str(&value)
1301			}
1302		}
1303
1304		deserializer.deserialize_any(ShellConfigVisitor)
1305	}
1306}
1307
1308/// Built-in execution units for `[[cli.<command>.steps]]`.
1309///
1310/// `monochange` runs steps in order and lets later steps consume state created by
1311/// earlier ones. Use standalone steps such as `Validate`, `Discover`,
1312/// `AffectedPackages`, `DiagnoseChangesets`, and `RetargetRelease` when you want
1313/// inspection or repair. Use `PrepareRelease` when later steps need structured
1314/// release state.
1315///
1316/// See the CLI step reference in the book for full workflow guidance.
1317#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1318#[serde(tag = "type", deny_unknown_fields)]
1319#[non_exhaustive]
1320pub enum CliStepDefinition {
1321	/// Validate `monochange` configuration and changesets without preparing a
1322	/// release.
1323	Validate {
1324		#[serde(default)]
1325		name: Option<String>,
1326		#[serde(default)]
1327		when: Option<String>,
1328		#[serde(default)]
1329		inputs: BTreeMap<String, CliStepInputValue>,
1330	},
1331	/// Discover packages across supported ecosystems and render the result.
1332	Discover {
1333		#[serde(default)]
1334		name: Option<String>,
1335		#[serde(default)]
1336		when: Option<String>,
1337		#[serde(default)]
1338		inputs: BTreeMap<String, CliStepInputValue>,
1339	},
1340	/// Create a `.changeset/*.md` file from typed CLI inputs or interactive
1341	/// prompts.
1342	CreateChangeFile {
1343		#[serde(default)]
1344		name: Option<String>,
1345		#[serde(default)]
1346		when: Option<String>,
1347		#[serde(default)]
1348		show_progress: Option<bool>,
1349		#[serde(default)]
1350		inputs: BTreeMap<String, CliStepInputValue>,
1351	},
1352	/// Prepare a release and expose structured `release.*` context to later
1353	/// steps.
1354	PrepareRelease {
1355		#[serde(default)]
1356		name: Option<String>,
1357		#[serde(default)]
1358		when: Option<String>,
1359		#[serde(default)]
1360		inputs: BTreeMap<String, CliStepInputValue>,
1361	},
1362	/// Create a local release commit with an embedded durable `ReleaseRecord`.
1363	///
1364	/// Requires a previous `PrepareRelease` step.
1365	CommitRelease {
1366		#[serde(default)]
1367		name: Option<String>,
1368		#[serde(default)]
1369		when: Option<String>,
1370		#[serde(default)]
1371		inputs: BTreeMap<String, CliStepInputValue>,
1372	},
1373	/// Publish hosted releases from a prepared `monochange` release.
1374	///
1375	/// Requires a previous `PrepareRelease` step and `[source]`
1376	/// configuration.
1377	PublishRelease {
1378		#[serde(default)]
1379		name: Option<String>,
1380		#[serde(default)]
1381		when: Option<String>,
1382		#[serde(default)]
1383		inputs: BTreeMap<String, CliStepInputValue>,
1384	},
1385	/// Open or update a hosted release request from prepared release state.
1386	///
1387	/// Requires a previous `PrepareRelease` step and `[source]`
1388	/// configuration.
1389	OpenReleaseRequest {
1390		#[serde(default)]
1391		name: Option<String>,
1392		#[serde(default)]
1393		when: Option<String>,
1394		#[serde(default)]
1395		inputs: BTreeMap<String, CliStepInputValue>,
1396	},
1397	/// Comment on linked released issues after a prepared release.
1398	///
1399	/// Requires a previous `PrepareRelease` step and currently expects
1400	/// `[source].provider = "github"`.
1401	CommentReleasedIssues {
1402		#[serde(default)]
1403		name: Option<String>,
1404		#[serde(default)]
1405		when: Option<String>,
1406		#[serde(default)]
1407		inputs: BTreeMap<String, CliStepInputValue>,
1408	},
1409	/// Evaluate affected packages and changeset coverage for changed files.
1410	///
1411	/// Standalone CI-oriented step.
1412	AffectedPackages {
1413		#[serde(default)]
1414		name: Option<String>,
1415		#[serde(default)]
1416		when: Option<String>,
1417		#[serde(default)]
1418		inputs: BTreeMap<String, CliStepInputValue>,
1419	},
1420	/// Inspect parsed changeset data, provenance, and linked metadata.
1421	DiagnoseChangesets {
1422		#[serde(default)]
1423		name: Option<String>,
1424		#[serde(default)]
1425		when: Option<String>,
1426		#[serde(default)]
1427		inputs: BTreeMap<String, CliStepInputValue>,
1428	},
1429	/// Repair a recent release by retargeting its stored release tag set.
1430	///
1431	/// This step is independent from `PrepareRelease` and exposes structured
1432	/// `retarget.*` state to later commands.
1433	RetargetRelease {
1434		#[serde(default)]
1435		name: Option<String>,
1436		#[serde(default)]
1437		when: Option<String>,
1438		#[serde(default)]
1439		inputs: BTreeMap<String, CliStepInputValue>,
1440	},
1441	/// Run an arbitrary command with `monochange` template context.
1442	///
1443	/// Use this to bridge built-in `monochange` state into external tooling.
1444	Command {
1445		#[serde(default)]
1446		name: Option<String>,
1447		#[serde(default)]
1448		when: Option<String>,
1449		#[serde(default)]
1450		show_progress: Option<bool>,
1451		command: String,
1452		#[serde(default)]
1453		dry_run_command: Option<String>,
1454		#[serde(default)]
1455		shell: ShellConfig,
1456		#[serde(default)]
1457		id: Option<String>,
1458		#[serde(default)]
1459		variables: Option<BTreeMap<String, CommandVariable>>,
1460		#[serde(default)]
1461		inputs: BTreeMap<String, CliStepInputValue>,
1462	},
1463}
1464
1465impl CliStepDefinition {
1466	/// Return the step-local input overrides configured for this step.
1467	#[must_use]
1468	pub fn inputs(&self) -> &BTreeMap<String, CliStepInputValue> {
1469		match self {
1470			Self::Validate { inputs, .. }
1471			| Self::Discover { inputs, .. }
1472			| Self::CreateChangeFile { inputs, .. }
1473			| Self::PrepareRelease { inputs, .. }
1474			| Self::CommitRelease { inputs, .. }
1475			| Self::PublishRelease { inputs, .. }
1476			| Self::OpenReleaseRequest { inputs, .. }
1477			| Self::CommentReleasedIssues { inputs, .. }
1478			| Self::AffectedPackages { inputs, .. }
1479			| Self::DiagnoseChangesets { inputs, .. }
1480			| Self::RetargetRelease { inputs, .. }
1481			| Self::Command { inputs, .. } => inputs,
1482		}
1483	}
1484
1485	/// Return the optional configured display name for this step.
1486	#[must_use]
1487	pub fn name(&self) -> Option<&str> {
1488		match self {
1489			Self::Validate { name, .. }
1490			| Self::Discover { name, .. }
1491			| Self::CreateChangeFile { name, .. }
1492			| Self::PrepareRelease { name, .. }
1493			| Self::CommitRelease { name, .. }
1494			| Self::PublishRelease { name, .. }
1495			| Self::OpenReleaseRequest { name, .. }
1496			| Self::CommentReleasedIssues { name, .. }
1497			| Self::AffectedPackages { name, .. }
1498			| Self::DiagnoseChangesets { name, .. }
1499			| Self::RetargetRelease { name, .. }
1500			| Self::Command { name, .. } => name.as_deref(),
1501		}
1502	}
1503
1504	/// Return the label shown in human-readable progress output.
1505	#[must_use]
1506	pub fn display_name(&self) -> &str {
1507		self.name().unwrap_or(self.kind_name())
1508	}
1509
1510	/// Return the optional `when` condition for this step.
1511	#[must_use]
1512	pub fn when(&self) -> Option<&str> {
1513		match self {
1514			Self::Validate { when, .. }
1515			| Self::Discover { when, .. }
1516			| Self::CreateChangeFile { when, .. }
1517			| Self::PrepareRelease { when, .. }
1518			| Self::CommitRelease { when, .. }
1519			| Self::PublishRelease { when, .. }
1520			| Self::OpenReleaseRequest { when, .. }
1521			| Self::CommentReleasedIssues { when, .. }
1522			| Self::AffectedPackages { when, .. }
1523			| Self::DiagnoseChangesets { when, .. }
1524			| Self::RetargetRelease { when, .. }
1525			| Self::Command { when, .. } => when.as_deref(),
1526		}
1527	}
1528
1529	/// Return whether progress output is explicitly enabled or disabled.
1530	#[must_use]
1531	pub fn show_progress(&self) -> Option<bool> {
1532		match self {
1533			Self::CreateChangeFile { show_progress, .. } | Self::Command { show_progress, .. } => {
1534				*show_progress
1535			}
1536			_ => None,
1537		}
1538	}
1539
1540	/// Return the built-in step kind name.
1541	#[must_use]
1542	pub fn kind_name(&self) -> &'static str {
1543		match self {
1544			Self::Validate { .. } => "Validate",
1545			Self::Discover { .. } => "Discover",
1546			Self::CreateChangeFile { .. } => "CreateChangeFile",
1547			Self::PrepareRelease { .. } => "PrepareRelease",
1548			Self::CommitRelease { .. } => "CommitRelease",
1549			Self::PublishRelease { .. } => "PublishRelease",
1550			Self::OpenReleaseRequest { .. } => "OpenReleaseRequest",
1551			Self::CommentReleasedIssues { .. } => "CommentReleasedIssues",
1552			Self::AffectedPackages { .. } => "AffectedPackages",
1553			Self::DiagnoseChangesets { .. } => "DiagnoseChangesets",
1554			Self::RetargetRelease { .. } => "RetargetRelease",
1555			Self::Command { .. } => "Command",
1556		}
1557	}
1558
1559	/// Returns the set of input names that this step kind recognises.
1560	///
1561	/// `Command` steps accept any input (returns `None`).
1562	/// All built-in step kinds return `Some(…)` with the exhaustive set of
1563	/// input names they consume at runtime.
1564	#[must_use]
1565	pub fn valid_input_names(&self) -> Option<&'static [&'static str]> {
1566		match self {
1567			Self::Validate { .. } | Self::CommitRelease { .. } => Some(&[]),
1568			Self::Discover { .. }
1569			| Self::PrepareRelease { .. }
1570			| Self::PublishRelease { .. }
1571			| Self::OpenReleaseRequest { .. }
1572			| Self::CommentReleasedIssues { .. } => Some(&["format"]),
1573			Self::CreateChangeFile { .. } => {
1574				Some(&[
1575					"interactive",
1576					"package",
1577					"bump",
1578					"version",
1579					"reason",
1580					"type",
1581					"details",
1582					"output",
1583				])
1584			}
1585			Self::AffectedPackages { .. } => {
1586				Some(&["format", "changed_paths", "since", "verify", "label"])
1587			}
1588			Self::DiagnoseChangesets { .. } => Some(&["format", "changeset"]),
1589			Self::RetargetRelease { .. } => Some(&["from", "target", "force", "sync_provider"]),
1590			Self::Command { .. } => None,
1591		}
1592	}
1593
1594	/// Returns the expected [`CliInputKind`] for a named input on this step,
1595	/// or `None` when the step is a `Command` (accepts anything) or the name
1596	/// is unrecognised.
1597	#[must_use]
1598	pub fn expected_input_kind(&self, name: &str) -> Option<CliInputKind> {
1599		match self {
1600			Self::Validate { .. } | Self::CommitRelease { .. } | Self::Command { .. } => None,
1601			Self::Discover { .. }
1602			| Self::PrepareRelease { .. }
1603			| Self::PublishRelease { .. }
1604			| Self::OpenReleaseRequest { .. }
1605			| Self::CommentReleasedIssues { .. } => {
1606				match name {
1607					"format" => Some(CliInputKind::Choice),
1608					_ => None,
1609				}
1610			}
1611			Self::CreateChangeFile { .. } => {
1612				match name {
1613					"interactive" => Some(CliInputKind::Boolean),
1614					"package" => Some(CliInputKind::StringList),
1615					"bump" => Some(CliInputKind::Choice),
1616					"version" | "reason" | "type" | "details" => Some(CliInputKind::String),
1617					"output" => Some(CliInputKind::Path),
1618					_ => None,
1619				}
1620			}
1621			Self::AffectedPackages { .. } => {
1622				match name {
1623					"format" => Some(CliInputKind::Choice),
1624					"changed_paths" | "label" => Some(CliInputKind::StringList),
1625					"since" => Some(CliInputKind::String),
1626					"verify" => Some(CliInputKind::Boolean),
1627					_ => None,
1628				}
1629			}
1630			Self::DiagnoseChangesets { .. } => {
1631				match name {
1632					"format" => Some(CliInputKind::Choice),
1633					"changeset" => Some(CliInputKind::StringList),
1634					_ => None,
1635				}
1636			}
1637			Self::RetargetRelease { .. } => {
1638				match name {
1639					"from" | "target" => Some(CliInputKind::String),
1640					"force" | "sync_provider" => Some(CliInputKind::Boolean),
1641					_ => None,
1642				}
1643			}
1644		}
1645	}
1646}
1647
1648#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1649pub struct CliCommandDefinition {
1650	pub name: String,
1651	#[serde(default)]
1652	pub help_text: Option<String>,
1653	#[serde(default)]
1654	pub inputs: Vec<CliInputDefinition>,
1655	#[serde(default)]
1656	pub steps: Vec<CliStepDefinition>,
1657}
1658
1659/// Render release notes in the selected changelog format.
1660#[must_use]
1661pub fn render_release_notes(format: ChangelogFormat, document: &ReleaseNotesDocument) -> String {
1662	match format {
1663		ChangelogFormat::Monochange => render_monochange_release_notes(document),
1664		ChangelogFormat::KeepAChangelog => render_keep_a_changelog_release_notes(document),
1665	}
1666}
1667
1668fn render_monochange_release_notes(document: &ReleaseNotesDocument) -> String {
1669	let mut lines = vec![format!("## {}", document.title), String::new()];
1670	for (index, paragraph) in document.summary.iter().enumerate() {
1671		if index > 0 {
1672			lines.push(String::new());
1673		}
1674		lines.push(paragraph.clone());
1675	}
1676	let include_section_headings = document.sections.len() > 1
1677		|| document
1678			.sections
1679			.iter()
1680			.any(|section| section.title != "Changed");
1681	for section in &document.sections {
1682		if section.entries.is_empty() {
1683			continue;
1684		}
1685		if !lines.last().is_some_and(String::is_empty) {
1686			lines.push(String::new());
1687		}
1688		if include_section_headings {
1689			lines.push(format!("### {}", section.title));
1690			lines.push(String::new());
1691		}
1692		push_release_note_entries(&mut lines, &section.entries);
1693	}
1694	lines.join("\n")
1695}
1696
1697fn render_keep_a_changelog_release_notes(document: &ReleaseNotesDocument) -> String {
1698	let mut lines = vec![format!("## {}", document.title), String::new()];
1699	for (index, paragraph) in document.summary.iter().enumerate() {
1700		if index > 0 {
1701			lines.push(String::new());
1702		}
1703		lines.push(paragraph.clone());
1704	}
1705	for section in &document.sections {
1706		if section.entries.is_empty() {
1707			continue;
1708		}
1709		if !lines.last().is_some_and(String::is_empty) {
1710			lines.push(String::new());
1711		}
1712		lines.push(format!("### {}", section.title));
1713		lines.push(String::new());
1714		push_release_note_entries(&mut lines, &section.entries);
1715	}
1716	lines.join("\n")
1717}
1718
1719fn push_release_note_entries(lines: &mut Vec<String>, entries: &[String]) {
1720	for (index, entry) in entries.iter().enumerate() {
1721		let trimmed = entry.trim();
1722		if trimmed.contains('\n') {
1723			lines.extend(trimmed.lines().map(ToString::to_string));
1724			if index + 1 < entries.len() {
1725				lines.push(String::new());
1726			}
1727			continue;
1728		}
1729		if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with('#') {
1730			lines.push(trimmed.to_string());
1731		} else {
1732			lines.push(format!("- {trimmed}"));
1733		}
1734	}
1735}
1736
1737#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
1738#[serde(rename_all = "snake_case")]
1739pub enum ReleaseOwnerKind {
1740	Package,
1741	Group,
1742}
1743
1744impl ReleaseOwnerKind {
1745	/// Return the canonical serialized name for the release-owner kind.
1746	#[must_use]
1747	pub fn as_str(self) -> &'static str {
1748		match self {
1749			Self::Package => "package",
1750			Self::Group => "group",
1751		}
1752	}
1753}
1754
1755impl fmt::Display for ReleaseOwnerKind {
1756	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1757		formatter.write_str(self.as_str())
1758	}
1759}
1760
1761#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1762#[serde(rename_all = "camelCase")]
1763pub struct ReleaseManifestTarget {
1764	pub id: String,
1765	pub kind: ReleaseOwnerKind,
1766	pub version: String,
1767	pub tag: bool,
1768	pub release: bool,
1769	pub version_format: VersionFormat,
1770	pub tag_name: String,
1771	pub members: Vec<String>,
1772	#[serde(default)]
1773	pub rendered_title: String,
1774	#[serde(default)]
1775	pub rendered_changelog_title: String,
1776}
1777
1778#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1779#[serde(rename_all = "camelCase")]
1780pub struct ReleaseManifestChangelog {
1781	pub owner_id: String,
1782	pub owner_kind: ReleaseOwnerKind,
1783	pub path: PathBuf,
1784	pub format: ChangelogFormat,
1785	pub notes: ReleaseNotesDocument,
1786	pub rendered: String,
1787}
1788
1789#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
1790#[serde(rename_all = "snake_case")]
1791pub enum HostingProviderKind {
1792	#[default]
1793	#[serde(rename = "generic_git")]
1794	GenericGit,
1795	#[serde(rename = "github")]
1796	GitHub,
1797	#[serde(rename = "gitlab")]
1798	GitLab,
1799	#[serde(rename = "gitea")]
1800	Gitea,
1801	#[serde(rename = "bitbucket")]
1802	Bitbucket,
1803}
1804
1805impl HostingProviderKind {
1806	/// Return the canonical serialized name for the hosting provider.
1807	#[must_use]
1808	pub fn as_str(self) -> &'static str {
1809		match self {
1810			Self::GenericGit => "generic_git",
1811			Self::GitHub => "github",
1812			Self::GitLab => "gitlab",
1813			Self::Gitea => "gitea",
1814			Self::Bitbucket => "bitbucket",
1815		}
1816	}
1817}
1818
1819impl fmt::Display for HostingProviderKind {
1820	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1821		formatter.write_str(self.as_str())
1822	}
1823}
1824
1825#[allow(clippy::struct_excessive_bools)]
1826#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1827#[serde(rename_all = "camelCase")]
1828pub struct HostingCapabilities {
1829	pub commit_web_urls: bool,
1830	pub actor_profiles: bool,
1831	pub review_request_lookup: bool,
1832	pub related_issues: bool,
1833	pub issue_comments: bool,
1834}
1835
1836#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
1837#[serde(rename_all = "snake_case")]
1838pub enum HostedActorSourceKind {
1839	#[default]
1840	CommitAuthor,
1841	CommitCommitter,
1842	ReviewRequestAuthor,
1843}
1844
1845#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1846#[serde(rename_all = "camelCase")]
1847pub struct HostedActorRef {
1848	pub provider: HostingProviderKind,
1849	#[serde(default)]
1850	pub host: Option<String>,
1851	#[serde(default)]
1852	pub id: Option<String>,
1853	#[serde(default)]
1854	pub login: Option<String>,
1855	#[serde(default)]
1856	pub display_name: Option<String>,
1857	#[serde(default)]
1858	pub url: Option<String>,
1859	pub source: HostedActorSourceKind,
1860}
1861
1862#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1863#[serde(rename_all = "camelCase")]
1864pub struct HostedCommitRef {
1865	pub provider: HostingProviderKind,
1866	#[serde(default)]
1867	pub host: Option<String>,
1868	pub sha: String,
1869	pub short_sha: String,
1870	#[serde(default)]
1871	pub url: Option<String>,
1872	#[serde(default)]
1873	pub authored_at: Option<String>,
1874	#[serde(default)]
1875	pub committed_at: Option<String>,
1876	#[serde(default)]
1877	pub author_name: Option<String>,
1878	#[serde(default)]
1879	pub author_email: Option<String>,
1880}
1881
1882#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
1883#[serde(rename_all = "snake_case")]
1884pub enum HostedReviewRequestKind {
1885	#[default]
1886	PullRequest,
1887	MergeRequest,
1888}
1889
1890impl HostedReviewRequestKind {
1891	/// Return the canonical serialized name for the review-request kind.
1892	#[must_use]
1893	pub fn as_str(self) -> &'static str {
1894		match self {
1895			Self::PullRequest => "pull_request",
1896			Self::MergeRequest => "merge_request",
1897		}
1898	}
1899}
1900
1901impl fmt::Display for HostedReviewRequestKind {
1902	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1903		formatter.write_str(self.as_str())
1904	}
1905}
1906
1907#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1908#[serde(rename_all = "camelCase")]
1909pub struct HostedReviewRequestRef {
1910	pub provider: HostingProviderKind,
1911	#[serde(default)]
1912	pub host: Option<String>,
1913	pub kind: HostedReviewRequestKind,
1914	pub id: String,
1915	#[serde(default)]
1916	pub title: Option<String>,
1917	#[serde(default)]
1918	pub url: Option<String>,
1919	#[serde(default)]
1920	pub author: Option<HostedActorRef>,
1921}
1922
1923#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
1924#[serde(rename_all = "snake_case")]
1925pub enum HostedIssueRelationshipKind {
1926	#[default]
1927	ClosedByReviewRequest,
1928	ReferencedByReviewRequest,
1929	Mentioned,
1930	Manual,
1931}
1932
1933impl HostedIssueRelationshipKind {
1934	/// Return the canonical serialized name for the issue relationship kind.
1935	#[must_use]
1936	pub fn as_str(self) -> &'static str {
1937		match self {
1938			Self::ClosedByReviewRequest => "closed_by_review_request",
1939			Self::ReferencedByReviewRequest => "referenced_by_review_request",
1940			Self::Mentioned => "mentioned",
1941			Self::Manual => "manual",
1942		}
1943	}
1944}
1945
1946impl fmt::Display for HostedIssueRelationshipKind {
1947	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1948		formatter.write_str(self.as_str())
1949	}
1950}
1951
1952#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1953#[serde(rename_all = "camelCase")]
1954pub struct HostedIssueRef {
1955	pub provider: HostingProviderKind,
1956	#[serde(default)]
1957	pub host: Option<String>,
1958	pub id: String,
1959	#[serde(default)]
1960	pub title: Option<String>,
1961	#[serde(default)]
1962	pub url: Option<String>,
1963	pub relationship: HostedIssueRelationshipKind,
1964}
1965
1966#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1967#[serde(rename_all = "camelCase")]
1968pub struct ChangesetRevision {
1969	#[serde(default)]
1970	pub actor: Option<HostedActorRef>,
1971	#[serde(default)]
1972	pub commit: Option<HostedCommitRef>,
1973	#[serde(default)]
1974	pub review_request: Option<HostedReviewRequestRef>,
1975}
1976
1977#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
1978#[serde(rename_all = "camelCase")]
1979pub struct ChangesetContext {
1980	pub provider: HostingProviderKind,
1981	#[serde(default)]
1982	pub host: Option<String>,
1983	#[serde(default)]
1984	pub capabilities: HostingCapabilities,
1985	#[serde(default)]
1986	pub introduced: Option<ChangesetRevision>,
1987	#[serde(default)]
1988	pub last_updated: Option<ChangesetRevision>,
1989	#[serde(default)]
1990	pub related_issues: Vec<HostedIssueRef>,
1991}
1992
1993#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
1994#[serde(rename_all = "snake_case")]
1995pub enum ChangesetTargetKind {
1996	Package,
1997	Group,
1998}
1999
2000impl ChangesetTargetKind {
2001	/// Return the canonical serialized name for the changeset target kind.
2002	#[must_use]
2003	pub fn as_str(self) -> &'static str {
2004		match self {
2005			Self::Package => "package",
2006			Self::Group => "group",
2007		}
2008	}
2009}
2010
2011impl fmt::Display for ChangesetTargetKind {
2012	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2013		formatter.write_str(self.as_str())
2014	}
2015}
2016
2017#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2018#[serde(rename_all = "camelCase")]
2019pub struct PreparedChangesetTarget {
2020	pub id: String,
2021	pub kind: ChangesetTargetKind,
2022	#[serde(default)]
2023	pub bump: Option<BumpSeverity>,
2024	pub origin: String,
2025	#[serde(default)]
2026	pub evidence_refs: Vec<String>,
2027	#[serde(default)]
2028	pub change_type: Option<String>,
2029}
2030
2031#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2032#[serde(rename_all = "camelCase")]
2033pub struct PreparedChangeset {
2034	pub path: PathBuf,
2035	#[serde(default)]
2036	pub summary: Option<String>,
2037	#[serde(default)]
2038	pub details: Option<String>,
2039	pub targets: Vec<PreparedChangesetTarget>,
2040	#[serde(default, alias = "context")]
2041	pub context: Option<ChangesetContext>,
2042}
2043
2044#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2045#[serde(rename_all = "camelCase")]
2046pub struct ReleaseManifestPlanDecision {
2047	pub package: String,
2048	pub bump: BumpSeverity,
2049	pub trigger: String,
2050	pub planned_version: Option<String>,
2051	pub reasons: Vec<String>,
2052	pub upstream_sources: Vec<String>,
2053}
2054
2055#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2056#[serde(rename_all = "camelCase")]
2057pub struct ReleaseManifestPlanGroup {
2058	pub id: String,
2059	pub planned_version: Option<String>,
2060	pub members: Vec<String>,
2061	pub bump: BumpSeverity,
2062}
2063
2064#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2065#[serde(rename_all = "camelCase")]
2066pub struct ReleaseManifestCompatibilityEvidence {
2067	pub package: String,
2068	pub provider: String,
2069	pub severity: BumpSeverity,
2070	pub summary: String,
2071	pub confidence: String,
2072	pub evidence_location: Option<String>,
2073}
2074
2075#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2076#[serde(rename_all = "camelCase")]
2077pub struct ReleaseManifestPlan {
2078	pub workspace_root: PathBuf,
2079	pub decisions: Vec<ReleaseManifestPlanDecision>,
2080	pub groups: Vec<ReleaseManifestPlanGroup>,
2081	pub warnings: Vec<String>,
2082	pub unresolved_items: Vec<String>,
2083	pub compatibility_evidence: Vec<ReleaseManifestCompatibilityEvidence>,
2084}
2085
2086#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2087#[serde(rename_all = "camelCase")]
2088pub struct ReleaseManifest {
2089	pub command: String,
2090	pub dry_run: bool,
2091	#[serde(default)]
2092	pub version: Option<String>,
2093	#[serde(default)]
2094	pub group_version: Option<String>,
2095	pub release_targets: Vec<ReleaseManifestTarget>,
2096	pub released_packages: Vec<String>,
2097	pub changed_files: Vec<PathBuf>,
2098	pub changelogs: Vec<ReleaseManifestChangelog>,
2099	#[serde(default)]
2100	pub changesets: Vec<PreparedChangeset>,
2101	#[serde(default)]
2102	pub deleted_changesets: Vec<PathBuf>,
2103	pub plan: ReleaseManifestPlan,
2104}
2105
2106/// Current supported `ReleaseRecord` schema version.
2107pub const RELEASE_RECORD_SCHEMA_VERSION: u64 = 1;
2108/// Required `ReleaseRecord.kind` discriminator.
2109pub const RELEASE_RECORD_KIND: &str = "monochange.releaseRecord";
2110/// Human-readable heading used for commit-embedded release records.
2111pub const RELEASE_RECORD_HEADING: &str = "## monochange Release Record";
2112/// Opening marker for a commit-embedded release record block.
2113pub const RELEASE_RECORD_START_MARKER: &str = "<!-- monochange:release-record:start -->";
2114/// Closing marker for a commit-embedded release record block.
2115pub const RELEASE_RECORD_END_MARKER: &str = "<!-- monochange:release-record:end -->";
2116
2117const fn release_record_schema_version() -> u64 {
2118	RELEASE_RECORD_SCHEMA_VERSION
2119}
2120
2121fn default_release_record_kind() -> String {
2122	RELEASE_RECORD_KIND.to_string()
2123}
2124
2125fn default_true() -> bool {
2126	true
2127}
2128
2129fn default_pull_request_branch_prefix() -> String {
2130	"monochange/release".to_string()
2131}
2132
2133fn default_pull_request_base() -> String {
2134	"main".to_string()
2135}
2136
2137fn default_pull_request_title() -> String {
2138	"chore(release): prepare release".to_string()
2139}
2140
2141fn default_pull_request_labels() -> Vec<String> {
2142	vec!["release".to_string(), "automated".to_string()]
2143}
2144
2145#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2146#[serde(rename_all = "camelCase")]
2147pub struct ReleaseRecordTarget {
2148	pub id: String,
2149	pub kind: ReleaseOwnerKind,
2150	pub version: String,
2151	pub version_format: VersionFormat,
2152	pub tag: bool,
2153	pub release: bool,
2154	pub tag_name: String,
2155	#[serde(default)]
2156	pub members: Vec<String>,
2157}
2158
2159#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2160#[serde(rename_all = "camelCase")]
2161pub struct ReleaseRecordProvider {
2162	pub kind: SourceProvider,
2163	pub owner: String,
2164	pub repo: String,
2165	#[serde(default)]
2166	pub host: Option<String>,
2167}
2168
2169#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2170#[serde(rename_all = "camelCase")]
2171pub struct ReleaseRecord {
2172	#[serde(default = "release_record_schema_version")]
2173	pub schema_version: u64,
2174	#[serde(default = "default_release_record_kind")]
2175	pub kind: String,
2176	pub created_at: String,
2177	pub command: String,
2178	#[serde(default)]
2179	pub version: Option<String>,
2180	#[serde(default)]
2181	pub group_version: Option<String>,
2182	pub release_targets: Vec<ReleaseRecordTarget>,
2183	pub released_packages: Vec<String>,
2184	pub changed_files: Vec<PathBuf>,
2185	#[serde(default)]
2186	pub updated_changelogs: Vec<PathBuf>,
2187	#[serde(default)]
2188	pub deleted_changesets: Vec<PathBuf>,
2189	#[serde(default)]
2190	pub provider: Option<ReleaseRecordProvider>,
2191}
2192
2193#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2194#[serde(rename_all = "camelCase")]
2195pub struct ReleaseRecordDiscovery {
2196	pub input_ref: String,
2197	pub resolved_commit: String,
2198	pub record_commit: String,
2199	pub distance: usize,
2200	pub record: ReleaseRecord,
2201}
2202
2203#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
2204#[serde(rename_all = "snake_case")]
2205pub enum RetargetOperation {
2206	Planned,
2207	Moved,
2208	AlreadyUpToDate,
2209	Skipped,
2210	Failed,
2211}
2212
2213#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2214#[serde(rename_all = "camelCase")]
2215pub struct RetargetTagResult {
2216	pub tag_name: String,
2217	pub from_commit: String,
2218	pub to_commit: String,
2219	pub operation: RetargetOperation,
2220	#[serde(default)]
2221	pub message: Option<String>,
2222}
2223
2224#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
2225#[serde(rename_all = "snake_case")]
2226pub enum RetargetProviderOperation {
2227	Planned,
2228	Synced,
2229	AlreadyAligned,
2230	Unsupported,
2231	Skipped,
2232	Failed,
2233}
2234
2235#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2236#[serde(rename_all = "camelCase")]
2237pub struct RetargetProviderResult {
2238	pub provider: SourceProvider,
2239	pub tag_name: String,
2240	pub target_commit: String,
2241	pub operation: RetargetProviderOperation,
2242	#[serde(default)]
2243	pub url: Option<String>,
2244	#[serde(default)]
2245	pub message: Option<String>,
2246}
2247
2248#[allow(clippy::struct_excessive_bools)]
2249#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2250#[serde(rename_all = "camelCase")]
2251pub struct RetargetPlan {
2252	pub record_commit: String,
2253	pub target_commit: String,
2254	pub is_descendant: bool,
2255	pub force: bool,
2256	pub git_tag_updates: Vec<RetargetTagResult>,
2257	pub provider_updates: Vec<RetargetProviderResult>,
2258	pub sync_provider: bool,
2259	pub dry_run: bool,
2260}
2261
2262#[allow(clippy::struct_excessive_bools)]
2263#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2264#[serde(rename_all = "camelCase")]
2265pub struct RetargetResult {
2266	pub record_commit: String,
2267	pub target_commit: String,
2268	pub force: bool,
2269	pub git_tag_results: Vec<RetargetTagResult>,
2270	pub provider_results: Vec<RetargetProviderResult>,
2271	pub sync_provider: bool,
2272	pub dry_run: bool,
2273}
2274
2275/// Return all tag names owned by the release record, deduplicated and sorted.
2276#[must_use]
2277pub fn release_record_tag_names(record: &ReleaseRecord) -> Vec<String> {
2278	record
2279		.release_targets
2280		.iter()
2281		.filter(|target| target.tag)
2282		.map(|target| target.tag_name.clone())
2283		.collect::<BTreeSet<_>>()
2284		.into_iter()
2285		.collect()
2286}
2287
2288/// Return tag names that correspond to outward hosted releases.
2289#[must_use]
2290pub fn release_record_release_tag_names(record: &ReleaseRecord) -> Vec<String> {
2291	record
2292		.release_targets
2293		.iter()
2294		.filter(|target| target.release)
2295		.map(|target| target.tag_name.clone())
2296		.collect::<BTreeSet<_>>()
2297		.into_iter()
2298		.collect()
2299}
2300
2301#[derive(Debug, Error)]
2302pub enum ReleaseRecordError {
2303	#[error("no monochange release record block found")]
2304	NotFound,
2305	#[error("found multiple monochange release record blocks")]
2306	MultipleBlocks,
2307	#[error("found a release record start marker without a matching end marker")]
2308	MissingEndMarker,
2309	#[error("found a malformed release record block without a fenced json payload")]
2310	MissingJsonBlock,
2311	#[error("release record is missing required `kind`")]
2312	MissingKind,
2313	#[error("release record is missing required `schemaVersion`")]
2314	MissingSchemaVersion,
2315	#[error("release record uses unsupported kind `{0}`")]
2316	UnsupportedKind(String),
2317	#[error("release record uses unsupported schemaVersion {0}")]
2318	UnsupportedSchemaVersion(u64),
2319	#[error("release record json error: {0}")]
2320	InvalidJson(#[from] serde_json::Error),
2321}
2322
2323/// Result type used by release-record parsing and rendering helpers.
2324pub type ReleaseRecordResult<T> = Result<T, ReleaseRecordError>;
2325
2326/// Render a `ReleaseRecord` into the reserved commit-message block format.
2327#[must_use = "the rendered record result must be checked"]
2328pub fn render_release_record_block(record: &ReleaseRecord) -> ReleaseRecordResult<String> {
2329	if record.kind != RELEASE_RECORD_KIND {
2330		return Err(ReleaseRecordError::UnsupportedKind(record.kind.clone()));
2331	}
2332	if record.schema_version != RELEASE_RECORD_SCHEMA_VERSION {
2333		return Err(ReleaseRecordError::UnsupportedSchemaVersion(
2334			record.schema_version,
2335		));
2336	}
2337	let json = serde_json::to_string_pretty(record)?;
2338	Ok(format!(
2339		"{RELEASE_RECORD_HEADING}\n\n{RELEASE_RECORD_START_MARKER}\n```json\n{json}\n```\n{RELEASE_RECORD_END_MARKER}"
2340	))
2341}
2342
2343/// Parse a `ReleaseRecord` from a full commit message body.
2344#[must_use = "the parsed record result must be checked"]
2345pub fn parse_release_record_block(commit_message: &str) -> ReleaseRecordResult<ReleaseRecord> {
2346	let start_matches = commit_message
2347		.match_indices(RELEASE_RECORD_START_MARKER)
2348		.collect::<Vec<_>>();
2349	if start_matches.is_empty() {
2350		return Err(ReleaseRecordError::NotFound);
2351	}
2352	let end_matches = commit_message
2353		.match_indices(RELEASE_RECORD_END_MARKER)
2354		.collect::<Vec<_>>();
2355	if end_matches.is_empty() {
2356		return Err(ReleaseRecordError::MissingEndMarker);
2357	}
2358	if start_matches.len() > 1 || end_matches.len() > 1 {
2359		return Err(ReleaseRecordError::MultipleBlocks);
2360	}
2361	let (start_index, _) = start_matches
2362		.first()
2363		.copied()
2364		.unwrap_or_else(|| unreachable!("start marker count was validated"));
2365	let (end_index, _) = end_matches
2366		.first()
2367		.copied()
2368		.unwrap_or_else(|| unreachable!("end marker count was validated"));
2369	if end_index <= start_index {
2370		return Err(ReleaseRecordError::MissingEndMarker);
2371	}
2372	let block_contents =
2373		&commit_message[start_index + RELEASE_RECORD_START_MARKER.len()..end_index];
2374	let json_text = extract_release_record_json(block_contents)?;
2375	let raw = serde_json::from_str::<serde_json::Value>(&json_text)?;
2376	let kind = raw
2377		.get("kind")
2378		.and_then(serde_json::Value::as_str)
2379		.ok_or(ReleaseRecordError::MissingKind)?;
2380	if kind != RELEASE_RECORD_KIND {
2381		return Err(ReleaseRecordError::UnsupportedKind(kind.to_string()));
2382	}
2383	let schema_version = raw
2384		.get("schemaVersion")
2385		.and_then(serde_json::Value::as_u64)
2386		.ok_or(ReleaseRecordError::MissingSchemaVersion)?;
2387	if schema_version != RELEASE_RECORD_SCHEMA_VERSION {
2388		return Err(ReleaseRecordError::UnsupportedSchemaVersion(schema_version));
2389	}
2390	serde_json::from_value(raw).map_err(ReleaseRecordError::InvalidJson)
2391}
2392
2393fn extract_release_record_json(block_contents: &str) -> ReleaseRecordResult<String> {
2394	let lines = block_contents.trim().lines().collect::<Vec<_>>();
2395	if lines.first().map(|line| line.trim_end()) != Some("```json") {
2396		return Err(ReleaseRecordError::MissingJsonBlock);
2397	}
2398	let Some(closing_index) = lines
2399		.iter()
2400		.enumerate()
2401		.skip(1)
2402		.find_map(|(index, line)| (line.trim_end() == "```").then_some(index))
2403	else {
2404		return Err(ReleaseRecordError::MissingJsonBlock);
2405	};
2406	if lines
2407		.iter()
2408		.skip(closing_index + 1)
2409		.any(|line| !line.trim().is_empty())
2410	{
2411		return Err(ReleaseRecordError::MissingJsonBlock);
2412	}
2413	let json = lines
2414		.iter()
2415		.skip(1)
2416		.take(closing_index.saturating_sub(1))
2417		.copied()
2418		.collect::<Vec<_>>()
2419		.join("\n");
2420	if json.trim().is_empty() {
2421		return Err(ReleaseRecordError::MissingJsonBlock);
2422	}
2423	Ok(json)
2424}
2425
2426#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
2427#[serde(rename_all = "snake_case")]
2428pub enum ProviderReleaseNotesSource {
2429	#[default]
2430	Monochange,
2431	#[serde(rename = "github_generated")]
2432	GitHubGenerated,
2433}
2434
2435#[allow(clippy::struct_excessive_bools)]
2436#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2437pub struct ProviderReleaseSettings {
2438	#[serde(default = "default_true")]
2439	pub enabled: bool,
2440	#[serde(default)]
2441	pub draft: bool,
2442	#[serde(default)]
2443	pub prerelease: bool,
2444	#[serde(default)]
2445	pub generate_notes: bool,
2446	#[serde(default)]
2447	pub source: ProviderReleaseNotesSource,
2448}
2449
2450impl Default for ProviderReleaseSettings {
2451	fn default() -> Self {
2452		Self {
2453			enabled: true,
2454			draft: false,
2455			prerelease: false,
2456			generate_notes: false,
2457			source: ProviderReleaseNotesSource::default(),
2458		}
2459	}
2460}
2461
2462#[allow(clippy::struct_excessive_bools)]
2463#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2464pub struct ProviderMergeRequestSettings {
2465	#[serde(default = "default_true")]
2466	pub enabled: bool,
2467	#[serde(default = "default_pull_request_branch_prefix")]
2468	pub branch_prefix: String,
2469	#[serde(default = "default_pull_request_base")]
2470	pub base: String,
2471	#[serde(default = "default_pull_request_title")]
2472	pub title: String,
2473	#[serde(default = "default_pull_request_labels")]
2474	pub labels: Vec<String>,
2475	#[serde(default)]
2476	pub auto_merge: bool,
2477}
2478
2479impl Default for ProviderMergeRequestSettings {
2480	fn default() -> Self {
2481		Self {
2482			enabled: true,
2483			branch_prefix: default_pull_request_branch_prefix(),
2484			base: default_pull_request_base(),
2485			title: default_pull_request_title(),
2486			labels: default_pull_request_labels(),
2487			auto_merge: false,
2488		}
2489	}
2490}
2491
2492#[allow(clippy::struct_excessive_bools)]
2493#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2494pub struct ProviderChangesetBotSettings {
2495	#[serde(default)]
2496	pub enabled: bool,
2497	#[serde(default = "default_true")]
2498	pub required: bool,
2499	#[serde(default)]
2500	pub skip_labels: Vec<String>,
2501	#[serde(default = "default_true")]
2502	pub comment_on_failure: bool,
2503	#[serde(default)]
2504	pub changed_paths: Vec<String>,
2505	#[serde(default)]
2506	pub ignored_paths: Vec<String>,
2507}
2508
2509impl Default for ProviderChangesetBotSettings {
2510	fn default() -> Self {
2511		Self {
2512			enabled: false,
2513			required: true,
2514			skip_labels: Vec::new(),
2515			comment_on_failure: true,
2516			changed_paths: Vec::new(),
2517			ignored_paths: Vec::new(),
2518		}
2519	}
2520}
2521
2522#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
2523pub struct ProviderBotSettings {
2524	#[serde(default)]
2525	pub changesets: ProviderChangesetBotSettings,
2526}
2527
2528#[allow(clippy::struct_excessive_bools)]
2529#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2530pub struct ChangesetVerificationSettings {
2531	#[serde(default = "default_true")]
2532	pub enabled: bool,
2533	#[serde(default = "default_true")]
2534	pub required: bool,
2535	#[serde(default)]
2536	pub skip_labels: Vec<String>,
2537	#[serde(default = "default_true")]
2538	pub comment_on_failure: bool,
2539}
2540
2541impl Default for ChangesetVerificationSettings {
2542	fn default() -> Self {
2543		Self {
2544			enabled: true,
2545			required: true,
2546			skip_labels: Vec::new(),
2547			comment_on_failure: true,
2548		}
2549	}
2550}
2551
2552#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
2553pub struct ChangesetSettings {
2554	#[serde(default)]
2555	pub verify: ChangesetVerificationSettings,
2556}
2557
2558#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
2559#[serde(rename_all = "snake_case")]
2560pub enum ChangesetPolicyStatus {
2561	Passed,
2562	Failed,
2563	Skipped,
2564	NotRequired,
2565}
2566
2567impl ChangesetPolicyStatus {
2568	/// Return the canonical serialized name for the policy status.
2569	#[must_use]
2570	pub fn as_str(self) -> &'static str {
2571		match self {
2572			Self::Passed => "passed",
2573			Self::Failed => "failed",
2574			Self::Skipped => "skipped",
2575			Self::NotRequired => "not_required",
2576		}
2577	}
2578}
2579
2580impl fmt::Display for ChangesetPolicyStatus {
2581	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2582		formatter.write_str(self.as_str())
2583	}
2584}
2585
2586#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2587#[serde(rename_all = "camelCase")]
2588pub struct ChangesetPolicyEvaluation {
2589	pub status: ChangesetPolicyStatus,
2590	pub required: bool,
2591	#[serde(default)]
2592	pub enforce: bool,
2593	pub summary: String,
2594	#[serde(default)]
2595	pub comment: Option<String>,
2596	#[serde(default)]
2597	pub labels: Vec<String>,
2598	#[serde(default)]
2599	pub matched_skip_labels: Vec<String>,
2600	#[serde(default)]
2601	pub changed_paths: Vec<String>,
2602	#[serde(default)]
2603	pub matched_paths: Vec<String>,
2604	#[serde(default)]
2605	pub ignored_paths: Vec<String>,
2606	#[serde(default)]
2607	pub changeset_paths: Vec<String>,
2608	#[serde(default)]
2609	pub affected_package_ids: Vec<String>,
2610	#[serde(default)]
2611	pub covered_package_ids: Vec<String>,
2612	#[serde(default)]
2613	pub uncovered_package_ids: Vec<String>,
2614	#[serde(default)]
2615	pub errors: Vec<String>,
2616}
2617
2618#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
2619pub enum SourceProvider {
2620	#[default]
2621	#[serde(rename = "github")]
2622	GitHub,
2623	#[serde(rename = "gitlab")]
2624	GitLab,
2625	#[serde(rename = "gitea")]
2626	Gitea,
2627}
2628
2629impl SourceProvider {
2630	/// Return the canonical serialized name for the source provider.
2631	#[must_use]
2632	pub fn as_str(self) -> &'static str {
2633		match self {
2634			Self::GitHub => "github",
2635			Self::GitLab => "gitlab",
2636			Self::Gitea => "gitea",
2637		}
2638	}
2639}
2640
2641impl fmt::Display for SourceProvider {
2642	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2643		formatter.write_str(self.as_str())
2644	}
2645}
2646
2647#[allow(clippy::struct_excessive_bools)]
2648#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
2649pub struct SourceCapabilities {
2650	pub draft_releases: bool,
2651	pub prereleases: bool,
2652	pub generated_release_notes: bool,
2653	pub auto_merge_change_requests: bool,
2654	pub released_issue_comments: bool,
2655	pub requires_host: bool,
2656}
2657
2658#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2659pub struct SourceConfiguration {
2660	#[serde(default)]
2661	pub provider: SourceProvider,
2662	pub owner: String,
2663	pub repo: String,
2664	#[serde(default)]
2665	pub host: Option<String>,
2666	#[serde(default)]
2667	pub api_url: Option<String>,
2668	#[serde(default)]
2669	pub releases: ProviderReleaseSettings,
2670	#[serde(default)]
2671	pub pull_requests: ProviderMergeRequestSettings,
2672	#[serde(default)]
2673	pub bot: ProviderBotSettings,
2674}
2675
2676#[allow(clippy::struct_excessive_bools)]
2677#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
2678#[serde(rename_all = "camelCase")]
2679pub struct HostedSourceFeatures {
2680	pub batched_changeset_context_lookup: bool,
2681	pub released_issue_comments: bool,
2682	pub release_retarget_sync: bool,
2683}
2684
2685#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2686#[serde(rename_all = "camelCase")]
2687pub struct HostedIssueCommentPlan {
2688	pub repository: String,
2689	pub issue_id: String,
2690	pub issue_url: Option<String>,
2691	pub body: String,
2692}
2693
2694#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
2695#[serde(rename_all = "snake_case")]
2696pub enum HostedIssueCommentOperation {
2697	Created,
2698	SkippedExisting,
2699}
2700
2701#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2702#[serde(rename_all = "camelCase")]
2703pub struct HostedIssueCommentOutcome {
2704	pub repository: String,
2705	pub issue_id: String,
2706	pub operation: HostedIssueCommentOperation,
2707	pub url: Option<String>,
2708}
2709
2710pub trait HostedSourceAdapter: Sync {
2711	fn provider(&self) -> SourceProvider;
2712
2713	fn features(&self) -> HostedSourceFeatures {
2714		HostedSourceFeatures::default()
2715	}
2716
2717	fn annotate_changeset_context(
2718		&self,
2719		source: &SourceConfiguration,
2720		changesets: &mut [PreparedChangeset],
2721	);
2722
2723	fn enrich_changeset_context(
2724		&self,
2725		source: &SourceConfiguration,
2726		changesets: &mut [PreparedChangeset],
2727	) {
2728		self.annotate_changeset_context(source, changesets);
2729	}
2730
2731	fn plan_released_issue_comments(
2732		&self,
2733		_source: &SourceConfiguration,
2734		_manifest: &ReleaseManifest,
2735	) -> Vec<HostedIssueCommentPlan> {
2736		Vec::new()
2737	}
2738
2739	fn comment_released_issues(
2740		&self,
2741		source: &SourceConfiguration,
2742		manifest: &ReleaseManifest,
2743	) -> MonochangeResult<Vec<HostedIssueCommentOutcome>> {
2744		let plans = self.plan_released_issue_comments(source, manifest);
2745		if plans.is_empty() {
2746			return Ok(Vec::new());
2747		}
2748		Err(MonochangeError::Config(format!(
2749			"released issue comments are not yet supported for {}",
2750			self.provider()
2751		)))
2752	}
2753
2754	fn plan_retargeted_releases(
2755		&self,
2756		tag_results: &[RetargetTagResult],
2757	) -> Vec<RetargetProviderResult> {
2758		let provider = self.provider();
2759		let supports_sync = self.features().release_retarget_sync;
2760		tag_results
2761			.iter()
2762			.map(|update| {
2763				RetargetProviderResult {
2764					provider,
2765					tag_name: update.tag_name.clone(),
2766					target_commit: update.to_commit.clone(),
2767					operation: if supports_sync {
2768						RetargetProviderOperation::Planned
2769					} else {
2770						RetargetProviderOperation::Unsupported
2771					},
2772					url: None,
2773					message: (!supports_sync).then_some(format!(
2774						"provider sync is not yet supported for {provider} release retargeting"
2775					)),
2776				}
2777			})
2778			.collect()
2779	}
2780
2781	fn sync_retargeted_releases(
2782		&self,
2783		source: &SourceConfiguration,
2784		tag_results: &[RetargetTagResult],
2785		dry_run: bool,
2786	) -> MonochangeResult<Vec<RetargetProviderResult>> {
2787		if dry_run {
2788			return Ok(self.plan_retargeted_releases(tag_results));
2789		}
2790		Err(MonochangeError::Config(format!(
2791			"provider sync is not yet supported for {} release retargeting",
2792			source.provider
2793		)))
2794	}
2795}
2796
2797#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2798#[serde(rename_all = "camelCase")]
2799pub struct SourceReleaseRequest {
2800	pub provider: SourceProvider,
2801	pub repository: String,
2802	pub owner: String,
2803	pub repo: String,
2804	pub target_id: String,
2805	pub target_kind: ReleaseOwnerKind,
2806	pub tag_name: String,
2807	pub name: String,
2808	pub body: Option<String>,
2809	pub draft: bool,
2810	pub prerelease: bool,
2811	pub generate_release_notes: bool,
2812}
2813
2814#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2815#[serde(rename_all = "snake_case")]
2816pub enum SourceReleaseOperation {
2817	Created,
2818	Updated,
2819}
2820
2821#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2822#[serde(rename_all = "camelCase")]
2823pub struct SourceReleaseOutcome {
2824	pub provider: SourceProvider,
2825	pub repository: String,
2826	pub tag_name: String,
2827	pub operation: SourceReleaseOperation,
2828	pub url: Option<String>,
2829}
2830
2831#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2832#[serde(rename_all = "camelCase")]
2833pub struct CommitMessage {
2834	pub subject: String,
2835	#[serde(default)]
2836	pub body: Option<String>,
2837}
2838
2839#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2840#[serde(rename_all = "camelCase")]
2841pub struct SourceChangeRequest {
2842	pub provider: SourceProvider,
2843	pub repository: String,
2844	pub owner: String,
2845	pub repo: String,
2846	pub base_branch: String,
2847	pub head_branch: String,
2848	pub title: String,
2849	pub body: String,
2850	pub labels: Vec<String>,
2851	pub auto_merge: bool,
2852	pub commit_message: CommitMessage,
2853}
2854
2855#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2856#[serde(rename_all = "snake_case")]
2857pub enum SourceChangeRequestOperation {
2858	Created,
2859	Updated,
2860	Skipped,
2861}
2862
2863#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2864#[serde(rename_all = "camelCase")]
2865pub struct SourceChangeRequestOutcome {
2866	pub provider: SourceProvider,
2867	pub repository: String,
2868	pub number: u64,
2869	pub head_branch: String,
2870	pub operation: SourceChangeRequestOperation,
2871	pub url: Option<String>,
2872}
2873
2874#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2875pub struct EffectiveReleaseIdentity {
2876	pub owner_id: String,
2877	pub owner_kind: ReleaseOwnerKind,
2878	pub group_id: Option<String>,
2879	pub tag: bool,
2880	pub release: bool,
2881	pub version_format: VersionFormat,
2882	pub members: Vec<String>,
2883}
2884
2885#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
2886pub struct WorkspaceConfiguration {
2887	pub root_path: PathBuf,
2888	pub defaults: WorkspaceDefaults,
2889	pub release_notes: ReleaseNotesSettings,
2890	pub packages: Vec<PackageDefinition>,
2891	pub groups: Vec<GroupDefinition>,
2892	pub cli: Vec<CliCommandDefinition>,
2893	pub changesets: ChangesetSettings,
2894	pub source: Option<SourceConfiguration>,
2895	pub cargo: EcosystemSettings,
2896	pub npm: EcosystemSettings,
2897	pub deno: EcosystemSettings,
2898	pub dart: EcosystemSettings,
2899}
2900
2901impl WorkspaceConfiguration {
2902	/// Look up a configured package by its package id.
2903	#[must_use]
2904	pub fn package_by_id(&self, package_id: &str) -> Option<&PackageDefinition> {
2905		self.packages
2906			.iter()
2907			.find(|package| package.id == package_id)
2908	}
2909
2910	/// Look up a configured group by its group id.
2911	#[must_use]
2912	pub fn group_by_id(&self, group_id: &str) -> Option<&GroupDefinition> {
2913		self.groups.iter().find(|group| group.id == group_id)
2914	}
2915
2916	/// Return the configured group that directly owns `package_id`, if any.
2917	#[must_use]
2918	pub fn group_for_package(&self, package_id: &str) -> Option<&GroupDefinition> {
2919		self.groups
2920			.iter()
2921			.find(|group| group.packages.iter().any(|member| member == package_id))
2922	}
2923
2924	/// Resolve the effective outward release identity for a package.
2925	#[must_use]
2926	pub fn effective_release_identity(&self, package_id: &str) -> Option<EffectiveReleaseIdentity> {
2927		let package = self.package_by_id(package_id)?;
2928		if let Some(group) = self.group_for_package(package_id) {
2929			return Some(EffectiveReleaseIdentity {
2930				owner_id: group.id.clone(),
2931				owner_kind: ReleaseOwnerKind::Group,
2932				group_id: Some(group.id.clone()),
2933				tag: group.tag,
2934				release: group.release,
2935				version_format: group.version_format,
2936				members: group.packages.clone(),
2937			});
2938		}
2939
2940		Some(EffectiveReleaseIdentity {
2941			owner_id: package.id.clone(),
2942			owner_kind: ReleaseOwnerKind::Package,
2943			group_id: None,
2944			tag: package.tag,
2945			release: package.release,
2946			version_format: package.version_format,
2947			members: vec![package.id.clone()],
2948		})
2949	}
2950}
2951
2952/// Return the built-in CLI command definitions used when config omits them.
2953#[must_use]
2954pub fn default_cli_commands() -> Vec<CliCommandDefinition> {
2955	vec![
2956		CliCommandDefinition {
2957			name: "validate".to_string(),
2958			help_text: Some("Validate monochange configuration and changesets".to_string()),
2959			inputs: Vec::new(),
2960			steps: vec![CliStepDefinition::Validate {
2961				name: Some("validate workspace".to_string()),
2962				when: None,
2963				inputs: BTreeMap::new(),
2964			}],
2965		},
2966		CliCommandDefinition {
2967			name: "discover".to_string(),
2968			help_text: Some("Discover packages across supported ecosystems".to_string()),
2969			inputs: vec![CliInputDefinition {
2970				name: "format".to_string(),
2971				kind: CliInputKind::Choice,
2972				help_text: Some("Output format".to_string()),
2973				required: false,
2974				default: Some("text".to_string()),
2975				choices: vec!["text".to_string(), "json".to_string()],
2976				short: None,
2977			}],
2978			steps: vec![CliStepDefinition::Discover {
2979				name: Some("discover packages".to_string()),
2980				when: None,
2981				inputs: BTreeMap::new(),
2982			}],
2983		},
2984		CliCommandDefinition {
2985			name: "change".to_string(),
2986			help_text: Some("Create a change file for one or more packages".to_string()),
2987			inputs: vec![
2988				CliInputDefinition {
2989					name: "interactive".to_string(),
2990					kind: CliInputKind::Boolean,
2991					help_text: Some(
2992						"Select packages, bumps, and options interactively".to_string(),
2993					),
2994					required: false,
2995					default: None,
2996					choices: Vec::new(),
2997					short: Some('i'),
2998				},
2999				CliInputDefinition {
3000					name: "package".to_string(),
3001					kind: CliInputKind::StringList,
3002					help_text: Some("Package or group to include in the change".to_string()),
3003					required: false,
3004					default: None,
3005					choices: Vec::new(),
3006					short: None,
3007				},
3008				CliInputDefinition {
3009					name: "bump".to_string(),
3010					kind: CliInputKind::Choice,
3011					help_text: Some("Requested semantic version bump".to_string()),
3012					required: false,
3013					default: Some("patch".to_string()),
3014					choices: vec![
3015						"none".to_string(),
3016						"patch".to_string(),
3017						"minor".to_string(),
3018						"major".to_string(),
3019					],
3020					short: None,
3021				},
3022				CliInputDefinition {
3023					name: "version".to_string(),
3024					kind: CliInputKind::String,
3025					help_text: Some("Pin an explicit version for this release".to_string()),
3026					required: false,
3027					default: None,
3028					choices: Vec::new(),
3029					short: None,
3030				},
3031				CliInputDefinition {
3032					name: "reason".to_string(),
3033					kind: CliInputKind::String,
3034					help_text: Some("Short release-note summary for this change".to_string()),
3035					required: false,
3036					default: None,
3037					choices: Vec::new(),
3038					short: None,
3039				},
3040				CliInputDefinition {
3041					name: "type".to_string(),
3042					kind: CliInputKind::String,
3043					help_text: Some(
3044						"Optional release-note type such as `security` or `note`".to_string(),
3045					),
3046					required: false,
3047					default: None,
3048					choices: Vec::new(),
3049					short: None,
3050				},
3051				CliInputDefinition {
3052					name: "details".to_string(),
3053					kind: CliInputKind::String,
3054					help_text: Some("Optional multi-line release-note details".to_string()),
3055					required: false,
3056					default: None,
3057					choices: Vec::new(),
3058					short: None,
3059				},
3060				CliInputDefinition {
3061					name: "output".to_string(),
3062					kind: CliInputKind::Path,
3063					help_text: Some(
3064						"Write the generated change file to a specific path".to_string(),
3065					),
3066					required: false,
3067					default: None,
3068					choices: Vec::new(),
3069					short: None,
3070				},
3071			],
3072			steps: vec![CliStepDefinition::CreateChangeFile {
3073				show_progress: None,
3074				name: Some("create change file".to_string()),
3075				when: None,
3076				inputs: BTreeMap::new(),
3077			}],
3078		},
3079		CliCommandDefinition {
3080			name: "release".to_string(),
3081			help_text: Some("Prepare a release from discovered change files".to_string()),
3082			inputs: vec![CliInputDefinition {
3083				name: "format".to_string(),
3084				kind: CliInputKind::Choice,
3085				help_text: Some("Output format".to_string()),
3086				required: false,
3087				default: Some("markdown".to_string()),
3088				choices: vec![
3089					"markdown".to_string(),
3090					"text".to_string(),
3091					"json".to_string(),
3092				],
3093				short: None,
3094			}],
3095			steps: vec![CliStepDefinition::PrepareRelease {
3096				name: Some("prepare release".to_string()),
3097				when: None,
3098				inputs: BTreeMap::new(),
3099			}],
3100		},
3101		CliCommandDefinition {
3102			name: "affected".to_string(),
3103			help_text: Some(
3104				"Show packages affected by file changes and their changeset coverage".to_string(),
3105			),
3106			inputs: vec![
3107				CliInputDefinition {
3108					name: "format".to_string(),
3109					kind: CliInputKind::Choice,
3110					help_text: Some("Output format".to_string()),
3111					required: false,
3112					default: Some("text".to_string()),
3113					choices: vec!["text".to_string(), "json".to_string()],
3114					short: None,
3115				},
3116				CliInputDefinition {
3117					name: "changed_paths".to_string(),
3118					kind: CliInputKind::StringList,
3119					help_text: Some(
3120						"Explicit changed paths (mutually exclusive with --since)".to_string(),
3121					),
3122					required: false,
3123					default: None,
3124					choices: Vec::new(),
3125					short: None,
3126				},
3127				CliInputDefinition {
3128					name: "since".to_string(),
3129					kind: CliInputKind::String,
3130					help_text: Some(
3131						"Git revision to compare against (branch, tag, commit, or HEAD)"
3132							.to_string(),
3133					),
3134					required: false,
3135					default: None,
3136					choices: Vec::new(),
3137					short: None,
3138				},
3139				CliInputDefinition {
3140					name: "verify".to_string(),
3141					kind: CliInputKind::Boolean,
3142					help_text: Some(
3143						"Enforce that affected packages are covered by changesets (exit non-zero if not)"
3144							.to_string(),
3145					),
3146					required: false,
3147					default: None,
3148					choices: Vec::new(),
3149					short: None,
3150				},
3151				CliInputDefinition {
3152					name: "label".to_string(),
3153					kind: CliInputKind::StringList,
3154					help_text: Some("Labels that may skip verification".to_string()),
3155					required: false,
3156					default: None,
3157					choices: Vec::new(),
3158					short: None,
3159				},
3160			],
3161			steps: vec![CliStepDefinition::AffectedPackages {
3162				name: Some("evaluate affected packages".to_string()),
3163				when: None,
3164				inputs: BTreeMap::new(),
3165			}],
3166		},
3167		CliCommandDefinition {
3168			name: "diagnostics".to_string(),
3169			help_text: Some(
3170				"Show per-changeset diagnostics including context and commit/PR context"
3171					.to_string(),
3172			),
3173			inputs: vec![
3174				CliInputDefinition {
3175					name: "format".to_string(),
3176					kind: CliInputKind::Choice,
3177					help_text: Some("Output format".to_string()),
3178					required: false,
3179					default: Some("text".to_string()),
3180					choices: vec!["text".to_string(), "json".to_string()],
3181					short: None,
3182				},
3183				CliInputDefinition {
3184					name: "changeset".to_string(),
3185					kind: CliInputKind::StringList,
3186					help_text: Some(
3187						"Changeset path(s) to inspect, relative to .changeset (omit for all changesets)".to_string(),
3188					),
3189					required: false,
3190					default: None,
3191					choices: Vec::new(),
3192					short: None,
3193				},
3194			],
3195			steps: vec![CliStepDefinition::DiagnoseChangesets {
3196				name: Some("diagnose changesets".to_string()),
3197				when: None,
3198				inputs: BTreeMap::new(),
3199			}],
3200		},
3201		CliCommandDefinition {
3202			name: "repair-release".to_string(),
3203			help_text: Some(
3204				"Repair a recent release by moving its release tags to a later commit".to_string(),
3205			),
3206			inputs: vec![
3207				CliInputDefinition {
3208					name: "from".to_string(),
3209					kind: CliInputKind::String,
3210					help_text: Some(
3211						"Tag or commit-ish used to locate the release record".to_string(),
3212					),
3213					required: true,
3214					default: None,
3215					choices: Vec::new(),
3216					short: None,
3217				},
3218				CliInputDefinition {
3219					name: "target".to_string(),
3220					kind: CliInputKind::String,
3221					help_text: Some("Commit-ish the release set should move to".to_string()),
3222					required: false,
3223					default: Some("HEAD".to_string()),
3224					choices: Vec::new(),
3225					short: None,
3226				},
3227				CliInputDefinition {
3228					name: "force".to_string(),
3229					kind: CliInputKind::Boolean,
3230					help_text: Some("Allow non-descendant retargets".to_string()),
3231					required: false,
3232					default: Some("false".to_string()),
3233					choices: Vec::new(),
3234					short: None,
3235				},
3236				CliInputDefinition {
3237					name: "sync_provider".to_string(),
3238					kind: CliInputKind::Boolean,
3239					help_text: Some("Sync hosted release state after tag movement".to_string()),
3240					required: false,
3241					default: Some("true".to_string()),
3242					choices: Vec::new(),
3243					short: None,
3244				},
3245				CliInputDefinition {
3246					name: "format".to_string(),
3247					kind: CliInputKind::Choice,
3248					help_text: Some("Output format".to_string()),
3249					required: false,
3250					default: Some("text".to_string()),
3251					choices: vec!["text".to_string(), "json".to_string()],
3252					short: None,
3253				},
3254			],
3255			steps: vec![CliStepDefinition::RetargetRelease {
3256				name: Some("retarget release".to_string()),
3257				when: None,
3258				inputs: BTreeMap::new(),
3259			}],
3260		},
3261	]
3262}
3263
3264#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3265pub struct VersionGroup {
3266	pub group_id: String,
3267	pub display_name: String,
3268	pub members: Vec<String>,
3269	pub mismatch_detected: bool,
3270}
3271
3272#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3273pub struct PlannedVersionGroup {
3274	pub group_id: String,
3275	pub display_name: String,
3276	pub members: Vec<String>,
3277	pub mismatch_detected: bool,
3278	pub planned_version: Option<Version>,
3279	pub recommended_bump: BumpSeverity,
3280}
3281
3282#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3283pub struct ChangeSignal {
3284	pub package_id: String,
3285	pub requested_bump: Option<BumpSeverity>,
3286	pub explicit_version: Option<Version>,
3287	pub change_origin: String,
3288	pub evidence_refs: Vec<String>,
3289	pub notes: Option<String>,
3290	pub details: Option<String>,
3291	pub change_type: Option<String>,
3292	pub source_path: PathBuf,
3293}
3294
3295#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3296pub struct CompatibilityAssessment {
3297	pub package_id: String,
3298	pub provider_id: String,
3299	pub severity: BumpSeverity,
3300	pub confidence: String,
3301	pub summary: String,
3302	pub evidence_location: Option<String>,
3303}
3304
3305#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3306pub struct ReleaseDecision {
3307	pub package_id: String,
3308	pub trigger_type: String,
3309	pub recommended_bump: BumpSeverity,
3310	pub planned_version: Option<Version>,
3311	pub group_id: Option<String>,
3312	pub reasons: Vec<String>,
3313	pub upstream_sources: Vec<String>,
3314	pub warnings: Vec<String>,
3315}
3316
3317#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3318pub struct ReleasePlan {
3319	pub workspace_root: PathBuf,
3320	pub decisions: Vec<ReleaseDecision>,
3321	pub groups: Vec<PlannedVersionGroup>,
3322	pub warnings: Vec<String>,
3323	pub unresolved_items: Vec<String>,
3324	pub compatibility_evidence: Vec<CompatibilityAssessment>,
3325}
3326
3327#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
3328pub struct DiscoveryReport {
3329	pub workspace_root: PathBuf,
3330	pub packages: Vec<PackageRecord>,
3331	pub dependencies: Vec<DependencyEdge>,
3332	pub version_groups: Vec<VersionGroup>,
3333	pub warnings: Vec<String>,
3334}
3335
3336#[derive(Debug, Clone, Eq, PartialEq)]
3337pub struct AdapterDiscovery {
3338	pub packages: Vec<PackageRecord>,
3339	pub warnings: Vec<String>,
3340}
3341
3342pub trait EcosystemAdapter {
3343	fn ecosystem(&self) -> Ecosystem;
3344
3345	fn discover(&self, root: &Path) -> MonochangeResult<AdapterDiscovery>;
3346}
3347
3348/// Build dependency edges by matching declared dependency names to known packages.
3349#[must_use]
3350pub fn materialize_dependency_edges(packages: &[PackageRecord]) -> Vec<DependencyEdge> {
3351	let mut package_ids_by_name = BTreeMap::<String, Vec<String>>::new();
3352	for package in packages {
3353		package_ids_by_name
3354			.entry(package.name.clone())
3355			.or_default()
3356			.push(package.id.clone());
3357	}
3358
3359	let mut edges = Vec::new();
3360	for package in packages {
3361		for dependency in &package.declared_dependencies {
3362			if let Some(target_package_ids) = package_ids_by_name.get(&dependency.name) {
3363				for target_package_id in target_package_ids {
3364					edges.push(DependencyEdge {
3365						from_package_id: package.id.clone(),
3366						to_package_id: target_package_id.clone(),
3367						dependency_kind: dependency.kind,
3368						source_kind: DependencySourceKind::Manifest,
3369						version_constraint: dependency.version_constraint.clone(),
3370						is_optional: dependency.optional,
3371						is_direct: true,
3372					});
3373				}
3374			}
3375		}
3376	}
3377
3378	edges
3379}
3380
3381#[cfg(test)]
3382mod __tests;