Skip to main content

caixa_core/
version.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// A caixa's pinned version — a thin typed wrapper over a String that parses
7/// as [`semver::Version`] on demand.
8///
9/// Stored as a String at rest so authoring a `caixa.lisp` stays a single
10/// quoted literal. The typed form is reached through [`Self::parse`].
11#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
12#[serde(transparent)]
13pub struct CaixaVersion(pub String);
14
15impl CaixaVersion {
16    /// Parse and validate the wrapped string as semver.
17    pub fn parse(&self) -> Result<semver::Version, VersionError> {
18        semver::Version::parse(&self.0)
19            .map_err(|e| VersionError::semver(self.0.clone(), e.to_string()))
20    }
21
22    /// Borrow the string form.
23    #[must_use]
24    pub const fn as_str(&self) -> &str {
25        self.0.as_str()
26    }
27}
28
29impl fmt::Display for CaixaVersion {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        f.write_str(&self.0)
32    }
33}
34
35impl From<String> for CaixaVersion {
36    fn from(s: String) -> Self {
37        Self(s)
38    }
39}
40
41impl From<&str> for CaixaVersion {
42    fn from(s: &str) -> Self {
43        Self(s.to_string())
44    }
45}
46
47/// Substrate-canonical [`AsRef<str>`] projection on the [`CaixaVersion`]
48/// typed newtype — routes through the same [`CaixaVersion::as_str`]
49/// `pub const fn` scalar accessor the sibling [`fmt::Display`] impl
50/// and every downstream `&str`-shaped consumer already keys off, so
51/// any future consumer that binds a [`CaixaVersion`] through the
52/// standard-library `impl AsRef<str>` bound (a `Path`-shaped file-
53/// system reader on the operator side that accepts the version body
54/// as one segment of a per-caixa `versao/<v>/...` on-disk cache path,
55/// a builder-shaped API on the future `feira publish` writer verb
56/// that composes `<prefix><versao>` through a git-tag builder crate's
57/// `impl AsRef<str>` join step, a `HashMap<CaixaVersion, _>` lookup
58/// through the `map.get::<str>(v.as_ref())` shape a future
59/// version-keyed dispatch table lands on) reaches the wrapped
60/// [`String`] through one substrate-primitive dispatch rather than
61/// through the pre-lift `.as_str()` open-coded projection at every
62/// wire-up.
63///
64/// Peer of the sibling [`fmt::Display`] impl on the same primitive —
65/// both delegate to the shared [`CaixaVersion::as_str`] `pub const
66/// fn` accessor, so [`format!("{v}")`], `v.as_str()`, and
67/// `<CaixaVersion as AsRef<str>>::as_ref(&v)` resolve to the same
68/// byte-string per instance by construction. A future rebrand of the
69/// wrapped storage (a hypothetical widening to a typed [`semver::Version`]
70/// slot the roadmap acknowledges once eager parse-on-construct
71/// discipline lands, an internal normalization step that trims
72/// leading zeroes off pre-release identifiers, a per-cluster overlay
73/// the operator pins through a future `:versao-overrides` slot) that
74/// changes what [`CaixaVersion::as_str`] returns migrates every
75/// consumer of every one of the three paths in lockstep.
76///
77/// Same "route the trait impl through the substrate-primitive
78/// accessor" discipline the sibling [`fmt::Display`] impl on this
79/// type already carries — extends it onto the standard-library
80/// [`AsRef<str>`] projection axis every third-party API that takes
81/// `impl AsRef<str>` (the [`std::path::Path::new`] / [`std::fs`]
82/// interop surface, [`std::process::Command::arg`], the peer
83/// `tracing::field::Value` recorder's `Str`-arm, every `clap`-side
84/// `value_parser!` fold that accepts an owned newtype through
85/// `impl AsRef<str>`) already binds through. Rust-side newtype
86/// convention pairs `AsRef<str>` and [`fmt::Display`] on the same
87/// primitive so a caller who has one has both; before this lift,
88/// [`CaixaVersion`] carried [`fmt::Display`] but not the paired
89/// [`AsRef<str>`] impl the convention names.
90///
91/// The first standard-library trait added to [`CaixaVersion`] beyond
92/// the pre-existing [`serde::Serialize`] / [`serde::Deserialize`] /
93/// [`Debug`] / [`Clone`] / [`PartialEq`] / [`Eq`] / [`Hash`] derives
94/// and the paired [`fmt::Display`] / [`From<String>`] / [`From<&str>`]
95/// hand-written impls. Pinned load-bearing by
96/// [`tests::caixa_version_as_ref_str_routes_through_as_str_accessor`]
97/// (byte-parity pin against [`CaixaVersion::as_str`]) — any future
98/// silent detour that routes the impl through a divergent projection
99/// (a `Cow<'_, str>` intermediate, a stray `.to_lowercase()`
100/// normalization, a swap onto a per-arm inline `&self.0.as_str()`
101/// re-inlining) trips at caixa-core test time under `assert_eq!`
102/// rather than at a downstream `impl AsRef<str>`-bound consumer's
103/// silent split.
104impl AsRef<str> for CaixaVersion {
105    fn as_ref(&self) -> &str {
106        self.as_str()
107    }
108}
109
110/// Canonical Zig-style git-tag prefix every `feira publish` run writes
111/// and every downstream consumer of a published caixa reads. A caixa
112/// published at `:versao "0.1.0"` lands as a git tag `v0.1.0` on the
113/// source repo's `origin` remote — the [`crate::CaixaVersion`] value
114/// gates the version body, this constant gates the prefix the body
115/// composes under.
116///
117/// Two production-code consumers carry this prefix on the same git
118/// remote axis:
119///
120/// 1. [`caixa-feira`]'s `feira publish` verb (caixa-feira/src/cmd/publish.rs)
121///    — the writer. Its `--prefix` clap flag defaults to this string
122///    and the verb computes the tag as `format!("{prefix}{versao}")`
123///    before `git tag -a <tag>` + `git push origin <tag>`.
124/// 2. [`caixa-flux`]'s [`caixa-flux::cluster_bundle`] renderer
125///    (caixa-flux/src/lib.rs) — the reader. Its
126///    `ClusterBundleOpts::for_caixa` constructor defaults
127///    `git_ref: GitRefSpec::Tag(...)` to `<prefix><versao>` so the
128///    rendered `gitrepository.yaml` carries `ref: { tag: v<versao> }`
129///    pointing `FluxCD`'s `GitRepository` reconciler at the exact tag
130///    the publisher just wrote.
131///
132/// Until this lift landed both consumers carried the bare `"v"` byte
133/// inline — `caixa-feira/src/cmd/publish.rs:22`'s clap
134/// `default_value = "v"` and `caixa-flux/src/lib.rs:335`'s
135/// `format!("v{}", caixa.versao)` literal. A future Zig-style-tag
136/// convention rebrand (the substrate moving to plain `<versao>` tags
137/// once the GitHub releases UI normalizes around the bare form, to
138/// `release/<versao>` once a sibling forge convention adopts the
139/// `<type>/<value>` slash-namespaced shape, or to a per-edition
140/// override the operator pins through a future `:placement
141/// :tag-prefix` slot) without a coordinated edit on both sides would
142/// silently emit a `feira publish`-side tag at one shape (e.g.
143/// `release/0.1.0`) and a `cluster_bundle`-side `ref: { tag: v0.1.0 }`
144/// pointing at the prior shape — Flux's `GitRepository` reconciler
145/// would loop forever looking for an upstream `v0.1.0` ref the publish
146/// remote no longer carries, the dependent `HelmRelease`'s `chart:
147/// sourceRef` would never resolve, every per-Servico apply would
148/// silently come up with the prior reconciled state, and the failure
149/// would surface at `kubectl describe gitrepository` time (the
150/// `Status: Stalled` / `Reason: Failed` arm) far from the rebrand
151/// commit's source.
152///
153/// Lifting the literal to one `&'static str` constant closes the drift
154/// footgun structurally — both consumers read from the same memory,
155/// so any future rebrand reaches both sites by construction and a CI
156/// build that re-introduces a sibling inline `"v"` literal trips the
157/// peer pinning tests
158/// ([`caixa-feira`]'s `publish_prefix_default_pins_lifted_caixa_core_constant`,
159/// [`caixa-flux`]'s `cluster_bundle_default_git_tag_uses_lifted_caixa_core_prefix`)
160/// at the build-time fail-before-deploy posture every prior
161/// load-bearing-string lift on this surface
162/// ([`crate::DEFAULT_NAMESPACE`] a085b26,
163/// [`crate::DEFAULT_LIBRARY_NAME`] 41438dc,
164/// [`crate::DEFAULT_SERVICO_PORT`] 1e22add) establishes.
165///
166/// Authoring-side `:versao` gates already refuse the `"v"`-prefixed
167/// publish tag shape leaking back into a version body — every typed
168/// `:versao` surface (top-level `:versao`, `:upgrade-from :from`,
169/// `:deps :versao`, `:deps-dev :versao`, `:membros :versao`,
170/// `:children :versao`) routes through `semver::Version::parse` /
171/// [`parse_requirement`], both of which reject the `v`-prefix as
172/// invalid `SemVer`. The split — bare `SemVer` at the `:versao` slot,
173/// `v<versao>` at the published git-tag axis — is the convention this
174/// constant pins.
175pub const DEFAULT_PUBLISH_TAG_PREFIX: &str = "v";
176
177/// Canonical git remote name every `feira` writer-side verb pushes to —
178/// the destination handle the operator-out-of-the-loop publish + deploy
179/// chain (`feira publish`, `feira deploy --apply`, `feira app deploy
180/// --apply`) names when it invokes `git push <remote> <ref>` against
181/// the local clone of the source / k8s GitOps repo.
182///
183/// Three production-code consumers carry this remote name on the same
184/// `git push` axis:
185///
186/// 1. [`caixa-feira`]'s `feira publish` verb (caixa-feira/src/cmd/publish.rs)
187///    — the writer-side publish path. Its `--remote` clap flag defaults
188///    to this string and the verb runs `git push <remote> <tag>` to push
189///    the freshly written `v<versao>` tag upstream.
190/// 2. [`caixa-feira`]'s `feira deploy --apply` verb
191///    (caixa-feira/src/cmd/deploy.rs) — the writer-side Servico cluster-
192///    deploy path. Its `push_origin` helper runs `git push origin HEAD`
193///    against the k8s GitOps repo's working tree after upserting the
194///    Servico's entry into the cluster's lareira-fleet-programs
195///    HelmRelease values.
196/// 3. [`caixa-feira`]'s `feira app deploy --apply` verb
197///    (caixa-feira/src/cmd/app.rs) — the writer-side Aplicacao
198///    cluster-deploy path. Its `push_origin` helper runs the same
199///    `git push origin HEAD` against the k8s GitOps repo after writing
200///    the rendered multi-doc YAML (programs.yaml entries + Cilium
201///    NetworkPolicies + Gateway/HTTPRoute) to the cluster's tree.
202///
203/// Until this lift landed all three consumers carried the bare
204/// `"origin"` byte inline — `publish.rs`'s clap `default_value = "origin"`,
205/// `deploy.rs`'s `git(repo, ["push", "origin", "HEAD"])`, and
206/// `app.rs`'s `git(repo, ["push", "origin", "HEAD"])`. A future
207/// remote-naming-convention rebrand on any one side (the substrate
208/// moving to `upstream` for forge-mirror clusters, to a per-tenant
209/// remote naming convention once the operator-flux pipeline grows the
210/// `:placement :remote` slot, or to the canonical multi-remote
211/// `release` + `mirror` split every Erlang/OTP `release_handler` /
212/// `relup` shop converges on once their git surface grows past one
213/// upstream) without a coordinated edit on the other two would have
214/// silently emitted a `git push` against a remote that doesn't exist
215/// on the operator's clone (`fatal: '<remote>' does not appear to be
216/// a git repository`) on one writer verb while the other two still
217/// pushed to the old remote — operator-observed symptom: the publish
218/// landed but the deploy didn't, or vice-versa, with the failure
219/// surfacing as a partial-state rollout far from the rebrand commit's
220/// source.
221///
222/// Lifting the literal to one `&'static str` constant closes the drift
223/// footgun structurally — all three consumers read from the same
224/// memory, so any future remote-naming rebrand reaches every writer
225/// verb by construction and a CI build that re-introduces a sibling
226/// inline `"origin"` literal trips the peer pinning tests
227/// ([`caixa-feira`]'s `publish_remote_default_pins_lifted_caixa_core_constant`
228/// on the clap-default axis, the sibling structural pins on the two
229/// `push_origin` helpers) at the build-time fail-before-deploy
230/// posture every prior load-bearing-string lift on this surface
231/// ([`crate::DEFAULT_NAMESPACE`] a085b26, [`crate::DEFAULT_LIBRARY_NAME`]
232/// 41438dc, [`crate::DEFAULT_SERVICO_PORT`] 1e22add,
233/// [`crate::DEFAULT_PUBLISH_TAG_PREFIX`] 0a6a602,
234/// [`crate::DEFAULT_FLUX_SYSTEM_NAMESPACE`] 7197d38) establishes.
235///
236/// Pairs with [`DEFAULT_PUBLISH_TAG_PREFIX`] on the same git remote
237/// axis — `feira publish` runs `git push <DEFAULT_GIT_REMOTE>
238/// <DEFAULT_PUBLISH_TAG_PREFIX><versao>` to push the typed `:versao`
239/// body composed under the canonical prefix to the canonical remote.
240/// Both halves of the publish-side convention now live in one place.
241pub const DEFAULT_GIT_REMOTE: &str = "origin";
242
243/// Canonical GitHub org name the pleme-io substrate defaults every un-
244/// pinned caixa's source repo to — the org handle the two substrate-side
245/// "no `:repositorio` / no `:fonte` declared, fall back to the canonical
246/// org" paths compose their `github:<org>/<nome>` shorthand + full
247/// `https://github.com/<org>/<nome>` URL under.
248///
249/// Two production-code consumers carry this org name on the same
250/// canonical-substrate-default-git-org axis:
251///
252/// 1. [`caixa-feira`]'s `feira lock` verb's `resolve_stub` (caixa-feira/src/cmd/lock.rs)
253///    — the resolver-side default. When a declared dep has no
254///    `:fonte` block the stub resolver composes
255///    `caixa_core::DepSource::default_github(<org>, &dep.nome)` to fill
256///    the shorthand `github:<org>/<nome>` fallback the phase 1.B
257///    `feira resolve` walker will resolve against upstream.
258/// 2. [`caixa-flux`]'s [`caixa-flux::cluster_bundle`] renderer
259///    (caixa-flux/src/lib.rs) — the renderer-side default. Its
260///    `ClusterBundleOpts::for_caixa` constructor defaults
261///    `git_url` to `format!("https://github.com/{org}/{}", caixa.nome)`
262///    when the caixa carries no `:repositorio`, so the rendered
263///    `gitrepository.yaml` points `FluxCD`'s `GitRepository`
264///    reconciler at the substrate's canonical git host for un-pinned
265///    caixas.
266///
267/// Until this lift landed both consumers carried the bare `"pleme-io"`
268/// byte inline — `caixa-feira/src/cmd/lock.rs:61`'s
269/// `default_github("pleme-io", …)` call and `caixa-flux/src/lib.rs`'s
270/// `format!("https://github.com/pleme-io/{}", …)` literal. A future
271/// substrate-side git-org migration (the pleme-io org renaming to a
272/// short form, forking to a per-tenant `<org>-<tenant>` shape once the
273/// operator-flux pipeline grows a `:placement :org` slot, or moving to
274/// a self-hosted forge under a wholly-owned org name once the
275/// substrate's forge-gen roadmap graduates past GitHub) without a
276/// coordinated edit on both sides would silently emit a `feira lock`-
277/// side `github:<old-org>/<nome>` fallback shorthand while the
278/// `cluster_bundle`-side `gitrepository.yaml` pointed at the new org's
279/// `<nome>` — the phase 1.B `feira resolve` walker would probe the
280/// prior org's git host for a repo that migrated with the org, or vice-
281/// versa: Flux's `GitRepository` reconciler would loop forever looking
282/// for an upstream repo the old org handle no longer maps to, the
283/// dependent `HelmRelease`'s `chart: sourceRef` would never resolve,
284/// every per-Servico apply would silently come up with the prior
285/// reconciled state, and the failure would surface at `kubectl describe
286/// gitrepository` time (the `Status: Stalled` / `Reason: Failed` arm)
287/// far from the org-migration commit's source.
288///
289/// Lifting the literal to one `&'static str` constant closes the drift
290/// footgun structurally — both consumers read from the same memory, so
291/// any future org migration reaches both sites by construction and a CI
292/// build that re-introduces a sibling inline `"pleme-io"` literal trips
293/// the peer pinning tests at the build-time fail-before-deploy posture
294/// every prior load-bearing-string lift on this surface
295/// ([`crate::DEFAULT_NAMESPACE`] a085b26,
296/// [`crate::DEFAULT_LIBRARY_NAME`] 41438dc,
297/// [`crate::DEFAULT_SERVICO_PORT`] 1e22add,
298/// [`DEFAULT_PUBLISH_TAG_PREFIX`] 0a6a602,
299/// [`DEFAULT_GIT_REMOTE`],
300/// [`crate::DEFAULT_FLUX_SYSTEM_NAMESPACE`] 7197d38) establishes.
301///
302/// Distinct from the [`crate::PLEME_LABEL_PREFIX`] canonical pleme-io
303/// label-namespace prefix (`"pleme.pleme.io"`, the K8s label-namespace
304/// axis every substrate-emitted cluster object's `LABEL_APLICACAO` /
305/// `LABEL_PROGRAM` / `LABEL_CONTRATO` axis shares) — these constants
306/// sit on separate schema-contract surfaces (the git-host org handle
307/// vs. the K8s label-namespace prefix) governed by independent rebrand
308/// cycles, so a git-org rename must not couple the K8s label-namespace
309/// axis to the git-host axis (or vice-versa). Splitting the two lets
310/// each schema's future rebrand land independently at its canonical
311/// const definition without silently coupling the surfaces — same
312/// "byte-distinct, semantically distinct" discipline the
313/// [`crate::PLEME_LABEL_PREFIX`] / [`crate::LABEL_APLICACAO`] /
314/// [`crate::LABEL_PROGRAM`] / [`crate::LABEL_CONTRATO`] set establishes
315/// on the peer per-K8s-label-namespace canonical-string surface.
316pub const DEFAULT_PLEME_GIT_ORG: &str = "pleme-io";
317
318/// Parse a dep's `:versao` string as a [`semver::VersionReq`].
319///
320/// Treats the literal `"*"` as "any version" (semver's wildcard).
321pub fn parse_requirement(s: &str) -> Result<semver::VersionReq, VersionError> {
322    if s == "*" {
323        return Ok(semver::VersionReq::STAR);
324    }
325    semver::VersionReq::parse(s).map_err(|e| VersionError::requirement(s, e.to_string()))
326}
327
328#[derive(Debug, Error, PartialEq, Eq)]
329pub enum VersionError {
330    #[error("invalid version '{0}': {1}")]
331    Semver(String, String),
332    #[error("invalid version requirement '{0}': {1}")]
333    Requirement(String, String),
334}
335
336// Fold the sole `VersionError::Semver(<into-String-expr>, <into-String-expr>)`
337// wire-up site on [`CaixaVersion::parse`]'s [`semver::Version::parse`]
338// `map_err` arm onto one substrate primitive — the paired
339// `(String, String)` two-slot tuple-newtype [`VersionError::Semver`] on
340// the [`CaixaVersion`] parser surface, the first of the two variants on
341// the [`VersionError`] envelope's paired `(String, String)` tuple-newtype
342// codec-magnitude family (its peer is [`VersionError::Requirement`] on
343// the sibling [`parse_requirement`] surface). Same discipline the peer
344// per-variant lifts on [`AplicacaoError`] / [`SupervisorError`] /
345// [`UpgradeError`] / [`LayoutError`] / [`DepError`] / [`ManifestError`]
346// / [`LimitsError`] / [`BehaviorError`] / [`DialetoError`] have
347// converged through the "one substrate primitive per emit-site variant"
348// ratchet: the sole wire-up site opens the identical
349// `VersionError::Semver(<into-String-expr>, <into-String-expr>)` block
350// against the parser-scoped `String` binding (`self.0.clone()`) and the
351// derived `String` binding (`e.to_string()`) on the failing
352// [`semver::Version::parse`] arm, so the fold routes the site through
353// one dispatch on a uniform pair of `impl Into<String>` params,
354// byte-equal to the pre-lift tuple-newtype construction on the same
355// arguments. The `impl Into<String>` bound covers both the pre-lift
356// `String` bindings and any future `&str` binding a downstream consumer
357// might carry without forcing the caller to spell the `.into()`
358// conversion at the wire-up site — the same shape the peer
359// [`LimitsError::empty_byte_size`] / [`LimitsError::empty_duration`] /
360// [`DialetoError::leitura`] folds carry on the single-slot `(String)`
361// tuple-newtype cousins of the same tuple-newtype error-envelope family
362// on the sibling parser surfaces. `#[must_use]` fires a compile warning
363// at any wire-up that mistakenly discards the constructed error. The
364// added [`PartialEq`] / [`Eq`] derives on the envelope (peer with the
365// sibling [`LimitsError`] / [`DialetoError`] / [`DepError`] envelopes
366// on the same axis) let the fail-before-pass-after byte-equality pins
367// below trip a de-lift regression at caixa-core test time under
368// `PartialEq` rather than at a downstream diagnostic shape drift.
369//
370// Every future consumer that wants to construct this variant outside
371// [`CaixaVersion::parse`] (a deferred `feira lint --canonical-versao`
372// per-caixa admission verb probing each authored top-level `:versao`
373// value against the same [`semver::Version::parse`] gate, an M4 typed
374// `mesh.pleme.io/v1alpha1/Servico` CR materializer's per-manifest
375// admission validator re-checking one edited `:versao` slot against
376// the [`CaixaVersion::parse`] semver floor, a per-`caixa.lisp` value-
377// shape pre-emitter probing each declared `:versao` magnitude ahead of
378// the operator's admit-cycle) now reaches the variant through one call
379// rather than re-inlining the two-slot tuple-newtype block in lockstep.
380impl VersionError {
381    /// Construct a [`VersionError::Semver`] carrying the offending
382    /// authoring string `value` and the underlying [`semver::Version::parse`]
383    /// `reason` verbatim in the variant's two-slot tuple-newtype payload.
384    /// Folds the uniform `Self::Semver(value.into(), reason.into())`
385    /// tuple-newtype construction onto one substrate primitive so every
386    /// wire-up on the variant reads through one dispatch rather than the
387    /// pre-lift open-coded
388    /// `VersionError::Semver(<into-String-expr>, <into-String-expr>)`
389    /// block. The paired `impl Into<String>` bounds cover the pre-lift
390    /// `String` wire-up shape on [`CaixaVersion::parse`]
391    /// (`self.0.clone()` on the parser-scoped `String` field, `e.to_string()`
392    /// on the derived `String` from the failing
393    /// [`semver::Version::parse`] arm) without forcing the caller to
394    /// spell the conversion at the wire-up site. Peer to the sibling
395    /// [`VersionError::Requirement`] variant on the [`parse_requirement`]
396    /// surface — the same `(String, String)` two-slot tuple-newtype axis
397    /// of the paired [`VersionError`] envelope, but on the `SemVer`
398    /// version-body parser surface rather than the version-requirement
399    /// parser surface.
400    #[must_use]
401    pub fn semver(value: impl Into<String>, reason: impl Into<String>) -> Self {
402        Self::Semver(value.into(), reason.into())
403    }
404
405    /// Construct a [`VersionError::Requirement`] carrying the offending
406    /// authoring string `value` and the underlying
407    /// [`semver::VersionReq::parse`] `reason` verbatim in the variant's
408    /// two-slot tuple-newtype payload. Folds the uniform
409    /// `Self::Requirement(value.into(), reason.into())` tuple-newtype
410    /// construction onto one substrate primitive so every wire-up on the
411    /// variant reads through one dispatch rather than the pre-lift open-
412    /// coded `VersionError::Requirement(<into-String-expr>,
413    /// <into-String-expr>)` block. Peer to the sibling
414    /// [`VersionError::semver`] ctor on the [`CaixaVersion::parse`]
415    /// surface — the same `(String, String)` two-slot tuple-newtype axis
416    /// of the paired [`VersionError`] envelope, but on the version-
417    /// requirement parser surface rather than the semver-version-body
418    /// parser surface. Closes the last un-lifted variant on the
419    /// [`VersionError`] envelope: every arm now reaches its emit site
420    /// through one substrate-primitive dispatch, matching the "one
421    /// substrate primitive per emit-site variant" ratchet the peer per-
422    /// variant lifts on [`crate::AplicacaoError`] /
423    /// [`crate::SupervisorError`] / [`crate::UpgradeError`] /
424    /// [`crate::LayoutError`] / [`crate::DepError`] /
425    /// [`crate::ManifestError`] / [`crate::LimitsError`] /
426    /// [`crate::BehaviorError`] / [`crate::DialetoError`] have converged
427    /// onto.
428    #[must_use]
429    pub fn requirement(value: impl Into<String>, reason: impl Into<String>) -> Self {
430        Self::Requirement(value.into(), reason.into())
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn version_round_trip() {
440        let v: CaixaVersion = "1.2.3".into();
441        assert_eq!(v.as_str(), "1.2.3");
442        assert_eq!(v.parse().unwrap().to_string(), "1.2.3");
443    }
444
445    #[test]
446    fn caixa_version_as_str_accessor_is_const_fn() {
447        // Fail-before-pass-after pin on [`CaixaVersion::as_str`]'s
448        // `const`-eval-surface posture. The accessor projects the typed
449        // newtype's inner [`String`] through the `pub const fn`
450        // [`String::as_str`] (const-stable since Rust 1.87, well within
451        // the workspace MSRV) — any future accidental downgrade to
452        // non-`const` fails `as_str_via_const_fn` at caixa-core build
453        // time with E0015 (`cannot call non-const method`), strictly
454        // stronger than a runtime `assert!`. Sibling of the peer
455        // per-M2/M3/universal-axis `String → &str` scalar-accessor
456        // family pins on the sibling `const`-eval-surface passes
457        // ([`crate::Caixa::nome`] / [`crate::Caixa::versao`] at the
458        // top-level manifest, [`crate::aplicacao::Membro::nome`] /
459        // [`crate::aplicacao::Membro::versao_requirement`] at the M3
460        // membership axis, [`crate::aplicacao::Entrada::hostname`] /
461        // [`crate::aplicacao::Entrada::destination`] at the M3 ingress
462        // axis, [`crate::supervisor::ChildSpec::nome`] /
463        // [`crate::supervisor::ChildSpec::versao_requirement`] at the
464        // M2 supervisor-tree axis,
465        // [`crate::upgrade::UpgradeFromEntry::prior_versao`] at the M2
466        // upgrade axis, [`crate::dep::Dep::nome`] /
467        // [`crate::dep::Dep::versao_requirement`] at the dep-graph
468        // axis, and the peer per-`:contratos` [`crate::aplicacao::WitContract::source`] /
469        // [`crate::aplicacao::WitContract::destination`] /
470        // [`crate::aplicacao::WitContract::world_ref`] trio the
471        // sibling pin at 279823b already anchors).
472        const fn as_str_via_const_fn(v: &CaixaVersion) -> &str {
473            v.as_str()
474        }
475        for versao in ["0.1.0", "1.2.3-alpha.1", "0.0.0", ""] {
476            let v: CaixaVersion = versao.into();
477            assert_eq!(as_str_via_const_fn(&v), v.as_str());
478            assert_eq!(v.as_str(), versao);
479        }
480    }
481
482    #[test]
483    fn star_is_any() {
484        let r = parse_requirement("*").unwrap();
485        assert!(r.matches(&"0.1.0".parse().unwrap()));
486        assert!(r.matches(&"99.0.0".parse().unwrap()));
487    }
488
489    #[test]
490    fn caret_matches_minor_range() {
491        let r = parse_requirement("^0.1").unwrap();
492        assert!(r.matches(&"0.1.0".parse().unwrap()));
493        assert!(r.matches(&"0.1.99".parse().unwrap()));
494        assert!(!r.matches(&"0.2.0".parse().unwrap()));
495    }
496
497    #[test]
498    fn invalid_version_errors() {
499        let v: CaixaVersion = "not-a-version".into();
500        assert!(v.parse().is_err());
501    }
502
503    #[test]
504    fn semver_ctor_matches_tuple_literal_wrap_on_str_binding() {
505        // Fail-before-pass-after byte-equality pin: the lifted
506        // [`VersionError::semver`] inherent ctor projects a `&str`
507        // binding pair through the paired `impl Into<String>` bounds
508        // byte-equal to the pre-lift open-coded
509        // `VersionError::Semver(<into-String-expr>, <into-String-expr>)`
510        // tuple-literal on the same fixture, so any future silent
511        // regression that swaps `.into()` for a divergent conversion
512        // (a stray `String::from(str::trim(v))` normalization, a
513        // parity-lossy `.to_lowercase()` fold, a `Cow<'_, str>` detour)
514        // trips at caixa-core test time under `PartialEq` rather than
515        // at a downstream diagnostic-shape drift on a consumer surface.
516        // Same shape the peer
517        // [`crate::LimitsError::empty_byte_size_ctor_matches_tuple_literal_wrap_on_str_binding`]
518        // / [`crate::DialetoError::leitura_ctor_matches_tuple_literal_wrap_on_str_binding`]
519        // pins carry on the sibling single-slot `(String)` tuple-newtype
520        // cousins of the same tuple-newtype error-envelope family on the
521        // sibling parser surfaces.
522        let value: &str = "not-a-version";
523        let reason: &str = "unexpected character 'n' while parsing major version number";
524        assert_eq!(
525            VersionError::semver(value, reason),
526            VersionError::Semver(value.to_string(), reason.to_string()),
527            "generated semver ctor over `&str` bindings must match \
528             the pre-lift tuple-literal wrap on the same fixture",
529        );
530    }
531
532    #[test]
533    fn semver_ctor_matches_tuple_literal_wrap_on_string_binding() {
534        // Fail-before-pass-after byte-equality pin on the paired owned-
535        // `String` shape — the actual wire-up shape on
536        // [`CaixaVersion::parse`] (`self.0.clone()` +
537        // `e.to_string()`). Peer to the `&str` variant above; refuses
538        // any future de-lift that inlines a divergent construction on
539        // the owned-`String` path (a stray `.trim().to_string()`
540        // normalization on either slot, a swap that routes the ctor
541        // through the sibling [`VersionError::Requirement`] variant on
542        // the paired parser surface).
543        let value: String = String::from("1.2");
544        let reason: String =
545            String::from("unexpected end of input while parsing minor version number");
546        assert_eq!(
547            VersionError::semver(value.clone(), reason.clone()),
548            VersionError::Semver(value, reason),
549            "generated semver ctor over owned-`String` bindings must \
550             match the pre-lift tuple-literal wrap on the same fixture",
551        );
552    }
553
554    #[test]
555    fn parse_semver_error_routes_through_semver_ctor() {
556        // Fail-before-pass-after routes-through pin: refuses any future
557        // de-lift of [`CaixaVersion::parse`]'s
558        // [`semver::Version::parse`] `map_err` arm off the substrate
559        // primitive. Sweeps three malformed authoring shapes (a bare
560        // non-numeric, a partial `major.minor` shape, a stray leading
561        // `v`-prefix that the [`DEFAULT_PUBLISH_TAG_PREFIX`] git-tag
562        // convention rejects at the version-body slot) through the
563        // parser and asserts the emitted [`VersionError`] equals the
564        // ctor-built error verbatim under `PartialEq`, so any future
565        // swap of the wire-up (an inline `Self::Semver(...)`
566        // re-inlining, a routing detour through the sibling
567        // [`VersionError::Requirement`] variant on the paired parser
568        // surface, a swap of the ordering on the paired arguments)
569        // trips at caixa-core test time rather than at a downstream
570        // diagnostic drift on a `feira lint` / operator admission
571        // callsite.
572        for bad in ["not-a-version", "1.2", "v0.1.0"] {
573            let v: CaixaVersion = bad.into();
574            let err = v
575                .parse()
576                .expect_err("malformed versao fixture must fail semver parsing");
577            let semver_reason = match semver::Version::parse(bad) {
578                Err(e) => e.to_string(),
579                Ok(_) => unreachable!(
580                    "fixture `{bad}` is documented as a `SemVer` \
581                     rejection but parsed cleanly — the pin's oracle \
582                     drifted from `semver`'s current shape",
583                ),
584            };
585            assert_eq!(
586                err,
587                VersionError::semver(bad, semver_reason),
588                "CaixaVersion::parse must route its semver `map_err` \
589                 arm through the lifted VersionError::semver ctor on \
590                 the same offending value and semver reason",
591            );
592        }
593    }
594
595    #[test]
596    fn default_git_remote_pins_canonical_origin_byte() {
597        // Bridge-arm pin: [`DEFAULT_GIT_REMOTE`] resolves to the
598        // canonical `"origin"` byte today, the same remote-handle every
599        // `git clone <url>` invocation populates by default and every
600        // peer `feira` writer-side verb (`feira publish`, `feira deploy
601        // --apply`, `feira app deploy --apply`) names when it invokes
602        // `git push <remote> <ref>` against the local clone. Pin the
603        // literal here (peer with the
604        // [`DEFAULT_PUBLISH_TAG_PREFIX`] / [`crate::DEFAULT_SERVICO_PORT`]
605        // / [`crate::DEFAULT_NAMESPACE`] / [`crate::DEFAULT_LIBRARY_NAME`]
606        // / [`crate::DEFAULT_FLUX_SYSTEM_NAMESPACE`] canonical-literal
607        // pins on the sibling lifted-constant surfaces) so a future
608        // remote-naming rebrand surfaces here as a coordinated edit-
609        // point: the sibling [`caixa-feira`]
610        // `publish_remote_default_pins_lifted_caixa_core_constant`
611        // pinning test already pins the equality at the clap-default
612        // axis; this pin closes the second coordinate of the
613        // triangle by anchoring the lifted constant's current byte
614        // to the canonical git-default-remote convention's documented
615        // shape.
616        assert_eq!(DEFAULT_GIT_REMOTE, "origin");
617    }
618
619    #[test]
620    fn default_pleme_git_org_pins_canonical_pleme_io_byte() {
621        // Bridge-arm pin: [`DEFAULT_PLEME_GIT_ORG`] resolves to the
622        // canonical `"pleme-io"` GitHub-org-handle today, the same org
623        // name every peer substrate-side default-git-source consumer
624        // ([`caixa-feira`]'s `feira lock` `resolve_stub` for the
625        // per-dep `:fonte`-elided `github:<org>/<nome>` fallback,
626        // [`caixa-flux`]'s `ClusterBundleOpts::for_caixa` constructor
627        // for the per-caixa `:repositorio`-elided
628        // `https://github.com/<org>/<nome>` fallback) fills into its
629        // per-consumer render/resolve compose site. Pin the literal
630        // here (peer with the [`DEFAULT_PUBLISH_TAG_PREFIX`] /
631        // [`DEFAULT_GIT_REMOTE`] canonical-literal pins on the sibling
632        // lifted-constant surfaces) so a future substrate-side git-org
633        // migration surfaces here as a coordinated edit-point: both
634        // sibling consumer sites already thread through the same
635        // `&'static str`, this pin anchors the lifted constant's
636        // current byte to the canonical substrate-git-org convention's
637        // documented shape.
638        assert_eq!(DEFAULT_PLEME_GIT_ORG, "pleme-io");
639    }
640
641    #[test]
642    fn requirement_ctor_matches_tuple_literal_wrap_on_str_binding() {
643        // Fail-before-pass-after byte-equality pin: the lifted
644        // [`VersionError::requirement`] inherent ctor projects a `&str`
645        // binding pair through the paired `impl Into<String>` bounds
646        // byte-equal to the pre-lift open-coded
647        // `VersionError::Requirement(<into-String-expr>, <into-String-expr>)`
648        // tuple-literal on the same fixture. Same shape the peer
649        // [`VersionError::semver_ctor_matches_tuple_literal_wrap_on_str_binding`]
650        // pin carries on the sibling [`VersionError::Semver`] variant of
651        // the same `(String, String)` two-slot tuple-newtype envelope.
652        let value: &str = "not-a-req";
653        let reason: &str = "unexpected character 'n' while parsing major version number";
654        assert_eq!(
655            VersionError::requirement(value, reason),
656            VersionError::Requirement(value.to_string(), reason.to_string()),
657            "generated requirement ctor over `&str` bindings must match \
658             the pre-lift tuple-literal wrap on the same fixture",
659        );
660    }
661
662    #[test]
663    fn requirement_ctor_matches_tuple_literal_wrap_on_string_binding() {
664        // Fail-before-pass-after byte-equality pin on the paired owned-
665        // `String` shape. Peer to the `&str` variant above; refuses any
666        // future de-lift that inlines a divergent construction on the
667        // owned-`String` path (a stray `.trim().to_string()` normalization
668        // on either slot, a swap that routes the ctor through the sibling
669        // [`VersionError::Semver`] variant on the paired parser surface,
670        // an argument-ordering swap on the paired slots).
671        let value: String = String::from("^bogus");
672        let reason: String = String::from("unexpected character while parsing requirement");
673        assert_eq!(
674            VersionError::requirement(value.clone(), reason.clone()),
675            VersionError::Requirement(value, reason),
676            "generated requirement ctor over owned-`String` bindings must \
677             match the pre-lift tuple-literal wrap on the same fixture",
678        );
679    }
680
681    #[test]
682    fn parse_requirement_error_routes_through_requirement_ctor() {
683        // Fail-before-pass-after routes-through pin: refuses any future
684        // de-lift of [`parse_requirement`]'s
685        // [`semver::VersionReq::parse`] `map_err` arm off the substrate
686        // primitive. Sweeps three malformed authoring shapes (a bare
687        // non-numeric, a stray operator with no version body, a
688        // caret-prefixed non-numeric that the [`semver::VersionReq`]
689        // grammar rejects at the operator-body slot) through the parser
690        // and asserts the emitted [`VersionError`] equals the ctor-built
691        // error verbatim under `PartialEq`, so any future swap of the
692        // wire-up (an inline `Self::Requirement(...)` re-inlining, a
693        // routing detour through the sibling [`VersionError::Semver`]
694        // variant on the paired parser surface, an argument-ordering
695        // swap on the paired slots) trips at caixa-core test time rather
696        // than at a downstream diagnostic drift on a `feira lock` /
697        // resolver admission callsite. The `"*"` wildcard short-circuit
698        // is deliberately excluded from the sweep — it returns
699        // [`semver::VersionReq::STAR`] before reaching the parser arm.
700        for bad in ["not-a-req", "^", "^bogus"] {
701            let err = parse_requirement(bad)
702                .expect_err("malformed requirement fixture must fail parsing");
703            let semver_reason = match semver::VersionReq::parse(bad) {
704                Err(e) => e.to_string(),
705                Ok(_) => unreachable!(
706                    "fixture `{bad}` is documented as a `VersionReq` \
707                     rejection but parsed cleanly — the pin's oracle \
708                     drifted from `semver`'s current shape",
709                ),
710            };
711            assert_eq!(
712                err,
713                VersionError::requirement(bad, semver_reason),
714                "parse_requirement must route its `map_err` arm through \
715                 the lifted VersionError::requirement ctor on the same \
716                 offending value and semver reason",
717            );
718        }
719    }
720
721    #[test]
722    fn default_publish_tag_prefix_pins_canonical_v_byte() {
723        // Bridge-arm pin: [`DEFAULT_PUBLISH_TAG_PREFIX`] resolves to the
724        // canonical Zig-style `"v"` byte today, the same prefix every
725        // peer doc-comment on the typed `:versao` surfaces (the
726        // top-level `:versao` `validate_versao` cascade at
727        // caixa-core/src/manifest.rs:646, the four sibling per-axis
728        // `:versao` requirement gates that name the publish-side
729        // `v<versao>` tag inline in their bodies) cites as the
730        // canonical convention. Pin the literal here (peer with the
731        // [`crate::DEFAULT_SERVICO_PORT`] / [`crate::DEFAULT_NAMESPACE`]
732        // / [`crate::DEFAULT_LIBRARY_NAME`] canonical-literal pins on
733        // the sibling lifted-constant surfaces) so a future rebrand of
734        // the constant surfaces here as a coordinated edit-point: both
735        // sibling pinning tests on the two consumer crates
736        // ([`caixa-feira`] `publish_prefix_default_pins_lifted_caixa_core_constant`,
737        // [`caixa-flux`] `cluster_bundle_default_git_tag_uses_lifted_caixa_core_prefix`)
738        // already pin the equality at the consumer-default axis; this
739        // pin closes the third coordinate of the triangle by anchoring
740        // the lifted constant's current byte to the canonical Zig-style
741        // convention's documented shape.
742        assert_eq!(DEFAULT_PUBLISH_TAG_PREFIX, "v");
743    }
744
745    #[test]
746    fn caixa_version_as_ref_str_routes_through_as_str_accessor() {
747        // Fail-before-pass-after byte-parity pin on the lifted
748        // `impl AsRef<str> for CaixaVersion` — asserts the standard-
749        // library trait impl and the substrate-primitive
750        // [`CaixaVersion::as_str`] `pub const fn` accessor resolve to
751        // the same `&str` per instance, so any future silent detour
752        // that routes the impl through a divergent projection (a
753        // `Cow<'_, str>` intermediate, a stray `.to_lowercase()`
754        // normalization, a swap onto a per-arm inline `&self.0.as_str()`
755        // re-inlining, a swap onto a divergent [`String::trim`]
756        // fold) trips at caixa-core test time under `PartialEq`
757        // rather than at a downstream `impl AsRef<str>`-bound
758        // consumer's silent split. Sweeps four authoring shapes (a
759        // canonical release version, a pre-release build-metadata
760        // version, the zero-version canonical unset baseline, and
761        // the empty-string byte the caller-side default-construct
762        // path composes) so every non-degenerate arm of the wrapped
763        // `String` storage is covered. Peer of the sibling
764        // [`caixa_version_as_str_accessor_is_const_fn`] const-eval
765        // pin on the same [`CaixaVersion::as_str`] primitive — the
766        // two pins together cover the const-eval axis (the pin above)
767        // and the trait-projection axis (this pin) of the same
768        // substrate-primitive scalar accessor.
769        for versao in ["0.1.0", "1.2.3-alpha.1", "0.0.0", ""] {
770            let v: CaixaVersion = versao.into();
771            assert_eq!(
772                <CaixaVersion as AsRef<str>>::as_ref(&v),
773                v.as_str(),
774                "AsRef<str> impl must byte-equal CaixaVersion::as_str \
775                 on the same instance — divergence signals a silent \
776                 detour off the substrate-primitive accessor",
777            );
778            assert_eq!(
779                <CaixaVersion as AsRef<str>>::as_ref(&v),
780                versao,
781                "AsRef<str> impl must byte-equal the pre-lift wrapped \
782                 String storage on round-trip through the From<&str> \
783                 constructor — divergence signals a normalization \
784                 detour on either the constructor or the accessor",
785            );
786        }
787    }
788
789    #[test]
790    fn caixa_version_as_ref_str_routes_through_display_via_shared_accessor() {
791        // Fail-before-pass-after byte-parity pin on the three-path
792        // convergence discipline the substrate primitive now carries
793        // on the `&str`-projection axis: `<CaixaVersion as
794        // AsRef<str>>::as_ref(&v)` (the newly lifted impl),
795        // `format!("{v}")` (the pre-existing [`fmt::Display`] impl),
796        // and `v.as_str()` (the substrate-primitive `pub const fn`
797        // accessor both trait impls delegate through) must resolve to
798        // the same byte-string on every instance. Refuses any future
799        // divergence between the two trait impls (a stray
800        // [`fmt::Display::fmt`] rewrite that inlines
801        // `f.write_str(&self.0)` on the wrapped `String` directly,
802        // bypassing the shared accessor; a hypothetical `AsRef<str>`
803        // rewrite that inlines the same `&self.0` field-access) that
804        // would silently split the two projection paths of the same
805        // typed newtype. Mirrors the sibling three-path-convergence
806        // discipline the peer [`RestartStrategy`] typed enum carries
807        // on its `Display` / `as_str` / `Serialize` triple (aplicacao.rs
808        // pin `restart_strategy_display_matches_serialized_wire_byte_string`).
809        for versao in ["0.1.0", "1.2.3-alpha.1", ""] {
810            let v: CaixaVersion = versao.into();
811            let via_as_ref: &str = <CaixaVersion as AsRef<str>>::as_ref(&v);
812            let via_display: String = format!("{v}");
813            let via_accessor: &str = v.as_str();
814            assert_eq!(via_as_ref, via_accessor);
815            assert_eq!(via_display, via_accessor);
816            assert_eq!(via_as_ref, via_display.as_str());
817        }
818    }
819}