onevcs/error.rs
1//! The one error type every fallible entry point in this crate returns.
2//!
3//! The variants are the failures `docs/contract.md` gives `onevcs publish` a
4//! distinct exit code for, plus the refusal an interface-only build owes a
5//! caller. Marked `#[non_exhaustive]` so a later seam can add its own failure
6//! without breaking a matching consumer.
7
8use thiserror::Error;
9
10/// Everything an `onevcs` operation can fail with.
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum Error {
14 /// The request parsed and validated, but the seam behind it has no
15 /// implementation yet. The CLI reports this as exit code 70.
16 #[error(
17 "{operation} is not implemented yet: this build of onevcs is interface-only \
18 (the approved contract is in docs/contract.md)"
19 )]
20 NotImplemented {
21 /// The contract operation that was asked for, e.g. `Vcs::open_session`.
22 operation: &'static str,
23 },
24
25 /// A publication was refused by something that judged it, where no narrower
26 /// kind says which. Today that is the repository's own `commit-msg` hook turning
27 /// down the subject the publication would land under, and a host that took a
28 /// merge and then reported it unperformed. The CLI reports this as exit code 1.
29 ///
30 /// What the *host's checks* reported is [`Error::ChecksFailed`] or
31 /// [`Error::ChecksUnsettled`], and what its `pre-push` hook said is
32 /// [`Error::PushRejected`].
33 ///
34 /// Named for the tier this crate used to run itself and kept under that name:
35 /// the contract fixes this vocabulary across the three libraries that route on
36 /// it, so a variant is not renamed because the tier behind it went away.
37 // llmlint: ignore[names_match_behavior] the approved contract fixes the spelling.
38 #[error("gate failed: {reason}")]
39 GateFailed {
40 /// What failed, and what turned it down.
41 reason: String,
42 },
43
44 /// Input was rejected at a trust boundary — a malformed registry document, a
45 /// rules file that does not parse, an unusable argument. The CLI reports this
46 /// as exit code 2.
47 #[error("invalid input: {reason}")]
48 Invalid {
49 /// What was rejected and why.
50 reason: String,
51 },
52
53 /// The base moved under a publication and the bounded resolve-and-requeue did
54 /// not converge. The CLI reports this as exit code 3.
55 #[error("sync conflict: {reason}")]
56 SyncConflict {
57 /// The conflict that survived the bounded retry.
58 reason: String,
59 },
60
61 /// A required check the host reports concluded red. The CLI reports this as
62 /// exit code 1, which is the code the contract fixes for a verification
63 /// failure — this says *which* verification failed, not a different one.
64 #[error("required check failed: {reason}")]
65 ChecksFailed {
66 /// The check that concluded red, named, and a bounded excerpt of its log.
67 reason: String,
68 },
69
70 /// The bound on watching the host elapsed with the change still outstanding.
71 /// The CLI reports this as exit code 1.
72 ///
73 /// Named for the commonest way that happens and shared with the others,
74 /// because the failure vocabulary is fixed across the three libraries that
75 /// route on it. Distinct from [`Error::ChecksFailed`] because the operator's
76 /// next move differs: nothing has failed, and the publication stopped watching.
77 // The vocabulary is fixed across the three libraries that route on it, and gives
78 // the bound one kind whichever of its endings this was; `reason` says which.
79 #[error("checks unsettled: {reason}")]
80 // llmlint: ignore[names_match_behavior] one fixed kind for the bound's three endings.
81 ChecksUnsettled {
82 /// What was still outstanding when the bound elapsed: the required checks
83 /// that had not settled, that the host declared none at all, or — where
84 /// every one of them had settled — that it never performed the merge.
85 reason: String,
86 },
87
88 /// The publishing push was refused by the merge path. The CLI reports this as
89 /// exit code 1.
90 #[error("push rejected: {reason}")]
91 PushRejected {
92 /// git's own per-ref refusal, which is what an operator acts on.
93 reason: String,
94 },
95
96 /// The publishing push was refused by the merge path because this host is
97 /// missing a tool or a credential one of its hooks needs — a failure that does not
98 /// depend on the tree being pushed. The CLI reports this as exit code 1.
99 ///
100 /// Said by the hook itself, on the one line
101 /// [`HOST_PREREQUISITE_MARKER`](crate::HOST_PREREQUISITE_MARKER) begins, and never
102 /// inferred: a refused push carrying no such line is [`Error::PushRejected`]. A
103 /// kind of its own for the reason [`Error::PushedUnverified`] is one: a router
104 /// branches on the kind, and no change to the work can clear this refusal.
105 #[error("host prerequisite missing: {reason}")]
106 // llmlint: ignore[invalid_states_unrepresentable] the approved contract fixes every
107 // variant of this enum as one `reason: String`, and three libraries route on that
108 // shape; the remediation and the evidence pointers are both prose for a reader.
109 HostPrerequisite {
110 /// What the hook said is missing and how to install it, and where the whole
111 /// of what the merge path wrote is kept.
112 reason: String,
113 },
114
115 /// The publishing push **reached the remote**, and the merge path could not
116 /// then be read. The CLI reports this as exit code 1.
117 ///
118 /// A kind of its own rather than a reason clause on the three above, because a
119 /// router branches on the *kind*: a sentence added to [`Error::ChecksFailed`]
120 /// still routes as "the checks said no". Widening a vocabulary fixed across the
121 /// libraries that route on it is the amendment in `docs/contract.md`, which
122 /// records why.
123 #[error("pushed, merge path unverified: {reason}")]
124 // llmlint: ignore[invalid_states_unrepresentable] the approved contract fixes every
125 // variant of this enum as one `reason: String`, and three libraries route on that
126 // shape. Splitting this one into a landing field and a cause field would add public
127 // surface the contract does not name and leave one variant unlike its siblings.
128 PushedUnverified {
129 /// Where the push landed, and what stopped the merge path being read.
130 reason: String,
131 },
132
133 /// A session could not be placed: every slot of the identity's pool is held and
134 /// its overflow admits no more. `onevcs session open` reports this as exit code 4,
135 /// and nothing waits — a caller decides whether to queue, close something, or open
136 /// with `--overflow unlimited`.
137 ///
138 /// Never a publication failure, so [`FailureKind`](crate::FailureKind) — the
139 /// vocabulary fixed across the three libraries that route a publication's outcome
140 /// — has no kind for it and reports it as `Invalid` should one ever reach there.
141 #[error("pool exhausted: {reason}")]
142 // llmlint: ignore[invalid_states_unrepresentable] one `reason: String` like every
143 // sibling variant, which is the shape three libraries route on; the identity, the
144 // limits with their sources, and every holder are prose for the operator who reads
145 // the refusal, and `workspace_capacity` is the typed form of the same answer.
146 PoolExhausted {
147 /// The identity, its pool and overflow with where each limit came from, and
148 /// every holder: token, branch, worktree, owner pid and liveness.
149 reason: String,
150 },
151
152 /// A reference named no work this host knows — it is not a change request
153 /// `onevcs` opened, a session token it printed, a branch any checkout or run
154 /// clone of a registered identity holds, or a commit one of those branches
155 /// carries. The CLI reports this as exit code 2.
156 ///
157 /// A kind of its own for the reason [`Error::PushedUnverified`] is one: a caller
158 /// branches on the *kind*. A consumer that falls back from one spelling of a
159 /// reference to another — onepipeline's release asker, from a squash commit to
160 /// the change request it settled — has to fire on exactly this case and never on
161 /// an I/O failure or a host that refused, and telling them apart by matching the
162 /// reason's prose breaks silently the day the wording changes.
163 ///
164 /// It says nothing new to whoever reads the refusal, so it renders and exits
165 /// exactly as [`Error::Invalid`] does, down to the `invalid input:` prefix: the
166 /// typed answer is for the router, and a rewording of the CLI's output would be
167 /// the breaking change this exists to prevent. [`FailureKind`](crate::FailureKind)
168 /// — the vocabulary fixed across the three libraries that route a publication's
169 /// outcome — gains no kind for it and reports it as `Invalid`, which is what fixes
170 /// the exit code at 2.
171 // llmlint: ignore[names_match_behavior] the wording of the refusal an operator
172 // reads is unchanged on purpose, so this renders under `Invalid`'s prefix rather
173 // than its own; the variant name is what the router matches on.
174 #[error("invalid input: {reason}")]
175 UnresolvableReference {
176 /// The reference as it was asked about, so a caller that routes on this kind
177 /// names what it could not resolve without parsing the reason.
178 reference: String,
179 /// What the reference was searched for as, and where the spellings that do
180 /// answer are listed.
181 reason: String,
182 },
183}
184
185/// The result type every fallible entry point in this crate returns.
186pub type Result<T> = std::result::Result<T, Error>;
187
188/// Reject something, naming what was wrong with it.
189pub(crate) fn invalid(reason: impl Into<String>) -> Error {
190 Error::Invalid {
191 reason: reason.into(),
192 }
193}
194
195/// An operation on a path that the filesystem refused, as the rejection a caller
196/// reads: what was being done, where, and what the system said.
197///
198/// One helper rather than a closure at each site, so the message every one of them
199/// produces has the same shape — and so a path that cannot be written reads the
200/// same whether it was the registry, a session record, or an artifact.
201pub(crate) fn at<'a, E: std::fmt::Display>(
202 action: &'static str,
203 path: &'a std::path::Path,
204) -> impl FnOnce(E) -> Error + 'a {
205 move |error| invalid(format!("cannot {action} {}: {error}", path.display()))
206}