1#![forbid(clippy::indexing_slicing)]
2
3use 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
74pub const DEFAULT_RELEASE_TITLE_PRIMARY: &str = "{{ version }} ({{ date }})";
76pub const DEFAULT_RELEASE_TITLE_NAMESPACED: &str = "{{ id }} {{ version }} ({{ date }})";
78pub const DEFAULT_CHANGELOG_VERSION_TITLE_PRIMARY: &str =
80 "{% if tag_url %}[{{ version }}]({{ tag_url }}){% else %}{{ version }}{% endif %} ({{ date }})";
81pub 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 #[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 #[must_use]
154 pub fn is_release(self) -> bool {
155 self != Self::None
156 }
157
158 #[must_use]
163 pub fn is_pre_stable(version: &Version) -> bool {
164 version.major == 0
165 }
166
167 #[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 #[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 #[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 #[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#[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#[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 #[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 #[must_use]
402 pub fn allows(&self, path: &Path) -> bool {
403 !self.is_ignored(path, path.is_dir())
404 }
405
406 #[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 #[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 #[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 #[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
551pub 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#[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 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 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 #[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 #[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#[derive(Debug, Clone, Eq, PartialEq, Default)]
1239pub enum ShellConfig {
1240 #[default]
1241 None,
1242 Default,
1243 Custom(String),
1244}
1245
1246impl ShellConfig {
1247 #[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#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
1318#[serde(tag = "type", deny_unknown_fields)]
1319#[non_exhaustive]
1320pub enum CliStepDefinition {
1321 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 {
1333 #[serde(default)]
1334 name: Option<String>,
1335 #[serde(default)]
1336 when: Option<String>,
1337 #[serde(default)]
1338 inputs: BTreeMap<String, CliStepInputValue>,
1339 },
1340 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[must_use]
1506 pub fn display_name(&self) -> &str {
1507 self.name().unwrap_or(self.kind_name())
1508 }
1509
1510 #[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 #[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 #[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 #[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 #[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#[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, §ion.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, §ion.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 #[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 #[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 #[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 #[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 #[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
2106pub const RELEASE_RECORD_SCHEMA_VERSION: u64 = 1;
2108pub const RELEASE_RECORD_KIND: &str = "monochange.releaseRecord";
2110pub const RELEASE_RECORD_HEADING: &str = "## monochange Release Record";
2112pub const RELEASE_RECORD_START_MARKER: &str = "<!-- monochange:release-record:start -->";
2114pub 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#[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#[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
2323pub type ReleaseRecordResult<T> = Result<T, ReleaseRecordError>;
2325
2326#[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#[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 #[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 #[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 #[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 #[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 #[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 #[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#[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#[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;