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