1use std::collections::BTreeMap;
4use std::str::FromStr;
5
6use ephemeris_documents::ManagedPath;
7use ephemeris_expr::Expr;
8use ephemeris_files::{RepositoryPath, RepositoryRoot};
9use facet::Facet;
10
11use crate::account::Account;
12use crate::adapters::{Adapters, LocalPlan, ResolveError};
13use crate::commit_subject::CommitSubject;
14use crate::digest::DigestAlgorithm;
15use crate::error::Error;
16use crate::unit::Unit;
17use crate::version::Version;
18
19#[derive(Clone, Debug, Eq, Facet, PartialEq)]
21#[facet(deny_unknown_fields)]
22pub struct ReleaseState {
23 pub(crate) cargo: Option<CargoReleaseState>,
25 pub(crate) npm: Option<NpmReleaseState>,
27 pub(crate) authoritative_branch: BranchName,
29 pub(crate) proposal_branch: BranchName,
31 pub(crate) commit_subject: CommitSubject,
33 pub(crate) commit_format: Option<CommitFormat>,
36 pub(crate) units: BTreeMap<String, ReleaseUnitState>,
38 pub(crate) artifacts: BTreeMap<String, BTreeMap<String, ReleaseArtifactState>>,
40 pub(crate) account: Account<Expr>,
42 pub(crate) packages_credential: String,
45}
46
47#[derive(Clone, Debug, Eq, Facet, PartialEq)]
49#[facet(proxy = String)]
50pub(crate) struct BranchName(String);
51
52#[derive(Clone, Debug, Eq, Facet, PartialEq)]
54#[facet(proxy = Vec<RepositoryPath>)]
55pub(crate) struct SourceDirectories(Vec<RepositoryPath>);
56
57#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
59#[error("a release unit must list at least one source directory")]
60pub(crate) struct EmptySourceDirectories;
61
62#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct AuthorityBranch(BranchName);
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
69#[error("a release branch name must not be empty")]
70pub struct EmptyBranchName;
71
72#[derive(Debug, thiserror::Error)]
74pub(crate) enum BranchError {
75 #[error(
77 "requested proposal branch {requested:?} conflicts with evaluated release branch \
78 {configured:?}"
79 )]
80 Proposal {
81 requested: String,
83 configured: String,
85 },
86}
87
88#[derive(Clone, Debug, Eq, Facet, PartialEq)]
90#[facet(tag = "ecosystem", deny_unknown_fields)]
91#[repr(u8)]
92pub(crate) enum ReleaseTarget {
93 #[facet(rename = "cargo")]
95 Cargo {
96 manifest: RepositoryPath,
98 publish: bool,
100 },
101 #[facet(rename = "npm")]
103 Npm {
104 manifest: RepositoryPath,
106 publish: bool,
108 },
109 #[facet(rename = "version-file")]
111 VersionFile {
112 version_file: RepositoryPath,
114 },
115 #[facet(rename = "document")]
117 Document {
118 path: RepositoryPath,
120 key: ManagedPath,
122 },
123}
124
125#[derive(Clone, Debug, Eq, Facet, PartialEq)]
127#[facet(deny_unknown_fields)]
128pub(crate) struct Pin {
129 pub path: RepositoryPath,
131 pub key: ManagedPath,
133 pub digests: BTreeMap<String, DigestPin>,
135}
136
137#[derive(Clone, Debug, Eq, Facet, PartialEq)]
139#[facet(deny_unknown_fields)]
140pub(crate) struct DigestPin {
141 pub path: RepositoryPath,
143 pub key: ManagedPath,
145 pub algorithm: DigestAlgorithm,
147 pub package: String,
149 pub output: String,
151}
152
153#[derive(Clone, Debug, Default, Eq, Facet, PartialEq)]
155#[facet(deny_unknown_fields)]
156pub(crate) struct CargoReleaseState {
157 pub(crate) registry: Option<String>,
159 pub(crate) registry_api_url: Option<String>,
161}
162
163#[derive(Clone, Debug, Eq, Facet, PartialEq)]
165#[facet(deny_unknown_fields)]
166pub(crate) struct NpmReleaseState {
167 pub(crate) registry: String,
169}
170
171#[derive(Clone, Debug, Eq, Facet, PartialEq)]
173#[facet(deny_unknown_fields)]
174pub(crate) struct ReleaseUnitState {
175 pub(crate) target: ReleaseTarget,
177 pub(crate) source: SourceDirectories,
179 pub(crate) changelog: Option<RepositoryPath>,
181 pub(crate) dependencies: Vec<String>,
183 pub(crate) pins: BTreeMap<String, Pin>,
185 pub(crate) tag_prefix: String,
187 pub(crate) kind: UnitKind,
189 pub(crate) application_bump: ApplicationBump,
191 pub(crate) revision_file: Option<RepositoryPath>,
193}
194
195#[derive(Clone, Copy, Debug, Eq, Facet, PartialEq)]
197#[facet(rename_all = "kebab-case")]
198#[repr(u8)]
199pub(crate) enum UnitKind {
200 Application,
202 Library,
204}
205
206#[derive(Clone, Copy, Debug, Eq, Facet, PartialEq)]
208#[facet(rename_all = "kebab-case")]
209#[repr(u8)]
210pub(crate) enum ApplicationBump {
211 Patch,
213 None,
215}
216
217#[derive(Clone, Copy, Debug, Eq, Facet, PartialEq)]
219#[facet(rename_all = "kebab-case")]
220#[repr(u8)]
221pub(crate) enum CommitFormat {
222 Conventional,
224}
225
226#[derive(Clone, Debug, Eq, Facet, PartialEq)]
229#[facet(deny_unknown_fields)]
230pub(crate) struct AssetSpec {
231 pub(crate) package: String,
233 pub(crate) output: String,
235 pub(crate) filename: String,
237 pub(crate) format: String,
239 pub(crate) variant: String,
241 pub(crate) required_names: Vec<String>,
243}
244
245#[derive(Clone, Debug, Eq, Facet, PartialEq)]
247#[facet(deny_unknown_fields)]
248pub(crate) struct OciSpec {
249 pub(crate) package: String,
251 pub(crate) registry: String,
253 pub(crate) repository: String,
255 pub(crate) latest: bool,
257}
258
259#[derive(Clone, Debug, Eq, Facet, PartialEq)]
260#[facet(tag = "kind", deny_unknown_fields)]
261#[repr(u8)]
262pub(crate) enum ReleaseArtifactState {
263 #[facet(rename = "asset")]
265 Asset(AssetSpec),
266 #[facet(rename = "oci")]
268 Oci(OciSpec),
269 #[facet(rename = "cargo")]
271 Cargo {
272 package: String,
274 },
275 #[facet(rename = "npm")]
277 Npm {
278 package: String,
280 },
281}
282
283impl BranchName {
284 pub(crate) fn as_str(&self) -> &str {
286 &self.0
287 }
288}
289
290impl SourceDirectories {
291 pub(crate) fn as_slice(&self) -> &[RepositoryPath] {
293 &self.0
294 }
295}
296
297impl TryFrom<Vec<RepositoryPath>> for SourceDirectories {
298 type Error = EmptySourceDirectories;
299
300 fn try_from(paths: Vec<RepositoryPath>) -> Result<Self, Self::Error> {
301 if paths.is_empty() {
302 Err(EmptySourceDirectories)
303 } else {
304 Ok(Self(paths))
305 }
306 }
307}
308
309impl From<&SourceDirectories> for Vec<RepositoryPath> {
310 fn from(directories: &SourceDirectories) -> Self {
311 directories.0.clone()
312 }
313}
314
315impl TryFrom<String> for BranchName {
316 type Error = EmptyBranchName;
317
318 fn try_from(name: String) -> Result<Self, Self::Error> {
319 if name.is_empty() {
320 Err(EmptyBranchName)
321 } else {
322 Ok(Self(name))
323 }
324 }
325}
326
327impl TryFrom<&str> for BranchName {
328 type Error = EmptyBranchName;
329
330 fn try_from(name: &str) -> Result<Self, Self::Error> {
331 Self::try_from(name.to_owned())
332 }
333}
334
335impl From<&BranchName> for String {
336 fn from(name: &BranchName) -> Self {
337 name.0.clone()
338 }
339}
340
341impl AuthorityBranch {
342 #[must_use]
344 pub fn name(&self) -> &str {
345 self.0.as_str()
346 }
347
348 #[must_use]
350 pub fn tracking(&self) -> String {
351 format!("origin/{}", self.name())
352 }
353
354 pub(crate) fn refspec(&self) -> String {
356 format!("+refs/heads/{0}:refs/remotes/origin/{0}", self.name())
357 }
358}
359
360impl FromStr for AuthorityBranch {
362 type Err = EmptyBranchName;
363
364 fn from_str(name: &str) -> Result<Self, Self::Err> {
365 BranchName::try_from(name).map(Self)
366 }
367}
368
369impl ReleaseState {
370 #[must_use]
372 pub const fn commit_subject(&self) -> &CommitSubject {
373 &self.commit_subject
374 }
375
376 #[must_use]
378 pub fn cargo_registry(&self) -> Option<&str> {
379 self.cargo.as_ref()?.registry.as_deref()
380 }
381
382 #[must_use]
384 pub fn cargo_registry_api_url(&self) -> Option<&str> {
385 self.cargo.as_ref()?.registry_api_url.as_deref()
386 }
387
388 #[must_use]
390 pub fn npm_registry(&self) -> Option<&str> {
391 self.npm.as_ref().map(|npm| npm.registry.as_str())
392 }
393
394 #[must_use]
396 pub const fn account(&self) -> &Account<Expr> {
397 &self.account
398 }
399
400 #[must_use]
402 pub fn packages_credential(&self) -> &str {
403 &self.packages_credential
404 }
405
406 pub(crate) fn discover_units(&self, root: &RepositoryRoot) -> Result<Vec<Unit>, ResolveError> {
411 Adapters::without_registry().resolve(root, &self.units)
412 }
413
414 pub fn plan_local(
421 &self,
422 root: &RepositoryRoot,
423 release_id: &str,
424 target_version: &str,
425 date: &str,
426 ) -> Result<LocalPlan, Error> {
427 let target =
428 Version::parse(target_version).map_err(|source| ResolveError::InvalidVersion {
429 unit: release_id.to_owned(),
430 version: target_version.to_owned(),
431 source,
432 })?;
433 let adapters = Adapters::without_registry();
434 let units = adapters.resolve(root, &self.units)?;
435 let unit = units
436 .iter()
437 .find(|candidate| candidate.name == release_id)
438 .ok_or_else(|| ResolveError::UnknownUnit(release_id.to_owned()))?;
439 Ok(adapters.local_plan(root, &units, unit, &target, date)?)
440 }
441
442 pub fn branches(
449 &self,
450 requested_proposal: Option<&str>,
451 ) -> Result<(AuthorityBranch, String), Error> {
452 let configured_proposal = self.proposal_branch.as_str();
453 match requested_proposal {
454 Some(requested) if requested != configured_proposal => Err(BranchError::Proposal {
455 requested: requested.to_owned(),
456 configured: configured_proposal.to_owned(),
457 }
458 .into()),
459 Some(_) | None => Ok((
460 AuthorityBranch(self.authoritative_branch.clone()),
461 configured_proposal.to_owned(),
462 )),
463 }
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use std::fs;
470 use std::path::Path;
471
472 use ephemeris_expr::Expr;
473 use ephemeris_files::RepositoryRoot;
474
475 use super::{
476 ApplicationBump, BranchName, CommitFormat, ReleaseState, ReleaseTarget, ReleaseUnitState,
477 UnitKind,
478 };
479 use crate::LocalChange;
480 use crate::adapters::ResolveError;
481 use crate::digest::DigestAlgorithm;
482
483 const ACCOUNT: &str =
484 r#","account":{"login":null,"name":{"literal":"n"},"email":{"literal":"e"}}"#;
485
486 fn decode(kind: &str) -> Result<ReleaseUnitState, facet_json::DeserializeError> {
487 facet_json::from_str(&format!(
488 r#"{{"target":{{"ecosystem":"version-file","version_file":"VERSION"}},"source":["."],"changelog":"CHANGELOG.md","dependencies":[],"pins":{{}},"tag_prefix":"v","revision_file":null{kind}}}"#
489 ))
490 }
491
492 fn json(edits: &[(&str, &str)]) -> String {
494 let base = format!(
495 r#"{{"cargo":null,"npm":null,"authoritative_branch":"main","proposal_branch":"ephemeris/release","commit_subject":"release: {{unit}} {{version}}","commit_format":null,"units":{{}},"artifacts":{{}}{ACCOUNT},"packages_credential":"forge"}}"#
496 );
497 edits
498 .iter()
499 .fold(base, |json, (from, to)| json.replace(from, to))
500 }
501
502 fn state(authoritative_branch: &str) -> ReleaseState {
503 let branch = format!(r#""authoritative_branch":"{authoritative_branch}""#);
504 facet_json::from_str(&json(&[(r#""authoritative_branch":"main""#, &branch)])).unwrap()
505 }
506
507 #[test]
508 fn account_login_is_optional_and_the_account_is_required() {
509 let json = |account: &str| json(&[(ACCOUNT, account)]);
510 let absent: ReleaseState = facet_json::from_str(&json(
511 r#","account":{"name":{"literal":"n"},"email":{"environment":"E"}}"#,
512 ))
513 .unwrap();
514 assert_eq!(absent.account().login, None);
515 assert_eq!(absent.account().name, Expr::Literal("n".to_owned()));
516 let given: ReleaseState = facet_json::from_str(&json(
517 r#","account":{"login":{"secret":"L"},"name":{"literal":"n"},"email":{"literal":"e"}}"#,
518 ))
519 .unwrap();
520 assert_eq!(given.account().login, Some(Expr::Secret("L".to_owned())));
521 assert!(facet_json::from_str::<ReleaseState>(&json("")).is_err());
522 }
523
524 #[test]
525 fn the_packages_credential_is_required() {
526 assert_eq!(state("main").packages_credential(), "forge");
527 let custom: ReleaseState = facet_json::from_str(&json(&[(
528 r#""packages_credential":"forge""#,
529 r#""packages_credential":"packages""#,
530 )]))
531 .unwrap();
532 assert_eq!(custom.packages_credential(), "packages");
533 assert!(
534 facet_json::from_str::<ReleaseState>(&json(&[(
535 r#","packages_credential":"forge""#,
536 ""
537 )]))
538 .is_err()
539 );
540 }
541
542 #[test]
543 fn a_unit_changelog_decodes_as_a_path_or_null() {
544 let kind = r#","kind":"library","application_bump":"none""#;
545 let path = decode(kind).unwrap();
546 assert_eq!(path.changelog, Some("CHANGELOG.md".parse().unwrap()));
547 let json = format!(
548 r#"{{"target":{{"ecosystem":"version-file","version_file":"VERSION"}},"source":["."],"changelog":null,"dependencies":[],"pins":{{}},"tag_prefix":"v","revision_file":null{kind}}}"#
549 );
550 let null: ReleaseUnitState = facet_json::from_str(&json).unwrap();
551 assert_eq!(null.changelog, None);
552 }
553
554 #[test]
555 fn commit_format_decodes_conventional_or_null() {
556 let input = |format: &str| {
557 json(&[(
558 r#""commit_format":null"#,
559 &format!(r#""commit_format":{format}"#),
560 )])
561 };
562 let decode = |format| facet_json::from_str::<ReleaseState>(&input(format));
563 assert_eq!(
564 decode(r#""conventional""#).unwrap().commit_format,
565 Some(CommitFormat::Conventional)
566 );
567 assert_eq!(state("main").commit_format, None);
568 assert!(decode(r#""scoped""#).is_err());
569 }
570
571 #[test]
572 fn unit_kind_decodes_the_evaluated_names() {
573 let bump = r#","application_bump":"patch""#;
574 assert_eq!(
575 decode(&format!(r#","kind":"application"{bump}"#))
576 .unwrap()
577 .kind,
578 UnitKind::Application
579 );
580 assert_eq!(
581 decode(&format!(r#","kind":"library"{bump}"#)).unwrap().kind,
582 UnitKind::Library
583 );
584 assert!(decode(bump).is_err());
585 assert!(decode(&format!(r#","kind":"service"{bump}"#)).is_err());
586 }
587
588 #[test]
589 fn application_bump_decodes_the_evaluated_names() {
590 let kind = r#","kind":"application""#;
591 assert_eq!(
592 decode(&format!(r#"{kind},"application_bump":"none""#))
593 .unwrap()
594 .application_bump,
595 ApplicationBump::None
596 );
597 assert_eq!(
598 decode(&format!(r#"{kind},"application_bump":"patch""#))
599 .unwrap()
600 .application_bump,
601 ApplicationBump::Patch
602 );
603 assert!(decode(kind).is_err());
604 assert!(decode(&format!(r#"{kind},"application_bump":"minor""#)).is_err());
605 }
606
607 #[test]
608 fn document_target_decodes_its_key_path_and_rejects_an_empty_one() {
609 let input = |key: &str| {
610 format!(
611 r#"{{"target":{{"ecosystem":"document","path":"Chart.yaml","key":{key}}},"source":["."],"changelog":"CHANGELOG.md","dependencies":[],"pins":{{}},"tag_prefix":"v","revision_file":null,"kind":"application","application_bump":"patch"}}"#
612 )
613 };
614 let unit: ReleaseUnitState = facet_json::from_str(&input(r#"["version"]"#)).unwrap();
615 let ReleaseTarget::Document { path: _, key } = unit.target else {
616 panic!("not a document target");
617 };
618 assert_eq!(key.segments(), ["version"]);
619 assert!(facet_json::from_str::<ReleaseUnitState>(&input("[]")).is_err());
620 }
621
622 #[test]
623 fn a_pin_decodes_its_digests_and_requires_them() {
624 let input = |pin: &str| {
625 format!(
626 r#"{{"target":{{"ecosystem":"version-file","version_file":"VERSION"}},"source":["."],"changelog":"CHANGELOG.md","dependencies":["client"],"pins":{{"client":{pin}}},"tag_prefix":"v","revision_file":null,"kind":"application","application_bump":"patch"}}"#
627 )
628 };
629 let unit: ReleaseUnitState = facet_json::from_str(&input(
630 r#"{"path":"action.yml","key":["version"],"digests":{"binary":{"path":"action.yml","key":["digest"],"algorithm":"sha512","package":"client-bin","output":"bin/client"}}}"#,
631 ))
632 .unwrap();
633 let digest = &unit.pins["client"].digests["binary"];
634 assert_eq!(digest.key.segments(), ["digest"]);
635 assert_eq!(digest.algorithm, DigestAlgorithm::Sha512);
636 assert_eq!(digest.package, "client-bin");
637 assert_eq!(digest.output, "bin/client");
638 assert!(
639 facet_json::from_str::<ReleaseUnitState>(&input(
640 r#"{"path":"action.yml","key":["version"]}"#
641 ))
642 .is_err()
643 );
644 }
645
646 #[test]
647 fn a_digest_pin_requires_its_algorithm() {
648 let input = r#"{"target":{"ecosystem":"version-file","version_file":"VERSION"},"source":["."],"changelog":"CHANGELOG.md","dependencies":["client"],"pins":{"client":{"path":"action.yml","key":["version"],"digests":{"binary":{"path":"action.yml","key":["digest"],"package":"client-bin","output":"bin/client"}}}},"tag_prefix":"v","revision_file":null,"kind":"application","application_bump":"patch"}"#;
649 assert!(facet_json::from_str::<ReleaseUnitState>(input).is_err());
650 }
651
652 #[test]
653 fn npm_target_and_registry_decode() {
654 let input = r#"{"target":{"ecosystem":"npm","manifest":"web/package.json","publish":true},"source":["web"],"changelog":"web/CHANGELOG.md","dependencies":[],"pins":{},"tag_prefix":"web-v","revision_file":null,"kind":"application","application_bump":"patch"}"#;
655 let unit: ReleaseUnitState = facet_json::from_str(input).unwrap();
656 assert_eq!(
657 unit.target,
658 ReleaseTarget::Npm {
659 manifest: "web/package.json".parse().unwrap(),
660 publish: true,
661 }
662 );
663 let several = input.replace(r#"["web"]"#, r#"["web","shared"]"#);
664 let several_unit: ReleaseUnitState = facet_json::from_str(&several).unwrap();
665 assert_eq!(several_unit.source.as_slice().len(), 2);
666 assert!(
667 facet_json::from_str::<ReleaseUnitState>(&input.replace(r#"["web"]"#, "[]")).is_err()
668 );
669 assert!(
670 facet_json::from_str::<ReleaseUnitState>(&input.replace(r#","publish":true"#, ""))
671 .is_err()
672 );
673
674 let configured: ReleaseState = facet_json::from_str(&json(&[(
675 r#""npm":null"#,
676 r#""npm":{"registry":"https://forge.example/api/packages/acme/npm/"}"#,
677 )]))
678 .unwrap();
679 assert_eq!(
680 configured.npm_registry(),
681 Some("https://forge.example/api/packages/acme/npm/")
682 );
683 assert_eq!(state("main").npm_registry(), None);
684 assert!(
685 facet_json::from_str::<ReleaseState>(&json(&[(
686 r#""npm":null"#,
687 r#""npm":{"registry":"u","extra":1}"#
688 )]))
689 .is_err()
690 );
691 }
692
693 #[test]
694 fn unit_requires_its_tag_prefix() {
695 let input = r#"{"target":{"ecosystem":"version-file","version_file":"VERSION"},"source":["."],"changelog":"CHANGELOG.md","dependencies":[],"pins":{},"revision_file":null,"kind":"library","application_bump":"patch"}"#;
696 assert!(facet_json::from_str::<ReleaseUnitState>(input).is_err());
697 }
698
699 #[test]
700 fn state_requires_its_branches_and_rejects_an_empty_name() {
701 let named = state("trunk");
702 assert_eq!(
703 named.authoritative_branch,
704 BranchName::try_from("trunk").unwrap()
705 );
706 assert!(
707 facet_json::from_str::<ReleaseState>(&json(&[(
708 r#""authoritative_branch":"main","proposal_branch":"ephemeris/release","#,
709 ""
710 )]))
711 .is_err()
712 );
713 assert!(
714 facet_json::from_str::<ReleaseState>(&json(&[(
715 r#""authoritative_branch":"main""#,
716 r#""authoritative_branch":"""#
717 )]))
718 .is_err()
719 );
720 }
721
722 #[test]
723 fn the_authority_is_always_read_through_origin() {
724 let (authority, proposal) = state("trunk").branches(None).unwrap();
725 assert_eq!(authority.name(), "trunk");
726 assert_eq!(authority.tracking(), "origin/trunk");
727 assert_eq!(
728 authority.refspec(),
729 "+refs/heads/trunk:refs/remotes/origin/trunk"
730 );
731 assert_eq!(proposal, "ephemeris/release");
732 assert_eq!(
733 state("trunk")
734 .branches(Some("ephemeris/release"))
735 .unwrap()
736 .0,
737 authority
738 );
739 }
740
741 #[test]
742 fn explicit_proposal_branch_must_match_the_configuration() {
743 assert_eq!(
744 state("main")
745 .branches(Some("other"))
746 .unwrap_err()
747 .to_string(),
748 "requested proposal branch \"other\" conflicts with evaluated release branch \
749 \"ephemeris/release\""
750 );
751 }
752
753 #[test]
754 fn local_version_file_plan_lists_its_files_and_applies() {
755 let root = tempfile::tempdir().unwrap();
756 fs::create_dir_all(root.path().join("app")).unwrap();
757 fs::write(root.path().join("app/VERSION"), "1.0.0\n").unwrap();
758 fs::write(
759 root.path().join("app/CHANGELOG.md"),
760 "# Changelog\n\n## [Unreleased]\n",
761 )
762 .unwrap();
763 let repository = git2::Repository::init(root.path()).unwrap();
764 let mut index = repository.index().unwrap();
765 index
766 .add_all(["*"], git2::IndexAddOption::DEFAULT, None)
767 .unwrap();
768 index.write().unwrap();
769 let state: ReleaseState = facet_json::from_str(&json(&[(
770 r#""units":{}"#,
771 r#""units":{"app":{"target":{"ecosystem":"version-file","version_file":"app/VERSION"},"source":["app"],"changelog":"app/CHANGELOG.md","dependencies":[],"pins":{},"tag_prefix":"app-v","kind":"application","application_bump":"patch","revision_file":null}}"#,
772 )]))
773 .unwrap();
774 let plan = state
775 .plan_local(
776 &RepositoryRoot::new(root.path()).unwrap(),
777 "app",
778 "1.1.0",
779 "2026-10-02",
780 )
781 .unwrap();
782 assert_eq!(
783 plan.changes(),
784 [LocalChange {
785 unit: "app".into(),
786 from: "1.0.0".into(),
787 to: "1.1.0".into(),
788 }]
789 );
790 assert_eq!(
791 plan.files(),
792 [Path::new("app/CHANGELOG.md"), Path::new("app/VERSION")]
793 );
794 plan.apply().unwrap();
795 assert_eq!(
796 fs::read_to_string(root.path().join("app/VERSION")).unwrap(),
797 "1.1.0\n"
798 );
799 }
800
801 #[test]
802 fn a_prerelease_with_a_revision_is_rejected_at_resolution() {
803 let root = tempfile::tempdir().unwrap();
804 fs::create_dir_all(root.path().join("app")).unwrap();
805 fs::write(root.path().join("app/VERSION"), "1.0.0-rc.1\n").unwrap();
806 fs::write(root.path().join("app/revision"), "3\n").unwrap();
807 let state: ReleaseState = facet_json::from_str(&json(&[(
808 r#""units":{}"#,
809 r#""units":{"app":{"target":{"ecosystem":"version-file","version_file":"app/VERSION"},"source":["app"],"changelog":"app/CHANGELOG.md","dependencies":[],"pins":{},"tag_prefix":"v","kind":"application","application_bump":"patch","revision_file":"app/revision"}}"#,
810 )]))
811 .unwrap();
812 let error = state
813 .discover_units(&RepositoryRoot::new(root.path()).unwrap())
814 .unwrap_err();
815 assert!(
816 matches!(
817 &error,
818 ResolveError::AmbiguousTag { unit, tag } if unit == "app" && tag == "v1.0.0-rc.1-3"
819 ),
820 "{error:?}"
821 );
822 }
823}