crates/cli/ents-web/src/error.rs
error.rshistorycomment on this file
| 1 | //! `ents-web`'s error type: every failure a page handler can hit, rendered |
| 2 | //! as an HTTP response by rendered per-page (via the `IntoResponse` impl below) rather than at the |
| 3 | //! type itself — a web frontend renders failures as pages/status codes, not |
| 4 | //! terminal text, so this module stays data-only (mirrors `git-ents`'s own |
| 5 | //! `error.rs` shape, one variant per failure source). |
| 6 | |
| 7 | /// Every way a page handler in this crate can fail. |
| 8 | #[derive(Debug, thiserror::Error)] |
| 9 | pub enum Error { |
| 10 | /// The named entity does not exist. |
| 11 | #[error("not found: {what}")] |
| 12 | NotFound { |
| 13 | /// What was being looked up. |
| 14 | what: String, |
| 15 | }, |
| 16 | |
| 17 | /// A malformed request: a bad line-range, an unparsable object id, a |
| 18 | /// missing required form field. |
| 19 | #[error("invalid request: {0}")] |
| 20 | InvalidArgument(String), |
| 21 | |
| 22 | /// The gate refused the proposed mutation (`gate.verdict-reason`). |
| 23 | #[error("rejected: {0}")] |
| 24 | Refused(String), |
| 25 | |
| 26 | /// `receive` rejected the batch as a stale compare-and-swap. |
| 27 | #[error("rejected: {name} changed concurrently, retry")] |
| 28 | Stale { |
| 29 | /// The ref whose precondition was stale. |
| 30 | name: String, |
| 31 | }, |
| 32 | |
| 33 | /// A previously redacted object would have been refilled by this |
| 34 | /// mutation (`receive.redaction-ingest`). |
| 35 | #[error("refused: object {oid} was redacted and cannot be refilled")] |
| 36 | Redacted { |
| 37 | /// The redacted object id. |
| 38 | oid: gix_hash::ObjectId, |
| 39 | }, |
| 40 | |
| 41 | /// The request's CSRF token was missing or did not match the session's |
| 42 | /// (`roots.web-session`). |
| 43 | #[error("invalid or missing CSRF token")] |
| 44 | BadCsrf, |
| 45 | |
| 46 | /// No session cookie was presented, or it named a session this server |
| 47 | /// no longer holds in memory (`roots.web-session`): the process |
| 48 | /// restarted, or the cookie is forged. |
| 49 | #[error("no valid session")] |
| 50 | NoSession, |
| 51 | |
| 52 | /// A `gix-ref-store` failure: reading or writing a ref. |
| 53 | #[error(transparent)] |
| 54 | Refs(#[from] gix_ref_store::Error), |
| 55 | |
| 56 | /// An `ents-model` failure: building or validating a refname or typed |
| 57 | /// tree. |
| 58 | #[error(transparent)] |
| 59 | Model(#[from] ents_model::Error), |
| 60 | |
| 61 | /// A `facet-git-tree` (de)serialization failure. |
| 62 | #[error(transparent)] |
| 63 | Tree(#[from] facet_git_tree::Error), |
| 64 | |
| 65 | /// An `ents-anchor` failure: capturing or projecting a code anchor. |
| 66 | #[error(transparent)] |
| 67 | Anchor(#[from] ents_anchor::Error), |
| 68 | |
| 69 | /// An `ents-forge` failure: anchoring, serializing, or proposing a |
| 70 | /// comment mutation. Boxed: `ents_forge::Error` is large enough on its |
| 71 | /// own to trip `clippy::result_large_err` if stored inline (mirrors |
| 72 | /// `git-ents::error::Error::Forge`'s identical boxing). |
| 73 | #[error(transparent)] |
| 74 | Forge(Box<ents_forge::Error>), |
| 75 | |
| 76 | /// An `ents-effect` failure: toolchain resolution or import (the error |
| 77 | /// type `ents-kiln`'s own toolchain module reuses as-is, per that |
| 78 | /// crate's own doc). Boxed; see [`Error::Forge`]'s own doc. |
| 79 | #[error(transparent)] |
| 80 | Effect(Box<ents_effect::Error>), |
| 81 | |
| 82 | /// An `ents-receive` failure: `receive` itself could not reach an |
| 83 | /// outcome. Boxed; see [`Error::Forge`]'s own doc. |
| 84 | #[error(transparent)] |
| 85 | Receive(Box<ents_receive::Error>), |
| 86 | |
| 87 | /// `crate::asciidoc::to_html` could not parse or convert an AsciiDoc |
| 88 | /// blob (`acdc` reported no more specific error than "could not |
| 89 | /// convert"). |
| 90 | #[error("could not render asciidoc: {0}")] |
| 91 | Asciidoc(String), |
| 92 | |
| 93 | /// `crate::pages::files` could not open the served repository or read |
| 94 | /// its `HEAD` tree/a tree or blob within it (`gix::open`, a tree |
| 95 | /// lookup, or a blob read). |
| 96 | #[error("could not read repository: {0}")] |
| 97 | Repo(String), |
| 98 | } |
| 99 | |
| 100 | impl From<ents_forge::Error> for Error { |
| 101 | fn from(source: ents_forge::Error) -> Self { |
| 102 | Self::Forge(Box::new(source)) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | impl From<ents_effect::Error> for Error { |
| 107 | fn from(source: ents_effect::Error) -> Self { |
| 108 | Self::Effect(Box::new(source)) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | impl From<ents_receive::Error> for Error { |
| 113 | fn from(source: ents_receive::Error) -> Self { |
| 114 | Self::Receive(Box::new(source)) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// Translate a reached [`ents_receive::Outcome`] into `Ok(())` on success or |
| 119 | /// an [`Error`] otherwise — this crate's counterpart to |
| 120 | /// `git_ents::mutate::outcome_to_result`, kept as a free function here for |
| 121 | /// exactly the same reason: every page that proposes a mutation renders a |
| 122 | /// refusal identically. |
| 123 | /// |
| 124 | /// # Errors |
| 125 | /// |
| 126 | /// [`Error::Refused`], [`Error::Stale`], or [`Error::Redacted`]; see |
| 127 | /// `git_ents::mutate::outcome_to_result` for the identical mapping this |
| 128 | /// mirrors. |
| 129 | pub fn outcome_to_result(outcome: ents_receive::Outcome) -> Result<()> { |
| 130 | match outcome.result { |
| 131 | ents_receive::TxResult::Applied => Ok(()), |
| 132 | ents_receive::TxResult::Refused => { |
| 133 | let reasons = outcome |
| 134 | .verdicts |
| 135 | .iter() |
| 136 | .filter_map(|(_, verdict)| match verdict { |
| 137 | ents_gate::Verdict::Fail(refusal) => Some(refusal.to_string()), |
| 138 | ents_gate::Verdict::Pass(_) => None, |
| 139 | }) |
| 140 | .collect::<Vec<_>>() |
| 141 | .join("; "); |
| 142 | Err(Error::Refused(reasons)) |
| 143 | } |
| 144 | ents_receive::TxResult::Rejected { name } => Err(Error::Stale { |
| 145 | name: name.as_bstr().to_string(), |
| 146 | }), |
| 147 | ents_receive::TxResult::Redacted { oid } => Err(Error::Redacted { oid }), |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | /// This crate's `Result` alias. |
| 152 | pub type Result<T> = std::result::Result<T, Error>; |
| 153 | |
| 154 | impl axum::response::IntoResponse for Error { |
| 155 | fn into_response(self) -> axum::response::Response { |
| 156 | use axum::http::StatusCode; |
| 157 | |
| 158 | let status = match &self { |
| 159 | Error::NotFound { .. } => StatusCode::NOT_FOUND, |
| 160 | // A forge entity with no ref at all is as much a 404 as this |
| 161 | // crate's own NotFound -- the box exists for variant-size |
| 162 | // hygiene, not to demote the status to a 500. |
| 163 | Error::Forge(inner) if matches!(inner.as_ref(), ents_forge::Error::NotFound { .. }) => { |
| 164 | StatusCode::NOT_FOUND |
| 165 | } |
| 166 | Error::InvalidArgument(_) | Error::BadCsrf => StatusCode::BAD_REQUEST, |
| 167 | Error::NoSession => StatusCode::UNAUTHORIZED, |
| 168 | Error::Refused(_) | Error::Stale { .. } | Error::Redacted { .. } => { |
| 169 | StatusCode::CONFLICT |
| 170 | } |
| 171 | _ => StatusCode::INTERNAL_SERVER_ERROR, |
| 172 | }; |
| 173 | let reason = status.canonical_reason().unwrap_or("Error"); |
| 174 | let body = maud::html! { |
| 175 | (maud::DOCTYPE) |
| 176 | html lang="en" { |
| 177 | head { |
| 178 | meta charset="utf-8"; |
| 179 | meta name="viewport" content="width=device-width, initial-scale=1"; |
| 180 | meta name="color-scheme" content="light dark"; |
| 181 | title { "git ents: " (status.as_u16()) " " (reason) } |
| 182 | link rel="stylesheet" href="/style.css"; |
| 183 | } |
| 184 | body { |
| 185 | main.content { |
| 186 | div.page-header { h1.page-title { (status.as_u16()) " " (reason) } } |
| 187 | div.blankslate { h2 { (reason) } p { (self.to_string()) } } |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | }; |
| 192 | (status, body).into_response() |
| 193 | } |
| 194 | } |