Skip to main content

vgi_forge/
error.rs

1//! The one error type every adapter returns.
2//!
3//! Variants describe what the *core* has to decide — retry, give up, ask a
4//! human, re-bind — rather than which HTTP status a forge happened to use, so
5//! the projector can act on a Forgejo failure and a GitHub failure the same
6//! way. Adapters keep the forge's own message in the payload for the audit log.
7
8use std::fmt;
9
10use vgi_core::ResourceError;
11
12/// Shorthand for adapter results.
13pub type Result<T, E = ForgeError> = std::result::Result<T, E>;
14
15/// A failed forge operation.
16#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum ForgeError {
19    /// The input is not a valid forge-qualified resource.
20    InvalidResource(ResourceError),
21    /// The resource is valid but not on this adapter's forge, or not the
22    /// shape the operation needs (a namespace where a repo was expected).
23    WrongResource {
24        /// What was given.
25        resource: String,
26        /// What the operation expected, in words.
27        expected: String,
28    },
29    /// No namespace binding covers this resource, so the adapter holds no
30    /// credential for it.
31    NotBound {
32        /// The namespace (`host/owner`) that has no binding.
33        namespace: String,
34    },
35    /// The forge (or this namespace on it) cannot do this — see
36    /// [`crate::Capabilities`]. `hint` says what a human can do instead.
37    Unsupported {
38        /// The operation that was refused.
39        operation: String,
40        /// Why, and what to do instead.
41        hint: String,
42    },
43    /// The resource does not exist, or the credential cannot see it (forges
44    /// deliberately do not distinguish the two).
45    NotFound {
46        /// What was looked up.
47        what: String,
48    },
49    /// Refused to create something that already exists. Carries the forge id
50    /// so the core can tell its own earlier attempt from a squatter.
51    AlreadyExists {
52        /// The resource that exists.
53        resource: String,
54        /// The forge's numeric id for it, when known.
55        forge_id: Option<u64>,
56    },
57    /// The forge answered with a redirect the adapter will not follow — a
58    /// renamed or transferred repository, usually.
59    Moved {
60        /// What was requested.
61        what: String,
62        /// Where the forge pointed.
63        location: String,
64    },
65    /// The forge rejected the adapter's credentials (expired, revoked, wrong
66    /// key). Re-binding or rotating the key is the fix, not a retry.
67    Unauthorized(String),
68    /// Authenticated, but not permitted — usually a permission the owner has
69    /// not approved on the installation.
70    Forbidden(String),
71    /// The forge refused the request as invalid or conflicting (a rule
72    /// violation, a validation failure).
73    Rejected {
74        /// The forge's status code.
75        status: u16,
76        /// The forge's message.
77        message: String,
78    },
79    /// Rate-limited. Retry after the given number of seconds, if the forge
80    /// said.
81    RateLimited {
82        /// Seconds to wait, when the forge said.
83        retry_after_secs: Option<u64>,
84    },
85    /// The forge could not be reached or failed (network, 5xx, timeout).
86    Unavailable(String),
87    /// A namespace bind callback did not match the bind that was started —
88    /// wrong or stale `state`, or an installation on the wrong owner.
89    BindRejected(String),
90    /// Linking a member's forge account did not complete (expired, denied).
91    LinkFailed(String),
92    /// A webhook failed verification or could not be parsed. Always treat as
93    /// hostile input: do not act on it.
94    Webhook(String),
95    /// The adapter or the bootstrap config is incomplete or invalid.
96    Config(String),
97    /// The forge answered with something the adapter does not understand.
98    Protocol(String),
99    /// A capability of the namespace turned out different from what was
100    /// planned with (a forge plan without a feature). The adapter has
101    /// updated its own copy; the caller persists the change and plans
102    /// again.
103    CapabilityChanged {
104        /// The namespace.
105        namespace: String,
106        /// Which [`crate::Capabilities`] field.
107        capability: String,
108        /// Its new value.
109        available: bool,
110        /// What the forge said.
111        reason: String,
112    },
113}
114
115impl ForgeError {
116    /// Whether retrying the same operation later can succeed without anyone
117    /// changing anything. Rejections, auth failures and bad input are not.
118    pub fn is_retryable(&self) -> bool {
119        matches!(
120            self,
121            ForgeError::RateLimited { .. } | ForgeError::Unavailable(_)
122        )
123    }
124}
125
126impl fmt::Display for ForgeError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            ForgeError::InvalidResource(e) => write!(f, "{e}"),
130            ForgeError::WrongResource { resource, expected } => {
131                write!(f, "resource `{resource}`: expected {expected}")
132            }
133            ForgeError::NotBound { namespace } => write!(
134                f,
135                "namespace `{namespace}` is not bound to this bridge; bind it before managing \
136                 its repositories"
137            ),
138            ForgeError::Unsupported { operation, hint } => {
139                write!(f, "{operation} is not supported here: {hint}")
140            }
141            ForgeError::NotFound { what } => write!(f, "{what}: not found (or not visible)"),
142            ForgeError::AlreadyExists { resource, forge_id } => match forge_id {
143                Some(id) => write!(f, "`{resource}` already exists (forge id {id})"),
144                None => write!(f, "`{resource}` already exists"),
145            },
146            ForgeError::Moved { what, location } => {
147                write!(
148                    f,
149                    "{what} has moved to {location} (renamed or transferred?)"
150                )
151            }
152            ForgeError::Unauthorized(m) => write!(f, "forge rejected the credentials: {m}"),
153            ForgeError::Forbidden(m) => write!(f, "forge refused the operation: {m}"),
154            ForgeError::Rejected { status, message } => {
155                write!(f, "forge rejected the request ({status}): {message}")
156            }
157            ForgeError::RateLimited { retry_after_secs } => match retry_after_secs {
158                Some(s) => write!(f, "rate-limited by the forge; retry in {s}s"),
159                None => write!(f, "rate-limited by the forge"),
160            },
161            ForgeError::Unavailable(m) => write!(f, "forge unavailable: {m}"),
162            ForgeError::BindRejected(m) => write!(f, "namespace bind rejected: {m}"),
163            ForgeError::LinkFailed(m) => write!(f, "account link failed: {m}"),
164            ForgeError::Webhook(m) => write!(f, "webhook rejected: {m}"),
165            ForgeError::Config(m) => write!(f, "configuration error: {m}"),
166            ForgeError::Protocol(m) => write!(f, "unexpected forge response: {m}"),
167            ForgeError::CapabilityChanged {
168                namespace,
169                capability,
170                available,
171                reason,
172            } => write!(
173                f,
174                "`{capability}` is now {available} for `{namespace}` ({reason}); plan again"
175            ),
176        }
177    }
178}
179
180impl std::error::Error for ForgeError {}
181
182impl From<ResourceError> for ForgeError {
183    fn from(e: ResourceError) -> Self {
184        ForgeError::InvalidResource(e)
185    }
186}