git-ents.gitmain
⌘K
foforge
commit 8d9418f
roots: degrade unreadable meta entities to a marker instead of a 500

refs/meta/toolchains/{rust,sccache,zig} in this very repository hold an older pre-redo schema (recipe stored as a tree, no name entry) that today’s ents_kiln::Toolchain no longer matches, so GET /toolchains and GET /toolchains/{name} surfaced facet-git-tree’s read failure as a 500 instead of a page. Every meta page family now catches a per-entity deserialize failure and renders a muted "unreadable — written by an older schema" marker (plus the underlying error on the show page) instead, leaving the rest of the listing and the id link intact.

roots: change render::list_table to take a per-row Result and mark unreadable rows roots: add render::unreadable for a show page’s marker card roots: apply the per-entity Result pattern to members/effects/redactions read_all roots: resolve every toolchain during GET /toolchains to mark unreadable entries roots: distinguish a missing toolchain ref (404) from an unreadable one (marker) on GET /toolchains/{name} Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/render.rs @@ -111,31 +111,51 @@ } /// A table listing `rows`, one row per `(id, entity)` pair, columns taken -/// from the entity's own reflected field names -- the generic "list" page -/// every kernel entity this crate exposes uses. +/// from the first successfully-read entity's own reflected field names -- +/// the generic "list" page every kernel entity this crate exposes uses. /// /// `id_header` names the leading column holding each entry's key (a /// username, an effect name, a redaction id -- whatever names the ref this /// listing was read from, which is never itself a field on the entity). /// +/// `entity` is `Err(detail)` for a ref whose stored tree this build's +/// `#[derive(Facet)]` shape could not read back (written by an older or +/// unrelated schema): that row still shows its id (linked, exactly like a +/// readable row, so its own show page -- which renders the same marker, +/// plus `detail` -- is reachable) and a single muted "unreadable" cell +/// spanning the rest of the row, rather than being dropped or failing the +/// whole list. This is this crate's graceful-degradation stance applied +/// per entity: a reader surfaces a marker, never an error, for one entry +/// this build can no longer speak the schema of. +/// /// # Examples /// /// ``` /// use ents_model::{Member, Provenance}; /// -/// let rows = vec![("jdc".to_owned(), Member::new("key-a", Provenance::AdminRegistered))]; -/// let markup = ents_web::render::list_table(&rows, "username", |id| format!("/members/{id}")); -/// assert!(markup.into_string().contains("jdc")); +/// let rows = vec![ +/// ("jdc".to_owned(), Ok(Member::new("key-a", Provenance::AdminRegistered))), +/// ("legacy".to_owned(), Err("object ... is not a blob".to_owned())), +/// ]; +/// let rendered = ents_web::render::list_table(&rows, "username", |id| format!("/members/{id}")).into_string(); +/// assert!(rendered.contains("jdc")); +/// assert!(rendered.contains("legacy")); +/// assert!(rendered.contains("unreadable")); /// ``` #[must_use] pub fn list_table<T: Facet<'static>>( - rows: &[(String, T)], + rows: &[(String, Result<T, String>)], id_header: &str, href_for: impl Fn(&str) -> String, ) -> Markup { let field_names: Vec<&'static str> = rows - .first() - .map(|(_, entity)| fields(entity).into_iter().map(|(name, _)| name).collect()) + .iter() + .find_map(|(_, entity)| { + entity + .as_ref() + .ok() + .map(|entity| fields(entity).into_iter().map(|(name, _)| name).collect()) + }) .unwrap_or_default(); html! { div.card { @@ -150,11 +170,19 @@ } tbody { @for (id, entity) in rows { - tr { - td { a href=(href_for(id)) { (id) } } - @for (_, rendered) in fields(entity) { - td { (rendered) } - } + @match entity { + Ok(entity) => tr { + td { a href=(href_for(id)) { (id) } } + @for (_, rendered) in fields(entity) { + td { (rendered) } + } + }, + Err(_) => tr.unreadable { + td { a href=(href_for(id)) { (id) } } + td colspan=(field_names.len().max(1).to_string()) { + "unreadable \u{2014} written by an older schema" + } + }, } } } @@ -163,6 +191,35 @@ } } +/// A muted marker card for one entity this crate could not reflect -- the +/// `GET /{family}/{id}` show-page counterpart to [`list_table`]'s per-row +/// marker: the same "unreadable" note, plus `detail` (the underlying +/// deserialization error) rendered verbatim in muted monospace, so an +/// operator can diagnose the schema mismatch without leaving the browser. +/// Never a 500 -- reading an older or unrelated schema's tree degrades to +/// this card, exactly as [`list_table`] degrades one row of a listing. +/// +/// # Examples +/// +/// ``` +/// let rendered = ents_web::render::unreadable("object ... is not a blob").into_string(); +/// assert!(rendered.contains("unreadable")); +/// assert!(rendered.contains("is not a blob")); +/// ``` +#[must_use] +pub fn unreadable(detail: &str) -> Markup { + html! { + div.card { + div.card-row.unreadable { + span { "unreadable \u{2014} written by an older schema" } + } + div.card-row { + code.unreadable-detail { (detail) } + } + } + } +} + /// A list of plain strings with no reflected entity behind them (inbox /// entries, toolchain names) -- deliberately not the [`fields`] mechanism, /// since there is no struct to reflect over, only a bare list of ids. @@ -250,14 +307,42 @@ #[rstest] // @relation(roots.web-agnostic, scope=function, role=Verifies) - fn list_table_derives_its_columns_from_the_first_rows_own_shape() { + fn list_table_derives_its_columns_from_the_first_readable_rows_own_shape() { let rows = vec![( "jdc".to_owned(), - Member::new("key", Provenance::AdminRegistered), + Ok(Member::new("key", Provenance::AdminRegistered)), )]; let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string(); assert!(markup.contains("username")); assert!(markup.contains("key")); assert!(markup.contains("jdc")); } + + #[rstest] + // @relation(roots.web-agnostic, scope=function, role=Verifies) + fn list_table_marks_an_unreadable_row_but_still_lists_a_readable_one() { + let rows = vec![ + ( + "jdc".to_owned(), + Ok(Member::new("key", Provenance::AdminRegistered)), + ), + ( + "legacy".to_owned(), + Err("object ... is not a blob".to_owned()), + ), + ]; + let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string(); + assert!(markup.contains("jdc")); + assert!(markup.contains("legacy")); + assert!(markup.contains("unreadable")); + assert!(markup.contains(r#"href="/members/legacy""#)); + } + + #[rstest] + // @relation(roots.web-agnostic, scope=function, role=Verifies) + fn unreadable_card_shows_the_underlying_error() { + let markup = unreadable("object deadbeef is not a blob").into_string(); + assert!(markup.contains("unreadable")); + assert!(markup.contains("object deadbeef is not a blob")); + } }
crates/cli/ents-web/tests/router.rs @@ -11,12 +11,17 @@ use axum::body::Body; use axum::http::{Request, StatusCode, header}; +use ents_kiln::Toolchain; use ents_model::{Account, MemberId}; use ents_receive::{Mode, NullEventSink}; -use ents_testutil::{Keypair, MemRefStore, ObjectStore}; +use ents_testutil::{ + CommitSpec, Keypair, MemRefStore, ObjectStore, write_commit, write_meta_entity, +}; use ents_web::identity::SigningIdentity; use ents_web::state::AppState; use gix::bstr::ByteSlice as _; +use gix_object::tree::{Entry, EntryKind}; +use gix_object::{Kind, Tree, Write as _}; use http_body_util::BodyExt as _; use tower::ServiceExt as _; @@ -79,6 +84,75 @@ )) } +/// Like [`build_state`], but `refs`/`objects` are already populated -- +/// what the toolchain-marker tests below use to seed a ref store directly +/// with plain `gix_object` writes (a wrong-shape tree no `ents-kiln` +/// helper would ever produce), rather than through a signed write path. +fn build_state_with( + identity: FixtureIdentity, + refs: MemRefStore, + objects: ObjectStore, +) -> Arc<AppState<ObjectStore>> { + Arc::new(AppState::new( + Box::new(refs), + objects, + Box::new(NullEventSink), + Mode::Advisory, + Box::new(identity), + std::env::temp_dir(), + )) +} + +/// Land a `refs/meta/toolchains/<name>` ref pointing at a tree shaped like +/// the pre-redo `git_toolchain::Bin` schema this repository's own +/// `refs/meta/toolchains/{rust,sccache,zig}` still carry: a `recipe` entry +/// that is itself a tree, not the blob today's `ents_kiln::Toolchain::recipe: +/// String` expects -- `facet_git_tree::deserialize` reads `recipe` as a +/// scalar (a blob) and fails with `NotABlob` on exactly this shape, the +/// same failure `git ents serve` hits reading this repository's own real +/// legacy toolchain refs (piece 1's bug report). Built from plain +/// `gix_object` writes, not `ents_kiln::toolchain::import` (which only ever +/// writes today's shape) or `write_meta_entity` (which only ever writes a +/// value that already round-trips through `facet_git_tree`). +fn write_legacy_toolchain(refs: &MemRefStore, objects: &ObjectStore, name: &str) { + let name_blob = objects + .write_buf(Kind::Blob, name.as_bytes()) + .expect("write"); + let recipe_tree = objects + .write(&Tree { + entries: Vec::new(), + }) + .expect("write"); + let mut entries = vec![ + Entry { + mode: EntryKind::Blob.into(), + filename: "name".into(), + oid: name_blob, + }, + Entry { + mode: EntryKind::Tree.into(), + filename: "recipe".into(), + oid: recipe_tree, + }, + ]; + entries.sort(); + let tree = objects.write(&Tree { entries }).expect("write"); + let tip = write_commit( + objects, + &CommitSpec { + tree, + parents: Vec::new(), + message: format!("legacy toolchain {name}"), + seconds: 100, + }, + None, + ); + let refname: gix::refs::FullName = format!("refs/meta/toolchains/{name}") + .try_into() + .expect("valid refname"); + refs.set(refname.as_ref(), tip); +} + /// Initialize a real git repository at a fresh tempdir, seed it with /// `files` (path, contents), and commit them on `HEAD` -- what /// `crate::pages::files`'s tests below browse. @@ -1286,3 +1360,97 @@ let body = String::from_utf8(body.to_vec()).expect("utf8 html"); assert!(body.contains("No matches")); } + +/// `GET /toolchains` lists a toolchain written by an older schema (piece +/// 1's bug: this repository's own `refs/meta/toolchains/{rust,sccache,zig}` +/// still carry it) as a muted marker row, never a 500 -- and a good +/// toolchain alongside it still lists and links normally. +#[tokio::test] +async fn toolchains_list_marks_a_legacy_entry_but_still_lists_a_good_one() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let name: gix::refs::FullName = "refs/meta/toolchains/good".try_into().expect("valid"); + write_meta_entity( + &refs, + &objects, + name, + &Toolchain { + name: "good".to_owned(), + recipe: "embedded 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n".to_owned(), + }, + None, + 100, + ); + write_legacy_toolchain(&refs, &objects, "legacy"); + + let state = build_state_with( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + refs, + objects, + ); + let router = ents_web::router(state); + + let response = router + .oneshot( + Request::get("/toolchains") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 html"); + assert!(body.contains(r#"href="/toolchains/good""#)); + assert!(body.contains(r#"href="/toolchains/legacy""#)); + assert!(body.contains("unreadable")); +} + +/// `GET /toolchains/{name}` on a legacy-schema entry renders the marker +/// card (with the underlying error) rather than a 500. +#[tokio::test] +async fn toolchain_show_on_a_legacy_entry_renders_a_marker_not_a_500() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + write_legacy_toolchain(&refs, &objects, "legacy"); + + let state = build_state_with( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + refs, + objects, + ); + let router = ents_web::router(state); + + let response = router + .oneshot( + Request::get("/toolchains/legacy") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 html"); + assert!(body.contains("unreadable")); + assert!( + body.contains("is not a blob"), + "the underlying facet-git-tree error renders verbatim: {body}" + ); +}
crates/cli/ents-web/src/assets/ents.css @@ -209,6 +209,23 @@ .string-list a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } .string-list a:hover { color: var(--color-accent); } +/* A per-entity read failure (an older or unrelated schema's tree shape), + * on a list row (`entity-list`/`string-list`) or a show page's own card + * (`crate::render::unreadable`) -- graceful degradation, never a 500. */ +.entity-list tr.unreadable td, .string-list .unreadable, .card-row.unreadable span { + color: var(--color-text-muted); + font-style: italic; +} +.unreadable-detail { + display: block; + margin-top: .2rem; + color: var(--color-text-muted); + font-style: normal; + font-family: var(--font-mono); + font-size: .78rem; + word-break: break-all; +} + /* Bare `dl`/`ul`/`form` markup a custom page (toolchains, comments) emits * directly, styled the same as the generic views above so a page needs no * per-page CSS hook to look consistent. */
crates/cli/ents-web/src/pages/effects.rs @@ -40,7 +40,12 @@ /// /// # Errors /// -/// [`Error::NotFound`] if `name` has no effect ref. +/// [`Error::NotFound`] if `name` has no effect ref at all -- an effect ref +/// that exists but whose stored tree does not match this build's +/// [`Effect`] shape degrades to [`crate::render::unreadable`] instead +/// (`roots.web-agnostic`'s graceful-degradation stance); the trigger-query +/// parse check is skipped in that case, since there is no [`Effect`] to +/// check. pub async fn show<O>( State(state): State<Arc<AppState<O>>>, Path(name): Path<String>, @@ -54,23 +59,35 @@ .ok_or_else(|| Error::NotFound { what: format!("effect {name}"), })?; - let query_status = match effect.trigger.parse::<Query>() { - Ok(_) => "parses".to_owned(), - Err(error) => format!("does not parse: {error}"), + let body = match effect { + Ok(effect) => { + let query_status = match effect.trigger.parse::<Query>() { + Ok(_) => "parses".to_owned(), + Err(error) => format!("does not parse: {error}"), + }; + html! { + (crate::render::view(&effect)) + p { "trigger query: " (query_status) } + } + } + Err(detail) => crate::render::unreadable(&detail), }; Ok(super::layout_meta( &super::RepoHeader::from_state(&state), &super::identity_label(&state), "/effects", &name, - html! { - (crate::render::view(&effect)) - p { "trigger query: " (query_status) } - }, + body, )) } -fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Effect)>> { +/// Every `refs/meta/effects/*` ref, with its tip's tree deserialized as an +/// [`Effect`] -- `Err(detail)` for a ref this build's `#[derive(Facet)]` +/// shape could not read back, kept in the listing rather than dropped (see +/// `crate::pages::members::read_all`'s identical rationale). +fn read_all<O: Find>( + state: &AppState<O>, +) -> Result<Vec<(String, std::result::Result<Effect, String>)>> { let mut out = Vec::new(); for entry in state.refs.iter_prefix("refs/meta/effects/")? { let (name, tip) = entry?; @@ -78,10 +95,13 @@ let Some(id) = path.strip_prefix("refs/meta/effects/") else { continue; }; - let tree = super::commit_tree(&*state.objects(), tip)?; - if let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, &*state.objects()) { - out.push((id.to_owned(), effect)); - } + let effect = super::commit_tree(&*state.objects(), tip) + .map_err(|error| error.to_string()) + .and_then(|tree| { + facet_git_tree::deserialize::<Effect>(&tree, &*state.objects()) + .map_err(|error| error.to_string()) + }); + out.push((id.to_owned(), effect)); } Ok(out) }
crates/cli/ents-web/src/pages/members.rs @@ -36,7 +36,10 @@ /// /// # Errors /// -/// [`Error::NotFound`] if `username` has no member ref. +/// [`Error::NotFound`] if `username` has no member ref at all -- a member +/// ref that exists but whose stored tree does not match this build's +/// [`Member`] shape degrades to [`crate::render::unreadable`] instead +/// (`roots.web-agnostic`'s graceful-degradation stance). pub async fn show<O>( State(state): State<Arc<AppState<O>>>, Path(username): Path<String>, @@ -50,16 +53,29 @@ .ok_or_else(|| Error::NotFound { what: format!("member {username}"), })?; + let body = match member { + Ok(member) => crate::render::view(&member), + Err(detail) => crate::render::unreadable(&detail), + }; Ok(super::layout_meta( &super::RepoHeader::from_state(&state), &super::identity_label(&state), "/members", &username, - crate::render::view(&member), + body, )) } -fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Member)>> { +/// Every `refs/meta/member/*` ref, with its tip's tree deserialized as a +/// [`Member`] -- `Err(detail)` for a ref this build's `#[derive(Facet)]` +/// shape could not read back, kept in the listing (not dropped) so +/// [`list`]/[`show`] can render it as a marker rather than silently +/// omitting it (`roots.web-agnostic`: a reader surfaces a marker, never an +/// error or a silent gap, for one entity written by a schema this build no +/// longer speaks). +fn read_all<O: Find>( + state: &AppState<O>, +) -> Result<Vec<(String, std::result::Result<Member, String>)>> { let mut out = Vec::new(); for entry in state.refs.iter_prefix("refs/meta/member/")? { let (name, tip) = entry?; @@ -67,10 +83,13 @@ let Some(username) = path.strip_prefix("refs/meta/member/") else { continue; }; - let tree = super::commit_tree(&*state.objects(), tip)?; - if let Ok(member) = facet_git_tree::deserialize::<Member>(&tree, &*state.objects()) { - out.push((username.to_owned(), member)); - } + let member = super::commit_tree(&*state.objects(), tip) + .map_err(|error| error.to_string()) + .and_then(|tree| { + facet_git_tree::deserialize::<Member>(&tree, &*state.objects()) + .map_err(|error| error.to_string()) + }); + out.push((username.to_owned(), member)); } Ok(out) }
crates/cli/ents-web/src/pages/redactions.rs @@ -35,7 +35,10 @@ /// /// # Errors /// -/// [`Error::NotFound`] if `id` has no redaction ref. +/// [`Error::NotFound`] if `id` has no redaction ref at all -- a redaction +/// ref that exists but whose stored tree does not match this build's +/// [`Redaction`] shape degrades to [`crate::render::unreadable`] instead +/// (`roots.web-agnostic`'s graceful-degradation stance). pub async fn show<O>( State(state): State<Arc<AppState<O>>>, Path(id): Path<String>, @@ -49,16 +52,27 @@ .ok_or_else(|| Error::NotFound { what: format!("redaction {id}"), })?; + let body = match redaction { + Ok(redaction) => crate::render::view(&redaction), + Err(detail) => crate::render::unreadable(&detail), + }; Ok(super::layout_meta( &super::RepoHeader::from_state(&state), &super::identity_label(&state), "/redactions", &id, - crate::render::view(&redaction), + body, )) } -fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Redaction)>> { +/// Every `refs/meta/redactions/*` ref, with its tip's tree deserialized as +/// a [`Redaction`] -- `Err(detail)` for a ref this build's +/// `#[derive(Facet)]` shape could not read back, kept in the listing +/// rather than dropped (see `crate::pages::members::read_all`'s identical +/// rationale). +fn read_all<O: Find>( + state: &AppState<O>, +) -> Result<Vec<(String, std::result::Result<Redaction, String>)>> { let mut out = Vec::new(); for entry in state.refs.iter_prefix("refs/meta/redactions/")? { let (name, tip) = entry?; @@ -66,10 +80,13 @@ let Some(id) = path.strip_prefix("refs/meta/redactions/") else { continue; }; - let tree = super::commit_tree(&*state.objects(), tip)?; - if let Ok(redaction) = facet_git_tree::deserialize::<Redaction>(&tree, &*state.objects()) { - out.push((id.to_owned(), redaction)); - } + let redaction = super::commit_tree(&*state.objects(), tip) + .map_err(|error| error.to_string()) + .and_then(|tree| { + facet_git_tree::deserialize::<Redaction>(&tree, &*state.objects()) + .map_err(|error| error.to_string()) + }); + out.push((id.to_owned(), redaction)); } Ok(out) }
crates/cli/ents-web/src/pages/toolchains.rs @@ -15,11 +15,21 @@ use gix_object::{Find, Write}; use maud::html; -use crate::error::Result; +use crate::error::{Error, Result}; use crate::state::AppState; /// `GET /toolchains`. /// +/// Every name resolves its own recipe (`toolchain::view`) so a name whose +/// stored tree does not match this build's [`ents_kiln::Toolchain`]/ +/// [`ents_kiln::Recipe`] shape (written by an older schema) renders here as +/// a muted marker rather than a working link -- the same per-entity +/// graceful-degradation stance [`crate::render::list_table`] takes for the +/// other meta families, hand-rolled here since [`toolchain::list`] itself +/// only enumerates ref names, with no reflected entity for +/// [`crate::render`]'s generic machinery to walk (this page family's own +/// top-level doc). +/// /// # Errors /// /// Propagates a ref-store read failure. @@ -28,12 +38,34 @@ O: Find + Write + Send + 'static, { let names = toolchain::list(state.refs.as_ref())?; + let rows: Vec<(String, Option<String>)> = names + .into_iter() + .map(|name| { + let detail = toolchain::view(state.refs.as_ref(), &*state.objects(), &name) + .err() + .map(|error| error.to_string()); + (name, detail) + }) + .collect(); Ok(super::layout_meta( &super::RepoHeader::from_state(&state), &super::identity_label(&state), "/toolchains", "toolchains", - crate::render::string_list(&names, |name| format!("/toolchains/{name}")), + html! { + div.card { + ul.string-list { + @for (name, detail) in &rows { + li { + a href=(format!("/toolchains/{name}")) { (name) } + @if detail.is_some() { + span.unreadable { "unreadable \u{2014} written by an older schema" } + } + } + } + } + } + }, )) } @@ -42,8 +74,15 @@ /// /// # Errors /// -/// Propagates an `ents-kiln` lookup failure (wrapped as -/// [`crate::Error::Effect`]) if `name` has no toolchain ref. +/// [`Error::NotFound`] if `name` has no toolchain ref at all +/// ([`ents_effect::Error::UnknownToolchain`]) -- a toolchain ref that +/// exists but whose stored tree does not match this build's +/// [`ents_kiln::Toolchain`]/[`ents_kiln::Recipe`] shape degrades to +/// [`crate::render::unreadable`] instead (`roots.web-agnostic`'s +/// graceful-degradation stance). The import log is best-effort once the +/// recipe itself reads back: a log entry this build cannot decode renders +/// as an empty log rather than failing the whole page, since the recipe is +/// this page's primary content. pub async fn show<O>( State(state): State<Arc<AppState<O>>>, Path(name): Path<String>, @@ -51,24 +90,35 @@ where O: Find + Write + Send + 'static, { - let (toolchain, recipe) = toolchain::view(state.refs.as_ref(), &*state.objects(), &name)?; - let log = toolchain::log(state.refs.as_ref(), &*state.objects(), &name)?; + let body = match toolchain::view(state.refs.as_ref(), &*state.objects(), &name) { + Ok((toolchain, recipe)) => { + let log = + toolchain::log(state.refs.as_ref(), &*state.objects(), &name).unwrap_or_default(); + html! { + dl { + dt { "name" } dd { (toolchain.name) } + dt { "recipe" } dd { (format!("{recipe:?}")) } + } + h2 { "import log" } + ul { + @for oid in &log { + li { (oid.to_string()) } + } + } + } + } + Err(ents_effect::Error::UnknownToolchain(_)) => { + return Err(Error::NotFound { + what: format!("toolchain {name}"), + }); + } + Err(error) => crate::render::unreadable(&error.to_string()), + }; Ok(super::layout_meta( &super::RepoHeader::from_state(&state), &super::identity_label(&state), "/toolchains", &name, - html! { - dl { - dt { "name" } dd { (toolchain.name) } - dt { "recipe" } dd { (format!("{recipe:?}")) } - } - h2 { "import log" } - ul { - @for oid in &log { - li { (oid.to_string()) } - } - } - }, + body, )) }