Skip to main content

ephemeris_release/
state.rs

1//! Release-specific evaluated configuration consumed by the release CLI.
2
3use 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/// Evaluated release policy. This component can be decoded on its own.
20#[derive(Clone, Debug, Eq, Facet, PartialEq)]
21#[facet(deny_unknown_fields)]
22pub struct ReleaseState {
23    /// Cargo registry settings, when Cargo units publish.
24    pub(crate) cargo: Option<CargoReleaseState>,
25    /// npm registry settings, when npm units publish.
26    pub(crate) npm: Option<NpmReleaseState>,
27    /// Merged branch from which publication is permitted.
28    pub(crate) authoritative_branch: BranchName,
29    /// Maintained release proposal branch.
30    pub(crate) proposal_branch: BranchName,
31    /// Subject template of the proposal commits.
32    pub(crate) commit_subject: CommitSubject,
33    /// Format of the commit messages that bumps and changelog entries are
34    /// derived from.
35    pub(crate) commit_format: Option<CommitFormat>,
36    /// Release units by ID.
37    pub(crate) units: BTreeMap<String, ReleaseUnitState>,
38    /// Publication artifacts by unit ID, then artifact name.
39    pub(crate) artifacts: BTreeMap<String, BTreeMap<String, ReleaseArtifactState>>,
40    /// Who commits, tags and logs in to registries.
41    pub(crate) account: Account<Expr>,
42    /// Name of the credential that uploads packages and logs in to OCI
43    /// registries.
44    pub(crate) packages_credential: String,
45}
46
47/// A non-empty Git branch name.
48#[derive(Clone, Debug, Eq, Facet, PartialEq)]
49#[facet(proxy = String)]
50pub(crate) struct BranchName(String);
51
52/// The non-empty list of directories whose changes trigger a unit.
53#[derive(Clone, Debug, Eq, Facet, PartialEq)]
54#[facet(proxy = Vec<RepositoryPath>)]
55pub(crate) struct SourceDirectories(Vec<RepositoryPath>);
56
57/// A configured unit lists no source directory.
58#[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/// The authoritative branch, which every comparison reads through its
63/// `origin` remote-tracking branch.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct AuthorityBranch(BranchName);
66
67/// A configured branch name is empty.
68#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
69#[error("a release branch name must not be empty")]
70pub struct EmptyBranchName;
71
72/// A command-line branch conflicts with the evaluated release policy.
73#[derive(Debug, thiserror::Error)]
74pub(crate) enum BranchError {
75    /// A command-line proposal branch differs from the configured one.
76    #[error(
77        "requested proposal branch {requested:?} conflicts with evaluated release branch \
78         {configured:?}"
79    )]
80    Proposal {
81        /// The branch from the command line.
82        requested: String,
83        /// The configured branch.
84        configured: String,
85    },
86}
87
88/// The ecosystem-specific version source and publication destination.
89#[derive(Clone, Debug, Eq, Facet, PartialEq)]
90#[facet(tag = "ecosystem", deny_unknown_fields)]
91#[repr(u8)]
92pub(crate) enum ReleaseTarget {
93    /// A Cargo package.
94    #[facet(rename = "cargo")]
95    Cargo {
96        /// Cargo manifest, relative to the repository root.
97        manifest: RepositoryPath,
98        /// Whether the package is published to a registry.
99        publish: bool,
100    },
101    /// An npm package.
102    #[facet(rename = "npm")]
103    Npm {
104        /// The `package.json`, relative to the repository root.
105        manifest: RepositoryPath,
106        /// Whether the package is published to a registry.
107        publish: bool,
108    },
109    /// A plain file holding one semantic version.
110    #[facet(rename = "version-file")]
111    VersionFile {
112        /// The version file, relative to the repository root.
113        version_file: RepositoryPath,
114    },
115    /// A string at a key path inside a TOML, YAML, or JSON document.
116    #[facet(rename = "document")]
117    Document {
118        /// The document, relative to the repository root.
119        path: RepositoryPath,
120        /// The key path of the version string.
121        key: ManagedPath,
122    },
123}
124
125/// A file of a unit that holds the version of one of its dependencies.
126#[derive(Clone, Debug, Eq, Facet, PartialEq)]
127#[facet(deny_unknown_fields)]
128pub(crate) struct Pin {
129    /// The document, relative to the repository root.
130    pub path: RepositoryPath,
131    /// The key path of the version string.
132    pub key: ManagedPath,
133    /// Files holding the digest of an asset of the dependency, by artifact.
134    pub digests: BTreeMap<String, DigestPin>,
135}
136
137/// A file of a unit that holds the digest of an asset of a dependency.
138#[derive(Clone, Debug, Eq, Facet, PartialEq)]
139#[facet(deny_unknown_fields)]
140pub(crate) struct DigestPin {
141    /// The document, relative to the repository root.
142    pub path: RepositoryPath,
143    /// The key path of the digest string.
144    pub key: ManagedPath,
145    /// The hash algorithm of the digest.
146    pub algorithm: DigestAlgorithm,
147    /// The per-system package that holds the asset.
148    pub package: String,
149    /// The asset path relative to the package output.
150    pub output: String,
151}
152
153/// Optional Cargo registry configuration, isolated from generic release policy.
154#[derive(Clone, Debug, Default, Eq, Facet, PartialEq)]
155#[facet(deny_unknown_fields)]
156pub(crate) struct CargoReleaseState {
157    /// Named Cargo registry to publish to instead of crates.io.
158    pub(crate) registry: Option<String>,
159    /// Registry API base URL used to check published versions.
160    pub(crate) registry_api_url: Option<String>,
161}
162
163/// npm registry configuration.
164#[derive(Clone, Debug, Eq, Facet, PartialEq)]
165#[facet(deny_unknown_fields)]
166pub(crate) struct NpmReleaseState {
167    /// Registry base URL that packages are published to and looked up in.
168    pub(crate) registry: String,
169}
170
171/// A configured package in the release graph.
172#[derive(Clone, Debug, Eq, Facet, PartialEq)]
173#[facet(deny_unknown_fields)]
174pub(crate) struct ReleaseUnitState {
175    /// Version source and publication ecosystem.
176    pub(crate) target: ReleaseTarget,
177    /// Directories whose changes trigger a release.
178    pub(crate) source: SourceDirectories,
179    /// Changelog path, absent for a unit without a changelog.
180    pub(crate) changelog: Option<RepositoryPath>,
181    /// Units that must be published first.
182    pub(crate) dependencies: Vec<String>,
183    /// Files holding the versions of dependencies, keyed by dependency.
184    pub(crate) pins: BTreeMap<String, Pin>,
185    /// Prefix of the unit's release tags.
186    pub(crate) tag_prefix: String,
187    /// Which version bump policy applies.
188    pub(crate) kind: UnitKind,
189    /// Automatic bump of an application.
190    pub(crate) application_bump: ApplicationBump,
191    /// Repository file holding the decimal packaging revision.
192    pub(crate) revision_file: Option<RepositoryPath>,
193}
194
195/// How a unit's automatic version bump is decided.
196#[derive(Clone, Copy, Debug, Eq, Facet, PartialEq)]
197#[facet(rename_all = "kebab-case")]
198#[repr(u8)]
199pub(crate) enum UnitKind {
200    /// Bumped by the configured application bump, without API analysis.
201    Application,
202    /// Bumped by semver analysis of its public API.
203    Library,
204}
205
206/// The version bump an application receives when it changes.
207#[derive(Clone, Copy, Debug, Eq, Facet, PartialEq)]
208#[facet(rename_all = "kebab-case")]
209#[repr(u8)]
210pub(crate) enum ApplicationBump {
211    /// Increment the patch component.
212    Patch,
213    /// Keep the version unless one is requested explicitly.
214    None,
215}
216
217/// A commit message format the proposal analyzes.
218#[derive(Clone, Copy, Debug, Eq, Facet, PartialEq)]
219#[facet(rename_all = "kebab-case")]
220#[repr(u8)]
221pub(crate) enum CommitFormat {
222    /// Conventional Commits.
223    Conventional,
224}
225
226/// An artifact associated with a release unit.
227/// A file from a package output, uploaded to the forge release.
228#[derive(Clone, Debug, Eq, Facet, PartialEq)]
229#[facet(deny_unknown_fields)]
230pub(crate) struct AssetSpec {
231    /// Per-system package output that contains the artifact.
232    pub(crate) package: String,
233    /// Path relative to the package output.
234    pub(crate) output: String,
235    /// Publication file name.
236    pub(crate) filename: String,
237    /// Publication format, such as `deb`.
238    pub(crate) format: String,
239    /// Artifact variant identifier.
240    pub(crate) variant: String,
241    /// File names that publication must expose for this artifact.
242    pub(crate) required_names: Vec<String>,
243}
244
245/// A package whose push program publishes to an OCI registry.
246#[derive(Clone, Debug, Eq, Facet, PartialEq)]
247#[facet(deny_unknown_fields)]
248pub(crate) struct OciSpec {
249    /// Per-system package whose output has a `bin/push` program.
250    pub(crate) package: String,
251    /// Registry and namespace.
252    pub(crate) registry: String,
253    /// Repository path under the registry.
254    pub(crate) repository: String,
255    /// Whether the `latest` tag is pushed too.
256    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    /// A file from a package output, uploaded to the forge release.
264    #[facet(rename = "asset")]
265    Asset(AssetSpec),
266    /// A package whose push program publishes to an OCI registry.
267    #[facet(rename = "oci")]
268    Oci(OciSpec),
269    /// A `.crate` file from a package output, uploaded to the Cargo registry.
270    #[facet(rename = "cargo")]
271    Cargo {
272        /// Per-system package whose output holds one `.crate` file.
273        package: String,
274    },
275    /// An npm tarball from a package output, published to the npm registry.
276    #[facet(rename = "npm")]
277    Npm {
278        /// Per-system package whose output holds one `.tgz` file.
279        package: String,
280    },
281}
282
283impl BranchName {
284    /// The branch name as configured.
285    pub(crate) fn as_str(&self) -> &str {
286        &self.0
287    }
288}
289
290impl SourceDirectories {
291    /// The directories as configured.
292    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    /// The branch name on the forge, such as `main`.
343    #[must_use]
344    pub fn name(&self) -> &str {
345        self.0.as_str()
346    }
347
348    /// The remote-tracking revision, such as `origin/main`.
349    #[must_use]
350    pub fn tracking(&self) -> String {
351        format!("origin/{}", self.name())
352    }
353
354    /// The refspec that updates [`AuthorityBranch::tracking`] from `origin`.
355    pub(crate) fn refspec(&self) -> String {
356        format!("+refs/heads/{0}:refs/remotes/origin/{0}", self.name())
357    }
358}
359
360/// Parses the branch name as the forge knows it, without a remote prefix.
361impl 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    /// The subject template of the proposal commits.
371    #[must_use]
372    pub const fn commit_subject(&self) -> &CommitSubject {
373        &self.commit_subject
374    }
375
376    /// The named Cargo registry to publish to, when one is configured.
377    #[must_use]
378    pub fn cargo_registry(&self) -> Option<&str> {
379        self.cargo.as_ref()?.registry.as_deref()
380    }
381
382    /// The Cargo registry API base URL used to check published versions.
383    #[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    /// The npm registry base URL, when one is configured.
389    #[must_use]
390    pub fn npm_registry(&self) -> Option<&str> {
391        self.npm.as_ref().map(|npm| npm.registry.as_str())
392    }
393
394    /// The expressions of the account the release acts as.
395    #[must_use]
396    pub const fn account(&self) -> &Account<Expr> {
397        &self.account
398    }
399
400    /// The name of the credential for package uploads and registry logins.
401    #[must_use]
402    pub fn packages_credential(&self) -> &str {
403        &self.packages_credential
404    }
405
406    /// Resolve configured release targets into graph units.
407    ///
408    /// # Errors
409    /// Returns an error when a configured unit cannot be resolved.
410    pub(crate) fn discover_units(&self, root: &RepositoryRoot) -> Result<Vec<Unit>, ResolveError> {
411        Adapters::without_registry().resolve(root, &self.units)
412    }
413
414    /// Validate one configured unit's local release plan for Check, Diff, or
415    /// Apply.
416    ///
417    /// # Errors
418    /// Returns errors for unknown units, invalid or non-increasing versions,
419    /// unsafe paths, and invalid target plans.
420    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    /// The authoritative branch and the proposal branch, checking an explicit
443    /// proposal branch against the evaluated release policy.
444    ///
445    /// # Errors
446    /// Returns an error when the explicit proposal branch conflicts with the
447    /// configured one.
448    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    /// The state JSON with each `from` text replaced by its `to` text.
493    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}