multi: surface unreadable entities behind a subtle disclosure
commit 08a2df8
multi: surface unreadable entities behind a subtle disclosure
comment::list and issue::list gain a list_all companion returning the
refs they could not read back (a new ents_forge::Unreadable row of
refname + error) instead of dropping them on the floor — list stays
the readable-rows-only view. Every entity list page (members, effects,
redactions, toolchains, comments, issues) now renders one shared
no-JS details disclosure — a muted 'N unreadable' badge expanding to
the failed refs and their errors — replacing the members table’s
inline unreadable rows and the toolchains list’s inline markers, so
all families degrade identically. An unreadable comment or issue
detail page renders the plain unreadable card instead of erroring.
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/ents-web/src/render.rs
@@ -112,22 +112,19 @@
}
/// A table listing `rows`, one row per `(id, entity)` pair, columns taken
-/// from the first successfully-read entity's own reflected field names --
-/// the generic "list" page every kernel entity this crate exposes uses.
+/// from the first 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.
+/// Rows are the readable entities only: a ref whose stored tree this
+/// build's `#[derive(Facet)]` shape could not read back is not this
+/// table's row to render -- the page surfaces it through
+/// [`unreadable_disclosure`] beside this table instead (the one place
+/// unreadable entities render, for every family alike), and its own show
+/// page still renders [`unreadable`]'s marker card.
///
/// # Examples
///
@@ -135,28 +132,21 @@
/// use ents_model::{Member, Provenance};
///
/// let rows = vec![
-/// ("jdc".to_owned(), Ok(Member::new("jdc", "key-a", Provenance::AdminRegistered))),
-/// ("legacy".to_owned(), Err("object ... is not a blob".to_owned())),
+/// ("jdc".to_owned(), Member::new("jdc", "key-a", Provenance::AdminRegistered)),
/// ];
/// 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"));
+/// assert!(rendered.contains("key-a"));
/// ```
#[must_use]
pub fn list_table<T: Facet<'static>>(
- rows: &[(String, Result<T, String>)],
+ rows: &[(String, T)],
id_header: &str,
href_for: impl Fn(&str) -> String,
) -> Markup {
let field_names: Vec<&'static str> = rows
- .iter()
- .find_map(|(_, entity)| {
- entity
- .as_ref()
- .ok()
- .map(|entity| fields(entity).into_iter().map(|(name, _)| name).collect())
- })
+ .first()
+ .map(|(_, entity)| fields(entity).into_iter().map(|(name, _)| name).collect())
.unwrap_or_default();
html! {
div.card {
@@ -171,19 +161,11 @@
}
tbody {
@for (id, entity) in rows {
- @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"
- }
- },
+ tr {
+ td { a href=(href_for(id)) { (id) } }
+ @for (_, rendered) in fields(entity) {
+ td { (rendered) }
+ }
}
}
}
@@ -221,6 +203,52 @@
}
}
+/// The subtle "this page has unreadable entities" disclosure a list page
+/// renders when one or more refs under its prefix failed to read back as
+/// this build's entity shape: a muted `<details>` badge ("N unreadable",
+/// warning glyph) that expands -- no JS, just the element's own toggle --
+/// to a small card listing each failed refname and its error text. One
+/// component for every entity family (members, effects, redactions,
+/// toolchains, comments, issues), so unreadable entities are surfaced the
+/// same way everywhere instead of a per-page mix of inline rows and
+/// silent gaps. Renders nothing at all when `items` is empty, so a
+/// healthy page carries no extra markup.
+///
+/// # Examples
+///
+/// ```
+/// let items = vec![(
+/// "refs/meta/comments/legacy".to_owned(),
+/// "object ... is not a blob".to_owned(),
+/// )];
+/// let rendered = ents_web::render::unreadable_disclosure(&items).into_string();
+/// assert!(rendered.contains("<details"));
+/// assert!(rendered.contains("1 unreadable"));
+/// assert!(rendered.contains("refs/meta/comments/legacy"));
+/// assert!(ents_web::render::unreadable_disclosure(&[]).into_string().is_empty());
+/// ```
+#[must_use]
+pub fn unreadable_disclosure(items: &[(String, String)]) -> Markup {
+ if items.is_empty() {
+ return html! {};
+ }
+ html! {
+ details.unreadable-note {
+ summary {
+ "\u{26a0} " (items.len()) " unreadable"
+ }
+ div.card {
+ dl.entity-view {
+ @for (refname, error) in items {
+ dt { (refname) }
+ dd { (error) }
+ }
+ }
+ }
+ }
+ }
+}
+
/// A key-value properties table for a rendered document's own metadata --
/// Markdown frontmatter ([`crate::markdown`]) and an AsciiDoc header's
/// attribute entries ([`crate::asciidoc`]) both render through this one
@@ -349,10 +377,10 @@
#[rstest]
// @relation(roots.web-agnostic, scope=function, role=Verifies)
- fn list_table_derives_its_columns_from_the_first_readable_rows_own_shape() {
+ fn list_table_derives_its_columns_from_the_first_rows_own_shape() {
let rows = vec![(
"jdc".to_owned(),
- Ok(Member::new("jdc", "key", Provenance::AdminRegistered)),
+ Member::new("jdc", "key", Provenance::AdminRegistered),
)];
let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
assert!(markup.contains("username"));
@@ -362,22 +390,26 @@
#[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![
+ fn unreadable_disclosure_lists_each_failed_ref_behind_a_details_toggle() {
+ let items = vec![
(
- "jdc".to_owned(),
- Ok(Member::new("jdc", "key", Provenance::AdminRegistered)),
+ "refs/meta/member/legacy".to_owned(),
+ "object ... is not a blob".to_owned(),
),
(
- "legacy".to_owned(),
- Err("object ... is not a blob".to_owned()),
+ "refs/meta/member/older".to_owned(),
+ "missing field".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""#));
+ let markup = unreadable_disclosure(&items).into_string();
+ assert!(markup.contains("<details"));
+ assert!(markup.contains("2 unreadable"));
+ assert!(markup.contains("refs/meta/member/legacy"));
+ assert!(markup.contains("missing field"));
+ assert!(
+ unreadable_disclosure(&[]).into_string().is_empty(),
+ "a healthy page carries no disclosure at all"
+ );
}
#[rstest]
crates/cli/ents-web/tests/router.rs
@@ -2132,10 +2132,61 @@
);
}
-/// `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.
+/// `GET /comments` surfaces a comment ref written by an older schema
+/// through the shared unreadable disclosure instead of silently dropping
+/// it, and its own `GET /comments/{id}` page renders the plain unreadable
+/// marker card rather than erroring.
+#[tokio::test]
+async fn comments_surface_an_unreadable_ref_in_the_list_and_on_its_own_page() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let tip = write_commit(
+ &objects,
+ &CommitSpec {
+ tree: ents_testutil::empty_tree(&objects),
+ parents: Vec::new(),
+ message: "legacy comment".to_owned(),
+ seconds: 100,
+ },
+ None,
+ );
+ let refname: gix::refs::FullName = "refs/meta/comments/legacy"
+ .try_into()
+ .expect("valid refname");
+ refs.set(refname.as_ref(), tip);
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ let list = get_body(&router, "/comments").await;
+ assert!(
+ list.contains("unreadable-note") && list.contains("1 unreadable"),
+ "the list page carries the subtle disclosure: {list}"
+ );
+ assert!(
+ list.contains("refs/meta/comments/legacy"),
+ "the disclosure names the failed ref"
+ );
+
+ let detail = get_body(&router, "/comments/legacy").await;
+ assert!(
+ detail.contains("unreadable"),
+ "the detail page shows the error state plainly instead of erroring: {detail}"
+ );
+}
+
+/// `GET /toolchains` surfaces a toolchain written by an older schema
+/// (piece 1's bug: this repository's own
+/// `refs/meta/toolchains/{rust,sccache,zig}` still carry it) through the
+/// shared unreadable disclosure, 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();
@@ -2182,7 +2233,14 @@
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"));
+ assert!(
+ body.contains("unreadable-note") && body.contains("1 unreadable"),
+ "the legacy entry surfaces through the shared disclosure: {body}"
+ );
+ assert!(
+ body.contains("refs/meta/toolchains/legacy"),
+ "the disclosure names the failed ref"
+ );
}
/// `GET /toolchains/{name}` on a legacy-schema entry renders the marker
crates/forge/ents-forge/src/lib.rs
@@ -114,6 +114,48 @@
.to_owned()
}
+/// One meta ref a listing could not read back as this build's own entity
+/// shape — its tip's tree was written by an older or unrelated schema (or
+/// the ref does not even point at a commit), so deserialization failed.
+/// [`comment::list_all`] and [`issue::list_all`] return these alongside
+/// their readable rows rather than dropping them on the floor: a reader
+/// surfaces a marker, never a silent gap, for an entity this build can no
+/// longer speak the schema of (the same graceful-degradation stance
+/// `ents-web`'s per-entity "unreadable" rendering takes).
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::Unreadable;
+///
+/// let unreadable = Unreadable {
+/// refname: "refs/meta/comments/deadbeef".to_owned(),
+/// error: "object ... is not a blob".to_owned(),
+/// };
+/// assert!(unreadable.refname.starts_with("refs/meta/"));
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Unreadable {
+ /// The full refname whose tip could not be read back.
+ pub refname: String,
+ /// The underlying read/deserialization error, rendered as text — a
+ /// diagnostic for an operator, not a value to match on.
+ pub error: String,
+}
+
+/// A listing's readable `(id, entity)` rows alongside the refs it could
+/// not read — [`comment::list_all`] and [`issue::list_all`]'s shared
+/// return shape (see [`Unreadable`]'s own doc).
+///
+/// # Examples
+///
+/// ```
+/// let listing: ents_forge::Listing<ents_forge::Issue> = (Vec::new(), Vec::new());
+/// let (rows, unreadable) = listing;
+/// assert!(rows.is_empty() && unreadable.is_empty());
+/// ```
+pub type Listing<T> = (Vec<(String, T)>, Vec<Unreadable>);
+
/// Abbreviate a genesis-oid entity id (`model.comment`, `model.issue`) to a
/// short prefix for display — the same seven-hex-character length git's own
/// short object id uses (`model.issue`: "porcelain abbreviates ids the way
crates/cli/ents-web/src/assets/ents.css
@@ -245,10 +245,10 @@
.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 {
+/* A per-entity read failure (an older or unrelated schema's tree shape)
+ * on a show page's own card (`crate::render::unreadable`) -- graceful
+ * degradation, never a 500. */
+.card-row.unreadable span {
color: var(--color-text-muted);
font-style: italic;
}
@@ -262,6 +262,19 @@
word-break: break-all;
}
+/* The subtle unreadable-entities disclosure a list page renders when refs
+ * under its prefix failed to read back (`crate::render::unreadable_disclosure`):
+ * a muted, badge-shaped `<details>` summary that must never dominate the
+ * page, expanding -- the element's own no-JS toggle -- to a small card of
+ * refname/error pairs. */
+.unreadable-note { margin-bottom: 1.25rem; }
+.unreadable-note summary { display: inline-flex; align-items: center; gap: .35rem; font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .1rem .6rem; cursor: pointer; list-style: none; user-select: none; -webkit-user-select: none; }
+.unreadable-note summary::-webkit-details-marker { display: none; }
+.unreadable-note summary:hover { color: var(--color-text); }
+.unreadable-note[open] summary { margin-bottom: .6rem; }
+.unreadable-note .card { margin-bottom: 0; }
+.unreadable-note dd { color: var(--color-text-muted); font-size: .78rem; }
+
/* 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/comments.rs
@@ -78,7 +78,11 @@
where
O: Find + Write + Send + 'static,
{
- let rows = comment::list(state.refs.as_ref(), &*state.objects())?;
+ let (rows, unreadable) = comment::list_all(state.refs.as_ref(), &*state.objects())?;
+ let failures: Vec<(String, String)> = unreadable
+ .into_iter()
+ .map(|entry| (entry.refname, entry.error))
+ .collect();
Ok(super::layout(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
@@ -86,6 +90,7 @@
"Comments",
html! {
div.readable {
+ (crate::render::unreadable_disclosure(&failures))
@if rows.is_empty() {
(super::blankslate(
"No comments yet",
@@ -131,7 +136,9 @@
/// # Errors
///
/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if
-/// `id` has no comment ref.
+/// `id` has no comment ref at all; a comment ref whose stored tree this
+/// build cannot read back degrades to [`crate::render::unreadable`]'s
+/// marker card instead of erroring.
pub async fn show<O>(
State(state): State<Arc<AppState<O>>>,
axum::Extension(session): axum::Extension<Session>,
@@ -141,14 +148,33 @@
where
O: Find + Write + Send + 'static,
{
- let (comment, projected) = comment::show(
+ let (comment, projected) = match comment::show(
state.refs.as_ref(),
&*state.objects(),
&state.path,
&id,
&query.rev,
false,
- )?;
+ ) {
+ Ok(read) => read,
+ // No ref at all stays a real 404; any other failure (a tree this
+ // build's shape cannot read back, written by an older schema) is
+ // an existing entity this page degrades to the plain unreadable
+ // card for, never a 404 or a 500.
+ Err(source @ ents_forge::Error::NotFound { .. }) => return Err(source.into()),
+ Err(source) => {
+ return Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Comments,
+ &format!("Comment {}", ents_forge::abbreviate_id(&id)),
+ html! {
+ (super::child_crumbs("comments", "/comments", ents_forge::abbreviate_id(&id)))
+ div.readable { (crate::render::unreadable(&source.to_string())) }
+ },
+ ));
+ }
+ };
let resolved = comment.state == "resolved";
let return_to = format!("/comments/{id}");
Ok(super::layout(
crates/cli/ents-web/src/pages/effects.rs
@@ -26,8 +26,15 @@
where
O: Find + Write + Send + 'static,
{
- let rows = read_all(&state)?;
- let body = if rows.is_empty() {
+ let mut rows = Vec::new();
+ let mut failures = Vec::new();
+ for (name, effect) in read_all(&state)? {
+ match effect {
+ Ok(effect) => rows.push((name, effect)),
+ Err(error) => failures.push((format!("refs/meta/effects/{name}"), error)),
+ }
+ }
+ let table = if rows.is_empty() {
super::blankslate(
"No effects yet",
html! { "Registered effects and their trigger queries appear here." },
@@ -40,7 +47,10 @@
&super::identity_label(&state),
"/effects",
"Effects",
- body,
+ html! {
+ (crate::render::unreadable_disclosure(&failures))
+ (table)
+ },
))
}
crates/cli/ents-web/src/pages/issues.rs
@@ -44,7 +44,11 @@
where
O: Find + Write + Send + 'static,
{
- let rows = issue::list(state.refs.as_ref(), &*state.objects())?;
+ let (rows, unreadable) = issue::list_all(state.refs.as_ref(), &*state.objects())?;
+ let failures: Vec<(String, String)> = unreadable
+ .into_iter()
+ .map(|entry| (entry.refname, entry.error))
+ .collect();
Ok(super::layout(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
@@ -52,6 +56,7 @@
"Issues",
html! {
div.readable {
+ (crate::render::unreadable_disclosure(&failures))
@if rows.is_empty() {
(super::blankslate(
"No issues yet",
@@ -90,8 +95,10 @@
/// # Errors
///
/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if
-/// `id` has no issue ref; otherwise propagates a ref-store or object read
-/// failure.
+/// `id` has no issue ref at all; an issue ref whose stored tree this
+/// build cannot read back degrades to [`crate::render::unreadable`]'s
+/// marker card instead of erroring. Otherwise propagates a ref-store or
+/// object read failure.
// @relation(model.issue, model.comment-context, scope=function)
pub async fn show<O>(
State(state): State<Arc<AppState<O>>>,
@@ -101,7 +108,25 @@
where
O: Find + Write + Send + 'static,
{
- let issue = issue::show(state.refs.as_ref(), &*state.objects(), &id)?;
+ let issue = match issue::show(state.refs.as_ref(), &*state.objects(), &id) {
+ Ok(issue) => issue,
+ // No ref at all stays a real not-found; any other failure (a tree
+ // this build's shape cannot read back) is an existing entity this
+ // page degrades to the plain unreadable card for.
+ Err(source @ ents_forge::Error::NotFound { .. }) => return Err(source.into()),
+ Err(source) => {
+ return Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Issues,
+ &format!("Issue {}", ents_forge::abbreviate_id(&id)),
+ html! {
+ (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id)))
+ div.readable { (crate::render::unreadable(&source.to_string())) }
+ },
+ ));
+ }
+ };
let context = format!("issues/{id}");
let thread = ents_forge::comment::thread(state.refs.as_ref(), &*state.objects(), &context)?;
let body =
crates/cli/ents-web/src/pages/members.rs
@@ -22,8 +22,15 @@
where
O: Find + Write + Send + 'static,
{
- let rows = read_all(&state)?;
- let body = if rows.is_empty() {
+ let mut rows = Vec::new();
+ let mut failures = Vec::new();
+ for (username, member) in read_all(&state)? {
+ match member {
+ Ok(member) => rows.push((username, member)),
+ Err(error) => failures.push((format!("refs/meta/member/{username}"), error)),
+ }
+ }
+ let table = if rows.is_empty() {
super::blankslate(
"No members yet",
maud::html! { "Enroll one with " code { "git ents members add" } "." },
@@ -36,7 +43,10 @@
&super::identity_label(&state),
"/members",
"Members",
- body,
+ maud::html! {
+ (crate::render::unreadable_disclosure(&failures))
+ (table)
+ },
))
}
@@ -80,7 +90,9 @@
/// 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
+/// [`list`] can surface it through
+/// [`crate::render::unreadable_disclosure`] and [`show`] as
+/// [`crate::render::unreadable`]'s marker card, 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).
crates/cli/ents-web/src/pages/redactions.rs
@@ -21,8 +21,15 @@
where
O: Find + Write + Send + 'static,
{
- let rows = read_all(&state)?;
- let body = if rows.is_empty() {
+ let mut rows = Vec::new();
+ let mut failures = Vec::new();
+ for (id, redaction) in read_all(&state)? {
+ match redaction {
+ Ok(redaction) => rows.push((id, redaction)),
+ Err(error) => failures.push((format!("refs/meta/redactions/{id}"), error)),
+ }
+ }
+ let table = if rows.is_empty() {
super::blankslate(
"No redactions yet",
maud::html! { "Record one with " code { "git ents redact add" } "." },
@@ -35,7 +42,10 @@
&super::identity_label(&state),
"/redactions",
"Redactions",
- body,
+ maud::html! {
+ (crate::render::unreadable_disclosure(&failures))
+ (table)
+ },
))
}
crates/cli/ents-web/src/pages/toolchains.rs
@@ -22,13 +22,13 @@
///
/// 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).
+/// [`ents_kiln::Recipe`] shape (written by an older schema) surfaces in
+/// the same [`crate::render::unreadable_disclosure`] every other entity
+/// family's list page renders, while its name stays linked in the listing
+/// (its show page renders the unreadable marker card) -- 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
///
@@ -38,16 +38,13 @@
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();
- let body = if rows.is_empty() {
+ let mut failures = Vec::new();
+ for name in &names {
+ if let Err(error) = toolchain::view(state.refs.as_ref(), &*state.objects(), name) {
+ failures.push((format!("refs/meta/toolchains/{name}"), error.to_string()));
+ }
+ }
+ let listing = if names.is_empty() {
super::blankslate(
"No toolchains yet",
html! { "Import one with " code { "git ents toolchain import" } "." },
@@ -56,12 +53,9 @@
html! {
div.card {
ul.string-list {
- @for (name, detail) in &rows {
+ @for name in &names {
li {
a href=(format!("/toolchains/{name}")) { (name) }
- @if detail.is_some() {
- span.unreadable { "unreadable \u{2014} written by an older schema" }
- }
}
}
}
@@ -73,7 +67,10 @@
&super::identity_label(&state),
"/toolchains",
"Toolchains",
- body,
+ html! {
+ (crate::render::unreadable_disclosure(&failures))
+ (listing)
+ },
))
}
crates/forge/ents-forge/src/comment/command.rs
@@ -62,6 +62,11 @@
/// `git ents comment list`: every comment recorded in this repository.
///
+/// A ref whose tip this build cannot read back as a [`Comment`] is
+/// silently absent here — a caller that must surface those refs instead
+/// of dropping them (`ents-web`'s comments page) uses [`list_all`], which
+/// this is the readable-rows-only view of.
+///
/// # Errors
///
/// Propagates a ref-store or object read failure.
@@ -77,19 +82,52 @@
/// assert!(list(&refs, &objects).expect("reads").is_empty());
/// ```
pub fn list(refs: &dyn RefStoreRead, objects: &impl Find) -> Result<Vec<(String, Comment)>> {
+ Ok(list_all(refs, objects)?.0)
+}
+
+/// [`list`] plus the refs it could not read: every readable comment, and
+/// one [`crate::Unreadable`] per `refs/meta/comments/*` ref whose tip this
+/// build's [`Comment`] shape could not read back (written by an older or
+/// unrelated schema, or not pointing at a commit at all). Nothing under
+/// the prefix is ever silently dropped — a failed read lands in the second
+/// vec with its refname and error text, for a caller to surface however
+/// its own surface degrades (see [`crate::Unreadable`]'s own doc).
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure — a per-ref *entity* read failure
+/// is a row in the second vec, never an error.
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::comment::list_all;
+/// use ents_testutil::{ObjectStore, MemRefStore};
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// let (rows, unreadable) = list_all(&refs, &objects).expect("reads");
+/// assert!(rows.is_empty());
+/// assert!(unreadable.is_empty());
+/// ```
+pub fn list_all(refs: &dyn RefStoreRead, objects: &impl Find) -> Result<crate::Listing<Comment>> {
let mut out = Vec::new();
+ let mut unreadable = Vec::new();
for entry in refs.iter_prefix("refs/meta/comments/")? {
let (name, tip) = entry?;
let path = name.as_bstr().to_string();
let Some(id) = path.strip_prefix("refs/meta/comments/") else {
continue;
};
- let tree = commit_tree(objects, tip)?;
- if let Ok(comment) = read_comment(&tree, objects) {
- out.push((id.to_owned(), comment));
+ match commit_tree(objects, tip).and_then(|tree| read_comment(&tree, objects)) {
+ Ok(comment) => out.push((id.to_owned(), comment)),
+ Err(error) => unreadable.push(crate::Unreadable {
+ refname: path.clone(),
+ error: error.to_string(),
+ }),
}
}
- Ok(out)
+ Ok((out, unreadable))
}
/// One row of [`list_projected`]: the comment, and — when it carries an
crates/forge/ents-forge/src/issue/command.rs
@@ -167,6 +167,11 @@
/// `git ents issue list`: every issue recorded in this repository.
///
+/// A ref whose tip this build cannot read back as an [`Issue`] is
+/// silently absent here — a caller that must surface those refs instead
+/// of dropping them (`ents-web`'s issues page) uses [`list_all`], which
+/// this is the readable-rows-only view of.
+///
/// # Errors
///
/// Propagates a ref-store or object read failure.
@@ -185,19 +190,55 @@
refs: &dyn gix_ref_store::RefStoreRead,
objects: &impl Find,
) -> Result<Vec<(String, Issue)>> {
+ Ok(list_all(refs, objects)?.0)
+}
+
+/// [`list`] plus the refs it could not read: every readable issue, and
+/// one [`crate::Unreadable`] per `refs/meta/issues/*` ref whose tip this
+/// build's [`Issue`] shape could not read back — the issue counterpart to
+/// [`crate::comment::list_all`], with the same never-silently-dropped
+/// contract (see [`crate::Unreadable`]'s own doc).
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure — a per-ref *entity* read failure
+/// is a row in the second vec, never an error.
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::issue::list_all;
+/// use ents_testutil::{MemRefStore, ObjectStore};
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// let (rows, unreadable) = list_all(&refs, &objects).expect("reads");
+/// assert!(rows.is_empty());
+/// assert!(unreadable.is_empty());
+/// ```
+pub fn list_all(
+ refs: &dyn gix_ref_store::RefStoreRead,
+ objects: &impl Find,
+) -> Result<crate::Listing<Issue>> {
let mut out = Vec::new();
+ let mut unreadable = Vec::new();
for entry in refs.iter_prefix("refs/meta/issues/")? {
let (name, tip) = entry?;
let path = name.as_bstr().to_string();
let Some(id) = path.strip_prefix("refs/meta/issues/") else {
continue;
};
- let tree = commit_tree(objects, tip)?;
- if let Ok(issue) = facet_git_tree::deserialize::<Issue>(&tree, objects) {
- out.push((id.to_owned(), issue));
+ match commit_tree(objects, tip)
+ .and_then(|tree| Ok(facet_git_tree::deserialize::<Issue>(&tree, objects)?))
+ {
+ Ok(issue) => out.push((id.to_owned(), issue)),
+ Err(error) => unreadable.push(crate::Unreadable {
+ refname: path.clone(),
+ error: error.to_string(),
+ }),
}
}
- Ok(out)
+ Ok((out, unreadable))
}
/// `git ents issue show`: `id`'s issue.
crates/forge/ents-forge/src/issue/mod.rs
@@ -10,5 +10,5 @@
mod entity;
pub use cli::IssueAction;
-pub use command::{EditIssue, NewIssue, edit, list, new, show};
+pub use command::{EditIssue, NewIssue, edit, list, list_all, new, show};
pub use entity::Issue;
crates/forge/ents-forge/tests/unreadable.rs
@@ -1,0 +1,87 @@
+//! Coverage for [`ents_forge::comment::list_all`] and
+//! [`ents_forge::issue::list_all`]: a listing returns the refs it could
+//! not read back alongside its readable rows ([`ents_forge::Unreadable`])
+//! rather than dropping them on the floor — the fixture writes one
+//! good-shape entity and one wrong-shape ref (an empty tree no entity
+//! decoder accepts) under the same prefix and asserts both surface.
+
+#![allow(
+ clippy::expect_used,
+ clippy::indexing_slicing,
+ reason = "integration test: fixtures panic on setup failure"
+)]
+
+use ents_forge::Issue;
+use ents_forge::comment::Comment;
+use ents_model::MemberId;
+use ents_testutil::{CommitSpec, MemRefStore, ObjectStore, empty_tree, write_commit};
+
+/// A commit whose tree is empty — a shape no entity decoder reads back —
+/// landed on `refname`, standing in for a ref written by an older or
+/// unrelated schema.
+fn seed_wrong_shape_ref(refs: &MemRefStore, objects: &ObjectStore, refname: &str) {
+ let spec = CommitSpec {
+ tree: empty_tree(objects),
+ parents: vec![],
+ message: "Wrong-shape entity".into(),
+ seconds: 1_000,
+ };
+ let tip = write_commit(objects, &spec, None);
+ refs.set_str(refname, tip);
+}
+
+#[test]
+fn comment_list_all_returns_an_unreadable_ref_alongside_a_readable_row() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+
+ let good = Comment {
+ body: "readable".to_owned(),
+ state: "open".to_owned(),
+ anchor: None,
+ context: Some("issues/42".to_owned()),
+ parent: None,
+ };
+ let name: gix::refs::FullName = "refs/meta/comments/good".try_into().expect("valid refname");
+ ents_testutil::write_meta_entity(&refs, &objects, name, &good, None, 1_000);
+ seed_wrong_shape_ref(&refs, &objects, "refs/meta/comments/legacy");
+
+ let (rows, unreadable) = ents_forge::comment::list_all(&refs, &objects).expect("listing reads");
+ assert_eq!(rows.len(), 1, "the readable comment still lists");
+ assert_eq!(rows[0].0, "good");
+ assert_eq!(unreadable.len(), 1, "the wrong-shape ref surfaces");
+ assert_eq!(unreadable[0].refname, "refs/meta/comments/legacy");
+ assert!(
+ !unreadable[0].error.is_empty(),
+ "the deserialization error text comes along for diagnosis"
+ );
+
+ // `list` stays the readable-rows-only view of the same walk: the
+ // wrong-shape ref is absent, and nothing errors.
+ let listed = ents_forge::comment::list(&refs, &objects).expect("listing reads");
+ assert_eq!(listed.len(), 1);
+ assert_eq!(listed[0].0, "good");
+}
+
+#[test]
+fn issue_list_all_returns_an_unreadable_ref_alongside_a_readable_row() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+
+ let good = Issue {
+ title: "readable".to_owned(),
+ body: String::new(),
+ state: "open".to_owned(),
+ assignees: vec![MemberId::new("jdc")],
+ labels: vec![],
+ };
+ let name: gix::refs::FullName = "refs/meta/issues/good".try_into().expect("valid refname");
+ ents_testutil::write_meta_entity(&refs, &objects, name, &good, None, 1_000);
+ seed_wrong_shape_ref(&refs, &objects, "refs/meta/issues/legacy");
+
+ let (rows, unreadable) = ents_forge::issue::list_all(&refs, &objects).expect("listing reads");
+ assert_eq!(rows.len(), 1, "the readable issue still lists");
+ assert_eq!(rows[0].0, "good");
+ assert_eq!(unreadable.len(), 1, "the wrong-shape ref surfaces");
+ assert_eq!(unreadable[0].refname, "refs/meta/issues/legacy");
+}