attrs, model, forge, cli: derive entity output from facet shapes via ents attributes
commit 7f591e8
attrs, model, forge, cli: derive entity output from facet shapes via ents attributes
Presentation policy now lives once, on each entity’s own fields, as an
ents attribute namespace (new ents-attrs grammar crate — forced out of
the entity crates by the macro-export absolute-path restriction, the same
reason figue-attrs exists). ents_forge::present walks any
#[derive(Facet)] shape into show lines, list columns, and porcelain
records without ever branching on the concrete entity type; exe.rs’s
hand-written per-command writeln! rendering for issue, comment, review,
and effect now derives through it, with only genuinely domain-specific
lines (comment projection, review thread, effect result) left bespoke.
New stable --porcelain forms for issue list, review list, and effect
list/log, in comment porcelain’s record grammar (full ids, space-separated
head line, tab-prefixed body, blank-line-separated records); comment’s
existing porcelain stays byte-identical. $EDITOR composition is now
driven by ents::compose marks on action variants: comment add and review
new compose an omitted --body in the editor (empty aborts), the same
fallback issue new already had.
Output fixes the derivation surfaced: review list/show now carry the
review state (a withdrawal was invisible); effect show lists a non-empty
toolchains line; effect log reports the judged commit rather than the
result ref’s tip; comment show abbreviates the parent id like every other
human-facing id.
crates/cli/git-ents/src/cli.rs
@@ -322,7 +322,16 @@
#[repr(u8)]
pub enum EffectAction {
/// List the effects configured in this repository.
- List,
+ ///
+ /// With --porcelain, emits a stable machine-readable form:
+ /// blank-line-separated records, each starting with a `<name>` line,
+ /// followed by `trigger <query>`, `toolchains <a, b>` (when
+ /// non-empty), and `run <command>` lines.
+ List {
+ /// Emit the stable machine-readable form described above.
+ #[facet(args::named, default)]
+ porcelain: bool,
+ },
/// Show one effect's definition and, when a commit is given, its
/// result.
Show {
@@ -368,11 +377,19 @@
#[facet(args::named)]
key: Option<PathBuf>,
},
- /// Show recorded results for an effect, newest first.
+ /// Show recorded results for an effect, one row per judged commit.
+ ///
+ /// With --porcelain, emits a stable machine-readable form:
+ /// blank-line-separated records of one line each,
+ /// `<commit> <status>` — the full oid of the judged commit and
+ /// pass, fail, or error.
Log {
/// The effect's name.
#[facet(args::positional)]
name: String,
+ /// Emit the stable machine-readable form described above.
+ #[facet(args::named, default)]
+ porcelain: bool,
},
}
crates/cli/git-ents/src/exe.rs
@@ -175,15 +175,23 @@
fn run_effect(action: EffectAction, out: &mut impl std::io::Write) -> Result<()> {
let root = LocalRoot::discover(".")?;
match action {
- EffectAction::List => {
- for (name, effect) in commands::effect::list(&root)? {
- let _ = writeln!(out, "{name}\t{}", effect.trigger);
+ EffectAction::List { porcelain } => {
+ let rows = commands::effect::list(&root)?;
+ if porcelain {
+ let _ = write!(out, "{}", ents_forge::present::porcelain(&rows));
+ } else {
+ for (name, effect) in rows {
+ let _ = writeln!(
+ out,
+ "{name}\t{}",
+ ents_forge::present::columns(&effect).join("\t")
+ );
+ }
}
}
EffectAction::Show { name, at } => {
let (effect, status) = commands::effect::show(&root, &name, at)?;
- let _ = writeln!(out, "trigger: {}", effect.trigger);
- let _ = writeln!(out, "run: {}", effect.run);
+ let _ = write!(out, "{}", ents_forge::present::view(&effect));
let _ = writeln!(
out,
"result: {}",
@@ -206,9 +214,23 @@
let _ = writeln!(out, "{oid}\t{:?}", outcome.result);
}
}
- EffectAction::Log { name } => {
- for (oid, status) in commands::effect::log(&root, &name)? {
- let _ = writeln!(out, "{oid}\t{status}");
+ EffectAction::Log { name, porcelain } => {
+ let rows = commands::effect::log(&root, &name)?;
+ if porcelain {
+ let rows: Vec<_> = rows
+ .into_iter()
+ .map(|(oid, record)| (oid.to_string(), record))
+ .collect();
+ let _ = write!(out, "{}", ents_forge::present::porcelain(&rows));
+ } else {
+ for (oid, record) in rows {
+ let _ = writeln!(
+ out,
+ "{}\t{}",
+ ents_forge::abbreviate_id(&oid.to_string()),
+ ents_forge::present::columns(&record).join("\t")
+ );
+ }
}
}
}
@@ -272,10 +294,9 @@
for row in rows {
let _ = writeln!(
out,
- "{}\t{}\t{}",
+ "{}\t{}",
ents_forge::abbreviate_id(&row.id),
- row.comment.state,
- row.comment.body
+ ents_forge::present::columns(&row.comment).join("\t")
);
}
for entry in unreadable {
@@ -294,7 +315,7 @@
key,
} => {
let new = ents_forge::comment::NewComment {
- body,
+ body: crate::compose::body::<CommentAction>("Add", body)?,
path,
lines,
rev,
@@ -319,12 +340,9 @@
}
CommentAction::Show { id, rev, worktree } => {
let (comment, projected) = commands::comment::show(&root, &id, &rev, worktree)?;
- let _ = writeln!(out, "state: {}", comment.state);
- if let Some(context) = &comment.context {
- let _ = writeln!(out, "context: {context}");
- }
- if let Some(parent) = &comment.parent {
- let _ = writeln!(out, "parent: {parent}");
+ let view = ents_forge::present::view(&comment);
+ for line in &view.lines {
+ let _ = writeln!(out, "{}: {}", line.name, line.value);
}
if let Some((anchor, projection)) = projected {
let _ = writeln!(out, "path: {}", anchor.path);
@@ -342,7 +360,9 @@
};
let _ = writeln!(out, "projection at {target}: {}{detail}", projection.label());
}
- let _ = writeln!(out, "body: {}", comment.body);
+ if let Some(body) = &view.body {
+ let _ = writeln!(out, "{}: {}", body.name, body.value);
+ }
}
}
Ok(())
@@ -351,29 +371,24 @@
fn run_issue(action: IssueAction, out: &mut impl std::io::Write) -> Result<()> {
let root = LocalRoot::discover(".")?;
match action {
- IssueAction::List => {
- for (id, issue) in commands::issue::list(&root)? {
- let _ = writeln!(
- out,
- "{}\t{}\t{}",
- ents_forge::abbreviate_id(&id),
- issue.state,
- issue.title
- );
+ IssueAction::List { porcelain } => {
+ let rows = commands::issue::list(&root)?;
+ if porcelain {
+ let _ = write!(out, "{}", ents_forge::present::porcelain(&rows));
+ } else {
+ for (id, issue) in rows {
+ let _ = writeln!(
+ out,
+ "{}\t{}",
+ ents_forge::abbreviate_id(&id),
+ ents_forge::present::columns(&issue).join("\t")
+ );
+ }
}
}
IssueAction::Show { id } => {
let issue = commands::issue::show(&root, &id)?;
- let _ = writeln!(out, "title: {}", issue.title);
- let _ = writeln!(out, "state: {}", issue.state);
- if !issue.assignees.is_empty() {
- let names: Vec<_> = issue.assignees.iter().map(ToString::to_string).collect();
- let _ = writeln!(out, "assignees: {}", names.join(", "));
- }
- if !issue.labels.is_empty() {
- let _ = writeln!(out, "labels: {}", issue.labels.join(", "));
- }
- let _ = writeln!(out, "body: {}", issue.body);
+ let _ = write!(out, "{}", ents_forge::present::view(&issue));
}
IssueAction::New {
title,
@@ -383,6 +398,7 @@
assignee,
key,
} => {
+ let (title, body) = crate::compose::title_body::<IssueAction>("New", title, body)?;
let id = commands::issue::new(&root, title, body, state, label, assignee, key)?;
let _ = writeln!(out, "opened {id}");
}
@@ -481,7 +497,7 @@
let new = ents_forge::review::NewReview {
target,
verdict: verdict.parse()?,
- body,
+ body: crate::compose::body::<ReviewAction>("New", body)?,
};
let target = commands::review::new(&root, new, key)?;
let _ = writeln!(out, "reviewed {}", ents_forge::abbreviate_id(&target));
@@ -490,33 +506,36 @@
let target = commands::review::withdraw(&root, target, key)?;
let _ = writeln!(out, "withdrew {}", ents_forge::abbreviate_id(&target));
}
- ReviewAction::List { target } => {
- for ((review_target, member), review) in commands::review::list(&root, target)? {
- let _ = writeln!(
- out,
- "{}\t{member}\t{}\t{}",
- ents_forge::abbreviate_id(&review_target),
- ents_forge::abbreviate_id(&review.target().to_string()),
- review.verdict
- );
+ ReviewAction::List { target, porcelain } => {
+ let rows = commands::review::list(&root, target)?;
+ if porcelain {
+ let rows: Vec<_> = rows
+ .into_iter()
+ .map(|((review_target, member), review)| {
+ (format!("{review_target} {member}"), review)
+ })
+ .collect();
+ let _ = write!(out, "{}", ents_forge::present::porcelain(&rows));
+ } else {
+ for ((review_target, member), review) in rows {
+ let _ = writeln!(
+ out,
+ "{}\t{member}\t{}",
+ ents_forge::abbreviate_id(&review_target),
+ ents_forge::present::columns(&review).join("\t")
+ );
+ }
}
}
ReviewAction::Show { target, member } => {
let (review, thread) = commands::review::show(&root, &target, &member)?;
- let _ = writeln!(
- out,
- "target: {}",
- ents_forge::abbreviate_id(&review.target().to_string())
- );
- let _ = writeln!(out, "verdict: {}", review.verdict);
- let _ = writeln!(out, "body: {}", review.body);
+ let _ = write!(out, "{}", ents_forge::present::view(&review));
for (comment_id, comment) in thread {
let _ = writeln!(
out,
- "comment {}\t{}\t{}",
+ "comment {}\t{}",
ents_forge::abbreviate_id(&comment_id),
- comment.state,
- comment.body
+ ents_forge::present::columns(&comment).join("\t")
);
}
}
crates/cli/git-ents/src/lib.rs
@@ -76,6 +76,7 @@
pub mod agent_worker;
pub mod cli;
pub mod commands;
+pub mod compose;
pub mod credentials;
pub mod error;
pub mod exe;
crates/cli/git-ents/tests/comment.rs
@@ -131,6 +131,63 @@
assert_eq!(all[0].comment.state, "resolved");
}
+/// `git ents comment add` with no `--body`, run as the real binary with a
+/// fake `$EDITOR`: the body composes from the scratch file, `#` lines
+/// stripped — the editor fallback the `ents::compose` attribute on
+/// `CommentAction::Add` declares.
+// @relation(model.comment, roots.local, scope=function, role=Verifies)
+#[test]
+fn comment_add_composes_body_from_a_fake_editor() {
+ let fixture = common::Fixture::new(2);
+ commit_file(fixture.path(), "file.txt", "line one\nline two\n");
+ let editor_path = fixture.path().join("fake-editor.sh");
+ common::write_fake_editor(
+ &editor_path,
+ "composed comment body\n# a stray comment line",
+ );
+
+ let output = Command::new(common::bin_path())
+ .current_dir(fixture.path())
+ .args(["comment", "add", "file.txt", "--key"])
+ .arg(&fixture.key_path)
+ .env("GIT_EDITOR", &editor_path)
+ .env("EDITOR", &editor_path)
+ .output()
+ .expect("runs");
+ assert!(output.status.success(), "{output:?}");
+
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ let listed = comment::list(&root).expect("lists");
+ assert_eq!(listed.len(), 1);
+ assert_eq!(listed[0].1.body, "composed comment body");
+}
+
+/// An empty composed comment body aborts with a failing exit status,
+/// mirroring `git commit`'s own empty-message abort.
+// @relation(model.comment, roots.local, scope=function, role=Verifies)
+#[test]
+fn comment_add_aborts_on_an_empty_editor_body() {
+ let fixture = common::Fixture::new(3);
+ commit_file(fixture.path(), "file.txt", "line one\n");
+ let editor_path = fixture.path().join("fake-editor.sh");
+ common::write_fake_editor(&editor_path, "# only a comment, no body");
+
+ let output = Command::new(common::bin_path())
+ .current_dir(fixture.path())
+ .args(["comment", "add", "file.txt", "--key"])
+ .arg(&fixture.key_path)
+ .env("GIT_EDITOR", &editor_path)
+ .env("EDITOR", &editor_path)
+ .output()
+ .expect("runs");
+ assert!(
+ !output.status.success(),
+ "an empty body must abort comment creation: {output:?}"
+ );
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ assert_eq!(comment::list(&root).expect("lists").len(), 0);
+}
+
/// Two records separate with exactly one blank line, and an unanchored
/// reply renders `-` for projection and location — the porcelain grammar
/// an agent parses (`lens.parity`).
crates/cli/git-ents/tests/issue.rs
@@ -16,21 +16,7 @@
use git_ents::commands::issue;
use git_ents::root::LocalRoot;
-/// Write an executable script at `path` that overwrites its one argument
-/// (the scratch file `git ents issue new` opens) with `contents` — a
-/// stand-in for a real `$EDITOR`, exercising the same
-/// spawn-and-read-back path a real editor would.
-fn write_fake_editor(path: &std::path::Path, contents: &str) {
- let script = format!("#!/bin/sh\ncat > \"$1\" <<'EOF'\n{contents}\nEOF\n");
- std::fs::write(path, script).expect("write fake editor");
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt as _;
- let mut perms = std::fs::metadata(path).expect("metadata").permissions();
- perms.set_mode(0o755);
- std::fs::set_permissions(path, perms).expect("chmod");
- }
-}
+use common::write_fake_editor;
/// `git ents issue new --title ...`: no editor needed, the title and body
/// round-trip exactly.
@@ -42,8 +28,8 @@
let id = issue::new(
&root,
- Some("gate rejects a valid signature".to_owned()),
- Some("steps to reproduce...".to_owned()),
+ "gate rejects a valid signature".to_owned(),
+ "steps to reproduce...".to_owned(),
"open".to_owned(),
vec!["bug".to_owned()],
vec!["jdc".to_owned()],
@@ -118,6 +104,58 @@
);
}
+/// `git ents issue list --porcelain` emits the stable record grammar
+/// (`lens.parity`, `model.issue`): full ids on a space-separated head
+/// line, `title`/`assignees`/`labels` keyed lines (empties omitted), the
+/// body tab-prefixed line by line, records blank-line separated.
+// @relation(lens.parity, model.issue, roots.local, scope=function, role=Verifies)
+#[test]
+fn issue_list_porcelain_emits_full_id_records() {
+ let fixture = common::Fixture::new(5);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+
+ let first = issue::new(
+ &root,
+ "gate rejects a valid signature".to_owned(),
+ "first body line\n\nthird body line".to_owned(),
+ "open".to_owned(),
+ vec!["bug".to_owned(), "gate".to_owned()],
+ vec!["jdc".to_owned()],
+ Some(fixture.key_path.clone()),
+ )
+ .expect("creates");
+ let second = issue::new(
+ &root,
+ "unlabeled".to_owned(),
+ "short".to_owned(),
+ "triaged".to_owned(),
+ vec![],
+ vec![],
+ Some(fixture.key_path.clone()),
+ )
+ .expect("creates");
+
+ let output = Command::new(common::bin_path())
+ .current_dir(fixture.path())
+ .args(["issue", "list", "--porcelain"])
+ .output()
+ .expect("runs");
+ assert!(output.status.success(), "{output:?}");
+ let stdout = String::from_utf8(output.stdout).expect("utf8");
+
+ let first_record = format!(
+ "{first} open\ntitle gate rejects a valid signature\nassignees jdc\nlabels bug, gate\n\tfirst body line\n\t\n\tthird body line\n"
+ );
+ let second_record = format!("{second} triaged\ntitle unlabeled\n\tshort\n");
+ // Listing order follows the refs' own (id-sorted) order.
+ let expected = if first < second {
+ format!("{first_record}\n{second_record}")
+ } else {
+ format!("{second_record}\n{first_record}")
+ };
+ assert_eq!(stdout, expected);
+}
+
/// `git ents issue edit`: state, assignees, and labels round-trip through
/// an edit on top of the issue's existing tip.
// @relation(model.issue, roots.local, scope=function, role=Verifies)
@@ -128,8 +166,8 @@
let id = issue::new(
&root,
- Some("title".to_owned()),
- Some("body".to_owned()),
+ "title".to_owned(),
+ "body".to_owned(),
"open".to_owned(),
vec![],
vec![],
crates/cli/git-ents/tests/review.rs
@@ -283,6 +283,108 @@
assert_eq!(all[0].1.state, ReviewState::Withdrawn);
}
+/// `git ents review list --porcelain` emits the stable record grammar
+/// (`lens.parity`, `model.review`): a head line of full target segment,
+/// member, full reviewed oid, verdict, and state, then the body
+/// tab-prefixed — and a withdrawal shows up as the state token.
+// @relation(lens.parity, model.review, roots.local, scope=function, role=Verifies)
+#[test]
+fn review_list_porcelain_emits_full_id_records() {
+ let fixture = common::Fixture::new(6);
+ let reviewed = commit_file(fixture.path(), "file.txt", "line one\n");
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "reviewer", None, Some(fixture.key_path.clone())).expect("enrolls");
+
+ let new = NewReview {
+ target: "HEAD".to_owned(),
+ verdict: Verdict::Approve,
+ body: "looks good\n\nsecond paragraph".to_owned(),
+ };
+ let target = review::new(&root, new, Some(fixture.key_path.clone())).expect("reviews");
+
+ let porcelain = |fixture: &common::Fixture| {
+ let output = Command::new(common::bin_path())
+ .current_dir(fixture.path())
+ .args(["review", "list", "--porcelain"])
+ .output()
+ .expect("runs");
+ assert!(output.status.success(), "{output:?}");
+ String::from_utf8(output.stdout).expect("utf8")
+ };
+
+ let expected = format!(
+ "{target} reviewer {reviewed} approve active\n\tlooks good\n\t\n\tsecond paragraph\n"
+ );
+ assert_eq!(porcelain(&fixture), expected);
+
+ review::withdraw(&root, reviewed.to_string(), Some(fixture.key_path.clone()))
+ .expect("withdraws");
+ let expected = format!(
+ "{target} reviewer {reviewed} approve withdrawn\n\tlooks good\n\t\n\tsecond paragraph\n"
+ );
+ assert_eq!(porcelain(&fixture), expected);
+}
+
+/// `git ents review new` with no `--body`, run as the real binary with a
+/// fake `$EDITOR`: the body composes from the scratch file, `#` lines
+/// stripped — the same editor fallback `issue new` has.
+// @relation(model.review, roots.local, scope=function, role=Verifies)
+#[test]
+fn review_new_composes_body_from_a_fake_editor() {
+ let fixture = common::Fixture::new(7);
+ commit_file(fixture.path(), "file.txt", "line one\n");
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "reviewer", None, Some(fixture.key_path.clone())).expect("enrolls");
+
+ let editor_path = fixture.path().join("fake-editor.sh");
+ common::write_fake_editor(
+ &editor_path,
+ "composed review body\n# a stray comment line",
+ );
+
+ let output = Command::new(common::bin_path())
+ .current_dir(fixture.path())
+ .args(["review", "new", "--verdict", "approve", "--key"])
+ .arg(&fixture.key_path)
+ .env("GIT_EDITOR", &editor_path)
+ .env("EDITOR", &editor_path)
+ .output()
+ .expect("runs");
+ assert!(output.status.success(), "{output:?}");
+
+ let all = review::list(&root, None).expect("lists");
+ assert_eq!(all.len(), 1);
+ assert_eq!(all[0].1.body, "composed review body");
+}
+
+/// An empty composed review body aborts with a failing exit status,
+/// mirroring `git commit`'s own empty-message abort.
+// @relation(model.review, roots.local, scope=function, role=Verifies)
+#[test]
+fn review_new_aborts_on_an_empty_editor_body() {
+ let fixture = common::Fixture::new(8);
+ commit_file(fixture.path(), "file.txt", "line one\n");
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "reviewer", None, Some(fixture.key_path.clone())).expect("enrolls");
+
+ let editor_path = fixture.path().join("fake-editor.sh");
+ common::write_fake_editor(&editor_path, "# only a comment, no body");
+
+ let output = Command::new(common::bin_path())
+ .current_dir(fixture.path())
+ .args(["review", "new", "--verdict", "approve", "--key"])
+ .arg(&fixture.key_path)
+ .env("GIT_EDITOR", &editor_path)
+ .env("EDITOR", &editor_path)
+ .output()
+ .expect("runs");
+ assert!(
+ !output.status.success(),
+ "an empty body must abort review creation: {output:?}"
+ );
+ assert_eq!(review::list(&root, None).expect("lists").len(), 0);
+}
+
/// `model.review`: withdrawing when this member has never reviewed
/// `target` (or an ancestor of it) is a clear refusal — there is nothing to
/// withdraw.
crates/forge/ents-forge/src/lib.rs
@@ -99,6 +99,7 @@
pub mod agent;
pub mod comment;
pub mod issue;
+pub mod present;
pub mod review;
pub use error::{Error, Result};
crates/kernel/ents-model/src/effect.rs
@@ -2,6 +2,7 @@
//!
//! Spec coverage: `model.effect-definition`.
+use ents_attrs as ents;
use facet::Facet;
/// A declarative effect definition, living at `refs/meta/effects/<name>`
@@ -39,12 +40,15 @@
/// The effect's own name — the natural key the refname's final segment
/// binds to (`model.effect-definition`, `meta-ref.identity-binding`):
/// the gate recomputes `refs/meta/effects/<name>` from this field.
+ #[facet(ents::skip)]
pub name: String,
/// The raw `CommitQuery` text denoting the commit set this effect
/// fires for (`query.grammar`).
+ #[facet(ents::col)]
pub trigger: String,
/// The names of the toolchains this effect's run requires, each a
/// `refs/meta/toolchains/<name>` reference (`model.toolchain`).
+ #[facet(ents::skip_empty)]
pub toolchains: Vec<String>,
/// The run command.
pub run: String,
crates/kernel/ents-model/src/result.rs
@@ -2,6 +2,7 @@
//!
//! Spec coverage: `model.result-taxonomy`, `model.result-identity`.
+use ents_attrs as ents;
use facet::Facet;
use gix_hash::ObjectId;
@@ -86,12 +87,15 @@
pub struct ResultRecord {
/// The name of the effect this result records — binds the refname's
/// `<effect>` segment (`model.result-identity`).
+ #[facet(ents::skip)]
pub effect: String,
/// The full oid of the commit the run judged, as a raw 20-byte SHA-1
/// array — binds the refname's `<short-oid>` segment
/// (`model.result-identity`).
+ #[facet(ents::skip)]
target: [u8; 20],
/// The run's outcome.
+ #[facet(ents::head)]
pub status: Status,
}
crates/cli/git-ents/src/commands/effect.rs
@@ -208,20 +208,21 @@
Ok(namespace::result_ref(name, short).expect("well-formed refname segments"))
}
-/// `git ents effect log`: every recorded result for `name`, newest first —
-/// the results ref's own commit log.
+/// `git ents effect log`: every recorded result for `name`, keyed by the
+/// full oid of the judged commit (the identity `model.result-identity`
+/// binds, and what `results(...)` queries match on).
///
/// # Errors
///
-/// [`Error::NotFound`] if `name` has no results yet.
-pub fn log(root: &LocalRoot, name: &str) -> Result<Vec<(gix_hash::ObjectId, Status)>> {
+/// Propagates a ref-store or object read failure.
+pub fn log(root: &LocalRoot, name: &str) -> Result<Vec<(gix_hash::ObjectId, ResultRecord)>> {
let prefix = format!("refs/meta/results/{name}/");
let mut out = Vec::new();
for entry in root.refs.iter_prefix(&prefix)? {
let (_, tip) = entry?;
let tree = super::commit_tree(&root.objects, tip)?;
if let Ok(record) = facet_git_tree::deserialize::<ResultRecord>(&tree, &root.objects) {
- out.push((tip, record.status));
+ out.push((record.target(), record));
}
}
Ok(out)
crates/cli/git-ents/src/commands/issue.rs
@@ -1,12 +1,9 @@
//! `git ents issue`: a thin wrapper around `ents_forge::issue`'s business
-//! logic, plus the one CLI-only piece that operation needs: composing a
-//! title and body in `$GIT_EDITOR`/`$EDITOR` when `--title` is omitted
-//! (mirroring `git commit`'s own editor fallback) — a frontend concern,
-//! not an operation `ents_forge::issue` offers (`lens.parity`).
+//! logic — the `$GIT_EDITOR` fallback for an omitted `--title` lives in
+//! [`crate::compose`], driven by the `ents::compose` attributes on
+//! [`ents_forge::issue::IssueAction`], not here (`lens.parity`).
-use std::io::Write as _;
use std::path::PathBuf;
-use std::process::Command;
use ents_forge::Issue;
use ents_forge::issue::{self, EditIssue, NewIssue};
@@ -14,7 +11,7 @@
use ents_receive::Identity;
use super::{actor, signer};
-use crate::error::{Error, Result};
+use crate::error::Result;
use crate::mutate::outcome_to_result;
use crate::root::LocalRoot;
@@ -37,30 +34,20 @@
Ok(issue::show(&root.refs, &root.objects, id)?)
}
-/// `git ents issue new`: create an issue. When `title` is `None`, composes
-/// the title and body interactively (see `compose_in_editor`).
+/// `git ents issue new`: create an issue.
///
/// # Errors
///
-/// [`Error::InvalidArgument`] if no title was given and the interactively
-/// composed message is empty (the editor path aborts, mirroring `git
-/// commit`'s own empty-message abort); [`Error::Io`] if the editor cannot
-/// be spawned or the scratch file cannot be read or written; otherwise see
-/// [`crate::mutate::outcome_to_result`].
+/// See [`crate::mutate::outcome_to_result`].
pub fn new(
root: &LocalRoot,
- title: Option<String>,
- body: Option<String>,
+ title: String,
+ body: String,
state: String,
labels: Vec<String>,
assignees: Vec<String>,
key: Option<PathBuf>,
) -> Result<String> {
- let (title, body) = match title {
- Some(title) => (title, body.unwrap_or_default()),
- None => compose_in_editor()?
- .ok_or_else(|| Error::InvalidArgument("empty issue message, aborting".into()))?,
- };
let signer = signer(root, key)?;
let identity = Identity {
actor: actor(&signer),
@@ -125,64 +112,3 @@
outcome_to_result(outcome, None)?;
Ok(())
}
-
-/// Compose a title (first line) and body (remaining lines) by opening
-/// `$GIT_EDITOR` (or `$EDITOR`, or `vi`) on a scratch file seeded with a
-/// `#`-prefixed instructions footer; lines starting with `#` are stripped
-/// on read-back. Returns `None` (mirroring `git commit`'s own
-/// empty-message abort) when the title line is empty after stripping.
-///
-/// # Errors
-///
-/// [`Error::Io`] if the scratch file cannot be created, written, or read,
-/// or the editor process cannot be spawned or exits with a failure status.
-fn compose_in_editor() -> Result<Option<(String, String)>> {
- let editor = std::env::var("GIT_EDITOR")
- .or_else(|_| std::env::var("EDITOR"))
- .unwrap_or_else(|_| "vi".to_owned());
-
- let mut file = tempfile::NamedTempFile::new().map_err(|source| Error::Io {
- path: std::env::temp_dir(),
- source,
- })?;
- let path = file.path().to_owned();
- writeln!(
- file,
- "\n# First line is the title, the rest is the body.\n\
- # Lines starting with '#' are stripped; an empty title aborts."
- )
- .map_err(|source| Error::Io {
- path: path.clone(),
- source,
- })?;
- file.flush().map_err(|source| Error::Io {
- path: path.clone(),
- source,
- })?;
-
- let status = Command::new(&editor)
- .arg(&path)
- .status()
- .map_err(|source| Error::Io {
- path: path.clone(),
- source,
- })?;
- if !status.success() {
- return Err(Error::Io {
- path: path.clone(),
- source: std::io::Error::other(format!("{editor} exited with {status}")),
- });
- }
-
- let contents = std::fs::read_to_string(&path).map_err(|source| Error::Io {
- path: path.clone(),
- source,
- })?;
- let mut lines = contents.lines().filter(|line| !line.starts_with('#'));
- let title = lines.next().unwrap_or("").trim();
- if title.is_empty() {
- return Ok(None);
- }
- let body = lines.collect::<Vec<_>>().join("\n");
- Ok(Some((title.to_owned(), body.trim_end().to_owned())))
-}
crates/cli/git-ents/tests/common/mod.rs
@@ -49,6 +49,22 @@
}
}
+/// Write an executable script at `path` that overwrites its one argument
+/// (the scratch file a composing command opens) with `contents` — a
+/// stand-in for a real `$EDITOR`, exercising the same spawn-and-read-back
+/// path a real editor would.
+pub fn write_fake_editor(path: &Path, contents: &str) {
+ let script = format!("#!/bin/sh\ncat > \"$1\" <<'EOF'\n{contents}\nEOF\n");
+ std::fs::write(path, script).expect("write fake editor");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+ let mut perms = std::fs::metadata(path).expect("metadata").permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(path, perms).expect("chmod");
+ }
+}
+
/// Write a deterministic key inside `dir` (as `.id_ed25519`) and return its
/// path — for tests that need a key living alongside a specific working
/// directory (a clone) rather than a [`Fixture`]'s own repo directory.
crates/forge/ents-forge/src/comment/cli.rs
@@ -8,6 +8,7 @@
use std::path::PathBuf;
+use ents_attrs as ents;
use facet::Facet;
use figue as args;
@@ -54,9 +55,11 @@
/// omit for a comment about a context or parent only.
#[facet(args::positional, default)]
path: Option<String>,
- /// The comment's body text.
- #[facet(args::named)]
- body: String,
+ /// The comment's body text; omit to compose it in
+ /// $GIT_EDITOR/$EDITOR instead (lines starting with '#' are
+ /// stripped, and an empty body aborts the command).
+ #[facet(args::named, ents::compose)]
+ body: Option<String>,
/// Lines to anchor, as `<start>[:<end>]` (1-based, inclusive);
/// omit for a whole-file comment.
#[facet(args::named)]
crates/forge/ents-forge/src/comment/entity.rs
@@ -4,6 +4,7 @@
//! Spec coverage: `model.comment`, `model.comment-state`,
//! `model.comment-context`, `model.comment-thread`.
+use ents_attrs as ents;
use facet::Facet;
use facet_git_tree::RawTree;
use gix_hash::ObjectId;
@@ -56,24 +57,31 @@
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct Comment {
/// The comment's text.
+ #[facet(ents::col, ents::body)]
pub body: String,
/// The comment's state (`model.comment-state`): `open` for a new
/// comment, `resolved` once resolved — not a fixed enum, because
/// custom states are schema, not platform features, exactly as for
/// issues (`model.issue`).
+ #[facet(ents::head)]
pub state: String,
/// The anchor identifying the exact content the comment was written
/// against (`anchor.definition`), opaque to this crate; `None` for a
- /// comment about a context entity or a parent comment only.
+ /// comment about a context entity or a parent comment only. Never
+ /// rendered generically (`ents::skip`): its projection is a bespoke,
+ /// domain-specific line on every surface.
+ #[facet(ents::skip)]
pub anchor: Option<RawTree>,
/// The canonical ref path below `refs/meta/` of the entity this
/// comment belongs to, such as `issues/<id>` or `reviews/<target>/<member>`
/// (`model.comment-context`) — an entity's thread is an aggregation
/// query over comments naming it, never a list the entity stores.
+ #[facet(ents::skip_empty)]
pub context: Option<String>,
/// The id of the comment this one replies to (`model.comment-thread`);
/// a reply inherits its aboutness from its thread root rather than
/// repeating an anchor or context.
+ #[facet(ents::skip_empty, ents::id)]
pub parent: Option<String>,
}
crates/forge/ents-forge/src/issue/cli.rs
@@ -7,6 +7,7 @@
use std::path::PathBuf;
+use ents_attrs as ents;
use facet::Facet;
use figue as args;
@@ -15,7 +16,17 @@
#[repr(u8)]
pub enum IssueAction {
/// List the issues recorded in this repository.
- List,
+ ///
+ /// With --porcelain, emits a stable machine-readable form:
+ /// blank-line-separated records, each starting with the line
+ /// `<id> <state>` (the full id, never abbreviated), followed by a
+ /// `title <title>` line, `assignees <a, b>` and `labels <x, y>` lines
+ /// when non-empty, then the body with every line prefixed by one tab.
+ List {
+ /// Emit the stable machine-readable form described above.
+ #[facet(args::named, default)]
+ porcelain: bool,
+ },
/// Show one issue.
Show {
/// The issue's id.
@@ -29,11 +40,11 @@
New {
/// The issue's title; omit to compose it (and the body) in an
/// editor instead.
- #[facet(args::named)]
+ #[facet(args::named, ents::compose)]
title: Option<String>,
/// The issue's body; ignored (and instead composed in the editor)
/// when --title is omitted.
- #[facet(args::named)]
+ #[facet(args::named, ents::compose)]
body: Option<String>,
/// The issue's initial state.
#[facet(args::named, default = "open")]
crates/forge/ents-forge/src/issue/entity.rs
@@ -2,6 +2,7 @@
//!
//! Spec coverage: `model.issue`.
+use ents_attrs as ents;
use facet::Facet;
use ents_model::MemberId;
@@ -38,15 +39,20 @@
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct Issue {
/// The issue's title.
+ #[facet(ents::col)]
pub title: String,
/// The issue's body.
+ #[facet(ents::body)]
pub body: String,
/// The issue's current state. Not a fixed enum: custom states are
/// schema, not a platform feature (`model.issue`).
+ #[facet(ents::head)]
pub state: String,
/// The members assigned to the issue. More than one is ordinary.
+ #[facet(ents::skip_empty)]
pub assignees: Vec<MemberId>,
/// Free-form labels.
+ #[facet(ents::skip_empty)]
pub labels: Vec<String>,
}
crates/forge/ents-forge/src/review/cli.rs
@@ -7,6 +7,7 @@
use std::path::PathBuf;
+use ents_attrs as ents;
use facet::Facet;
use figue as args;
@@ -28,9 +29,11 @@
/// values are schema, not a platform feature.
#[facet(args::named)]
verdict: String,
- /// The review's body text.
- #[facet(args::named)]
- body: String,
+ /// The review's body text; omit to compose it in
+ /// $GIT_EDITOR/$EDITOR instead (lines starting with '#' are
+ /// stripped, and an empty body aborts the command).
+ #[facet(args::named, ents::compose)]
+ body: Option<String>,
/// Key to sign with; defaults to `user.signingkey`.
#[facet(args::named)]
key: Option<PathBuf>,
@@ -51,10 +54,20 @@
key: Option<PathBuf>,
},
/// List the reviews recorded in this repository.
+ ///
+ /// With --porcelain, emits a stable machine-readable form:
+ /// blank-line-separated records, each starting with the line
+ /// `<target> <member> <reviewed> <verdict> <state>` — target the
+ /// review's full genesis-oid ref segment, reviewed the full oid of the
+ /// most recently reviewed commit — followed by the body with every
+ /// line prefixed by one tab.
List {
/// Keep only reviews of this revision.
#[facet(args::named)]
target: Option<String>,
+ /// Emit the stable machine-readable form described above.
+ #[facet(args::named, default)]
+ porcelain: bool,
},
/// Show one review: its reviewed commit, verdict, body, and discussion
/// thread (comments naming it as their context).
crates/forge/ents-forge/src/review/entity.rs
@@ -3,6 +3,7 @@
//!
//! Spec coverage: `model.review`.
+use ents_attrs as ents;
use facet::Facet;
use gix_hash::ObjectId;
@@ -167,11 +168,14 @@
// @relation(model.review, meta-ref.identity-binding, meta-ref.typed-tree, model.extensibility, scope=file)
#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct Review {
+ #[facet(ents::head, ents::id)]
target: [u8; 20],
/// The review's verdict (`model.review`): a fixed [`Verdict`], not a
/// string.
+ #[facet(ents::head)]
pub verdict: Verdict,
/// The review's body text.
+ #[facet(ents::body)]
pub body: String,
/// Whether this review still stands or has been withdrawn
/// (`model.review`). `#[facet(default)]` so a tree written before this
@@ -179,7 +183,7 @@
/// [`ReviewState::Active`] rather than failing to decode; every
/// existing `refs/meta/reviews/*` history predates this field and must
/// keep reading.
- #[facet(default)]
+ #[facet(default, ents::head)]
pub state: ReviewState,
}
crates/cli/git-ents/src/compose.rs
@@ -1,0 +1,129 @@
+//! Attribute-driven `$GIT_EDITOR`/`$EDITOR` composition: an action
+//! variant marks its message-carrying fields `#[facet(ents::compose)]`,
+//! and this module — reading only the variant's [`facet::Shape`], never a
+//! per-command branch — opens the editor when those flags were omitted,
+//! mirroring `git commit`'s own editor fallback and its empty-message
+//! abort. A frontend concern, deliberately not an `ents-forge` operation
+//! (`lens.parity`).
+
+use std::io::Write as _;
+use std::process::Command;
+
+use facet::{Facet, Type, UserType};
+
+use crate::error::{Error, Result};
+
+/// Resolve `title` and `body` for a variant whose `title` and `body`
+/// fields are compose-marked: given values pass through; with no title,
+/// the editor composes both (first line title, rest body).
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] if the variant's fields are not
+/// compose-marked (the flag is simply required) or the composed title is
+/// empty; [`Error::Io`] if the editor cannot run.
+pub fn title_body<T: Facet<'static>>(
+ variant: &str,
+ title: Option<String>,
+ body: Option<String>,
+) -> Result<(String, String)> {
+ if let Some(title) = title {
+ return Ok((title, body.unwrap_or_default()));
+ }
+ require_compose::<T>(variant, "title")?;
+ let message = editor_message(
+ "# First line is the title, the rest is the body.\n\
+ # Lines starting with '#' are stripped; an empty title aborts.",
+ )?;
+ let mut lines = message.lines();
+ let title = lines.next().unwrap_or("").trim();
+ if title.is_empty() {
+ return Err(Error::InvalidArgument("empty message, aborting".into()));
+ }
+ let body = lines.collect::<Vec<_>>().join("\n");
+ Ok((title.to_owned(), body.trim().to_owned()))
+}
+
+/// Resolve `body` for a variant whose `body` field is compose-marked:
+/// a given value passes through; with none, the editor composes it.
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] if the field is not compose-marked (the
+/// flag is simply required) or the composed body is empty; [`Error::Io`]
+/// if the editor cannot run.
+pub fn body<T: Facet<'static>>(variant: &str, body: Option<String>) -> Result<String> {
+ if let Some(body) = body {
+ return Ok(body);
+ }
+ require_compose::<T>(variant, "body")?;
+ let message = editor_message(
+ "# Compose the body. Lines starting with '#' are stripped;\n\
+ # an empty body aborts.",
+ )?;
+ let body = message.trim();
+ if body.is_empty() {
+ return Err(Error::InvalidArgument("empty message, aborting".into()));
+ }
+ Ok(body.to_owned())
+}
+
+/// Refuse unless `T`'s variant marks `field` with `ents::compose` — the
+/// attribute on the action enum, not this module, is what licenses the
+/// editor fallback; an unmarked omitted flag is simply a missing argument.
+fn require_compose<T: Facet<'static>>(variant: &str, field: &str) -> Result<()> {
+ let marked = match T::SHAPE.ty {
+ Type::User(UserType::Enum(shape)) => shape
+ .variants
+ .iter()
+ .find(|candidate| candidate.name == variant)
+ .is_some_and(|found| {
+ found
+ .data
+ .fields
+ .iter()
+ .any(|f| f.name == field && f.has_attr(Some("ents"), "compose"))
+ }),
+ _ => false,
+ };
+ if marked {
+ Ok(())
+ } else {
+ Err(Error::InvalidArgument(format!("--{field} is required")))
+ }
+}
+
+/// Open `$GIT_EDITOR` (or `$EDITOR`, or `vi`) on a scratch file seeded
+/// with `instructions`, returning its content with `#` lines stripped.
+fn editor_message(instructions: &str) -> Result<String> {
+ let editor = std::env::var("GIT_EDITOR")
+ .or_else(|_| std::env::var("EDITOR"))
+ .unwrap_or_else(|_| "vi".to_owned());
+
+ let io_error = |path: &std::path::Path| {
+ let path = path.to_owned();
+ move |source| Error::Io { path, source }
+ };
+ let mut file = tempfile::NamedTempFile::new().map_err(io_error(&std::env::temp_dir()))?;
+ let path = file.path().to_owned();
+ writeln!(file, "\n{instructions}").map_err(io_error(&path))?;
+ file.flush().map_err(io_error(&path))?;
+
+ let status = Command::new(&editor)
+ .arg(&path)
+ .status()
+ .map_err(io_error(&path))?;
+ if !status.success() {
+ return Err(Error::Io {
+ path,
+ source: std::io::Error::other(format!("{editor} exited with {status}")),
+ });
+ }
+
+ let contents = std::fs::read_to_string(&path).map_err(io_error(&path))?;
+ Ok(contents
+ .lines()
+ .filter(|line| !line.starts_with('#'))
+ .collect::<Vec<_>>()
+ .join("\n"))
+}
crates/cli/git-ents/tests/effect.rs
@@ -1,0 +1,109 @@
+//! Integration coverage for `git ents effect list`/`log` output against a
+//! real local composition root (`roots.local`): the human listing and the
+//! stable `--porcelain` record grammar (`lens.parity`), both derived from
+//! [`ents_model::Effect`]'s and [`ents_model::ResultRecord`]'s own
+//! `#[facet(ents::...)]`-annotated shapes.
+
+#![allow(clippy::expect_used, reason = "integration test")]
+
+mod common;
+
+use std::process::Command;
+
+use ents_model::Status;
+use git_ents::commands::effect;
+use git_ents::root::LocalRoot;
+
+fn run(fixture: &common::Fixture, args: &[&str]) -> String {
+ let output = Command::new(common::bin_path())
+ .current_dir(fixture.path())
+ .args(args)
+ .output()
+ .expect("runs");
+ assert!(output.status.success(), "{output:?}");
+ String::from_utf8(output.stdout).expect("utf8")
+}
+
+/// `git ents effect list --porcelain` emits the stable record grammar
+/// (`lens.parity`): a `<name>` head line, then `trigger`, `toolchains`
+/// (omitted when empty), and `run` keyed lines, records blank-line
+/// separated; the human listing stays `<name>\t<trigger>`.
+// @relation(lens.parity, model.effect-definition, roots.local, scope=function, role=Verifies)
+#[test]
+fn effect_list_porcelain_emits_one_record_per_definition() {
+ let fixture = common::Fixture::new(1);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ effect::add(
+ &root,
+ "unit",
+ "rev(refs/heads/main)".to_owned(),
+ "cargo test".to_owned(),
+ vec![],
+ Some(fixture.key_path.clone()),
+ )
+ .expect("defines");
+ effect::add(
+ &root,
+ "with-tools",
+ "rev(refs/heads/main)".to_owned(),
+ "cargo build".to_owned(),
+ vec!["rust-stable".to_owned(), "node-lts".to_owned()],
+ Some(fixture.key_path.clone()),
+ )
+ .expect("defines");
+
+ assert_eq!(
+ run(&fixture, &["effect", "list", "--porcelain"]),
+ "unit\ntrigger rev(refs/heads/main)\nrun cargo test\n\
+ \n\
+ with-tools\ntrigger rev(refs/heads/main)\ntoolchains rust-stable, node-lts\nrun cargo build\n"
+ );
+ assert_eq!(
+ run(&fixture, &["effect", "list"]),
+ "unit\trev(refs/heads/main)\nwith-tools\trev(refs/heads/main)\n"
+ );
+}
+
+/// `git ents effect log --porcelain` emits one `<commit> <status>` record
+/// per judged commit, the commit's full oid (`model.result-identity`,
+/// `lens.parity`); the human listing abbreviates it like git does.
+// @relation(lens.parity, model.result-identity, roots.local, scope=function, role=Verifies)
+#[test]
+fn effect_log_porcelain_carries_the_full_judged_commit_oid() {
+ let fixture = common::Fixture::new(2);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+
+ let target = gix_hash::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567")
+ .expect("valid hex");
+ let signer = git_ents::sign::Signer::load(&fixture.key_path).expect("loads");
+ let author = gix::actor::Signature {
+ name: "worker".into(),
+ email: "worker@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ };
+ let results_ref =
+ ents_model::namespace::result_ref("unit", &ents_effect::run::short_oid(target))
+ .expect("valid refname");
+ ents_effect::write_result(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ results_ref,
+ "unit",
+ target,
+ Status::Pass,
+ &author,
+ |payload| signer.sign(payload),
+ ents_receive::Mode::Advisory,
+ )
+ .expect("records");
+
+ assert_eq!(
+ run(&fixture, &["effect", "log", "unit", "--porcelain"]),
+ "0123456789abcdef0123456789abcdef01234567 pass\n"
+ );
+ assert_eq!(run(&fixture, &["effect", "log", "unit"]), "0123456\tpass\n");
+}
crates/forge/ents-forge/src/present.rs
@@ -1,0 +1,416 @@
+//! Schema-driven entity output: one reflection walk over any
+//! `#[derive(Facet)]` entity's [`facet::Shape`], presentation policy read
+//! from the `ents` attributes declared on the entity's own fields
+//! ([`ents_attrs::Attr`]) — never from a branch on the concrete entity
+//! type. The CLI derives its `show` lines, `list` columns, and porcelain
+//! records here; a surface that genuinely needs a domain-specific line (a
+//! comment's projected anchor, a review's thread) appends it beside this
+//! module's output rather than reaching into the walk.
+
+use facet::{Facet, Field, Peek};
+
+/// One rendered field: its declared name and display value.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FieldLine {
+ /// The field's declared name, exactly as the struct spells it.
+ pub name: &'static str,
+ /// The field's rendered value.
+ pub value: String,
+}
+
+/// An entity's `show` view: `field: value` lines in declaration order,
+/// with the `ents::body`-marked field split out so a caller can interleave
+/// domain-specific lines before it. Its `Display` renders lines then body.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct View {
+ /// Every non-skipped, non-body field's line, in declaration order.
+ pub lines: Vec<FieldLine>,
+ /// The `ents::body` field's line, rendered last.
+ pub body: Option<FieldLine>,
+}
+
+impl std::fmt::Display for View {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ for line in self.lines.iter().chain(&self.body) {
+ writeln!(f, "{}: {}", line.name, line.value)?;
+ }
+ Ok(())
+ }
+}
+
+/// Reflect `value` into its human `show` view: one `field: value` line per
+/// non-skipped field, empties omitted where `ents::skip_empty` says so,
+/// id-valued fields abbreviated, the `ents::body` field split out last.
+///
+/// # Examples
+///
+/// ```
+/// let issue = ents_forge::Issue {
+/// title: "gate rejects a valid signature".to_owned(),
+/// body: "steps to reproduce...".to_owned(),
+/// state: "open".to_owned(),
+/// assignees: vec![],
+/// labels: vec!["bug".to_owned(), "gate".to_owned()],
+/// };
+/// let rendered = ents_forge::present::view(&issue).to_string();
+/// assert_eq!(
+/// rendered,
+/// "title: gate rejects a valid signature\nstate: open\nlabels: bug, gate\nbody: steps to reproduce...\n"
+/// );
+/// ```
+#[must_use]
+pub fn view<T: Facet<'static>>(value: &T) -> View {
+ let mut lines = Vec::new();
+ let mut body = None;
+ for row in rows(value, Audience::Human) {
+ if row.policy.skip_empty && row.empty {
+ continue;
+ }
+ let line = FieldLine {
+ name: row.name,
+ value: row.value,
+ };
+ if row.policy.body {
+ body = Some(line);
+ } else {
+ lines.push(line);
+ }
+ }
+ View { lines, body }
+}
+
+/// Reflect `value` into its human `list` columns: `ents::head` fields
+/// first, then `ents::col` fields, each set in declaration order,
+/// id-valued fields abbreviated. The caller prepends the row's own id
+/// column(s) and joins with tabs.
+///
+/// # Examples
+///
+/// ```
+/// let issue = ents_forge::Issue {
+/// title: "gate rejects a valid signature".to_owned(),
+/// body: String::new(),
+/// state: "open".to_owned(),
+/// assignees: vec![],
+/// labels: vec![],
+/// };
+/// assert_eq!(
+/// ents_forge::present::columns(&issue),
+/// vec!["open".to_owned(), "gate rejects a valid signature".to_owned()]
+/// );
+/// ```
+#[must_use]
+pub fn columns<T: Facet<'static>>(value: &T) -> Vec<String> {
+ let rows = rows(value, Audience::Human);
+ let heads = rows.iter().filter(|row| row.policy.head);
+ let cols = rows.iter().filter(|row| row.policy.col && !row.policy.head);
+ heads.chain(cols).map(|row| row.value.clone()).collect()
+}
+
+/// Reflect `value` into one porcelain record (`lens.parity`), the record
+/// grammar `git ents comment list --porcelain` established: a head line of
+/// `id` then each `ents::head` field's value, space-separated; one
+/// `<name> <value>` line per remaining field (omitted when
+/// `ents::skip_empty` and empty); the `ents::body` field's lines each
+/// tab-prefixed. Ids render full, never abbreviated.
+///
+/// # Examples
+///
+/// ```
+/// let effect = ents_model::Effect {
+/// name: "unit".to_owned(),
+/// trigger: "rev(refs/heads/main)".to_owned(),
+/// toolchains: vec![],
+/// run: "cargo test".to_owned(),
+/// };
+/// assert_eq!(
+/// ents_forge::present::record("unit", &effect),
+/// "unit\ntrigger rev(refs/heads/main)\nrun cargo test\n"
+/// );
+/// ```
+#[must_use]
+pub fn record<T: Facet<'static>>(id: &str, value: &T) -> String {
+ let rows = rows(value, Audience::Porcelain);
+ let mut out = id.to_owned();
+ for row in rows.iter().filter(|row| row.policy.head) {
+ out.push(' ');
+ out.push_str(&row.value);
+ }
+ out.push('\n');
+ for row in &rows {
+ if row.policy.head || row.policy.body || (row.policy.skip_empty && row.empty) {
+ continue;
+ }
+ out.push_str(row.name);
+ out.push(' ');
+ out.push_str(&row.value);
+ out.push('\n');
+ }
+ if let Some(body) = rows.iter().find(|row| row.policy.body) {
+ for line in body.value.lines() {
+ out.push('\t');
+ out.push_str(line);
+ out.push('\n');
+ }
+ }
+ out
+}
+
+/// [`record`] over every `(id, entity)` row, records separated by one
+/// blank line — the whole `--porcelain` output for a listing.
+#[must_use]
+pub fn porcelain<T: Facet<'static>>(rows: &[(String, T)]) -> String {
+ rows.iter()
+ .map(|(id, value)| record(id, value))
+ .collect::<Vec<_>>()
+ .join("\n")
+}
+
+/// Who the rendering is for: humans get abbreviated ids, porcelain full.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Audience {
+ Human,
+ Porcelain,
+}
+
+/// The presentation roles one field declares via `#[facet(ents::...)]` —
+/// the parsed form of [`ents_attrs::Attr`], read once per field.
+#[derive(Default, Clone, Copy)]
+struct FieldPolicy {
+ skip: bool,
+ head: bool,
+ col: bool,
+ skip_empty: bool,
+ id: bool,
+ body: bool,
+}
+
+impl FieldPolicy {
+ fn of(field: &Field) -> Self {
+ let has = |key: &str| field.has_attr(Some("ents"), key);
+ Self {
+ skip: has("skip"),
+ head: has("head"),
+ col: has("col"),
+ skip_empty: has("skip_empty"),
+ id: has("id"),
+ body: has("body"),
+ }
+ }
+}
+
+/// One walked field: its policy, name, rendered value, and emptiness.
+struct Row {
+ policy: FieldPolicy,
+ name: &'static str,
+ value: String,
+ empty: bool,
+}
+
+/// Walk `value`'s shape into one [`Row`] per non-`ents::skip` field, in
+/// declaration order. A non-struct `T` yields no rows — reflection is a
+/// presentation convenience, never a correctness path.
+fn rows<T: Facet<'static>>(value: &T, audience: Audience) -> Vec<Row> {
+ let peek = Peek::new(value);
+ let Ok(structure) = peek.into_struct() else {
+ return Vec::new();
+ };
+ structure
+ .ty()
+ .fields
+ .iter()
+ .enumerate()
+ .filter_map(|(index, field)| {
+ let policy = FieldPolicy::of(field);
+ if policy.skip {
+ return None;
+ }
+ let peek = structure.field(index).ok()?;
+ Some(Row {
+ policy,
+ name: field.name,
+ value: render(peek, policy, audience),
+ empty: is_empty(peek),
+ })
+ })
+ .collect()
+}
+
+/// Render one field's value: id-valued fields as full-or-abbreviated ids
+/// (raw 20-byte oids as hex), otherwise [`scalar`].
+fn render(peek: Peek<'_, '_>, policy: FieldPolicy, audience: Audience) -> String {
+ if !policy.id {
+ return scalar(peek);
+ }
+ let full = peek
+ .get::<[u8; 20]>()
+ .map(|bytes| bytes.iter().map(|byte| format!("{byte:02x}")).collect())
+ .unwrap_or_else(|_| scalar(peek));
+ match audience {
+ Audience::Human => crate::abbreviate_id(&full).to_owned(),
+ Audience::Porcelain => full,
+ }
+}
+
+/// Render one value as plain text: a `str` verbatim, an `Option` as its
+/// inner value (or empty), a list as its items joined with `", "`, and
+/// anything else via its own `Display` — falling back to `Debug` so an
+/// enum without `Display` still shows its variant name rather than an
+/// opaque placeholder (the same rule `ents-web`'s renderer applies).
+fn scalar(peek: Peek<'_, '_>) -> String {
+ if let Some(text) = peek.as_str() {
+ return text.to_owned();
+ }
+ if let Ok(option) = peek.into_option() {
+ return option.value().map(scalar).unwrap_or_default();
+ }
+ if let Ok(list) = peek.into_list_like() {
+ return list.iter().map(scalar).collect::<Vec<_>>().join(", ");
+ }
+ let displayed = format!("{peek}");
+ if displayed.starts_with('\u{27e8}') {
+ format!("{peek:?}")
+ } else {
+ displayed
+ }
+}
+
+/// Whether a value counts as empty for `ents::skip_empty`: an empty
+/// string, a `None`, or an empty list.
+fn is_empty(peek: Peek<'_, '_>) -> bool {
+ if let Some(text) = peek.as_str() {
+ return text.is_empty();
+ }
+ if let Ok(option) = peek.into_option() {
+ return option.is_none();
+ }
+ if let Ok(list) = peek.into_list_like() {
+ return list.is_empty();
+ }
+ false
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use ents_model::MemberId;
+ use gix_hash::ObjectId;
+ use rstest::rstest;
+
+ use super::*;
+ use crate::Issue;
+ use crate::comment::Comment;
+ use crate::review::{Review, Verdict};
+
+ fn issue() -> Issue {
+ Issue {
+ title: "gate rejects a valid signature".to_owned(),
+ body: "first line\n\nthird line".to_owned(),
+ state: "open".to_owned(),
+ assignees: vec![MemberId::new("jdc"), MemberId::new("alice")],
+ labels: vec![],
+ }
+ }
+
+ fn review() -> Review {
+ let target =
+ ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").expect("valid hex");
+ Review::new(target, Verdict::RequestChanges, "please fix")
+ }
+
+ /// The whole walk is attribute-driven: the same [`view`] call renders
+ /// an issue and a comment with each one's own field policy, no branch
+ /// on the concrete type anywhere in this module.
+ #[rstest]
+ // @relation(model.issue, model.comment, scope=function, role=Verifies)
+ fn view_orders_lines_by_declaration_with_the_body_last_and_empties_skipped() {
+ let rendered = view(&issue()).to_string();
+ assert_eq!(
+ rendered,
+ "title: gate rejects a valid signature\nstate: open\nassignees: jdc, alice\nbody: first line\n\nthird line\n"
+ );
+
+ let comment = Comment {
+ body: "looks off".to_owned(),
+ state: "open".to_owned(),
+ anchor: None,
+ context: None,
+ parent: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
+ };
+ assert_eq!(
+ view(&comment).to_string(),
+ "state: open\nparent: 0123456\nbody: looks off\n"
+ );
+ }
+
+ /// `model.issue`: human columns abbreviate id-valued fields the way
+ /// git abbreviates oids; head columns lead, then plain columns.
+ #[rstest]
+ // @relation(model.issue, model.review, scope=function, role=Verifies)
+ fn columns_lead_with_head_fields_and_abbreviate_ids() {
+ assert_eq!(
+ columns(&issue()),
+ vec![
+ "open".to_owned(),
+ "gate rejects a valid signature".to_owned()
+ ]
+ );
+ assert_eq!(
+ columns(&review()),
+ vec![
+ "0123456".to_owned(),
+ "request-changes".to_owned(),
+ "active".to_owned()
+ ]
+ );
+ }
+
+ /// `lens.parity`, `model.issue`: a porcelain record carries the full
+ /// id and full field values on a space-separated head line, keyed
+ /// lines for the rest, and the body tab-prefixed line by line.
+ #[rstest]
+ // @relation(lens.parity, model.issue, model.review, scope=function, role=Verifies)
+ fn record_renders_full_ids_keyed_lines_and_a_tab_prefixed_body() {
+ let id = "89abcdef0123456789abcdef0123456789abcdef";
+ assert_eq!(
+ record(id, &issue()),
+ format!(
+ "{id} open\ntitle gate rejects a valid signature\nassignees jdc, alice\n\tfirst line\n\t\n\tthird line\n"
+ )
+ );
+ assert_eq!(
+ record("0123456789abcdef0123456789abcdef01234567 jdc", &review()),
+ "0123456789abcdef0123456789abcdef01234567 jdc \
+ 0123456789abcdef0123456789abcdef01234567 request-changes active\n\tplease fix\n"
+ );
+ }
+
+ /// Records separate with exactly one blank line, mirroring the comment
+ /// porcelain grammar.
+ #[rstest]
+ // @relation(lens.parity, scope=function, role=Verifies)
+ fn porcelain_separates_records_with_one_blank_line() {
+ let rows = vec![
+ ("a".repeat(40), issue()),
+ ("b".repeat(40), issue()),
+ ];
+ let rendered = porcelain(&rows);
+ assert_eq!(rendered.split("\n\n").count(), 2, "{rendered}");
+ assert!(
+ rendered.contains(&format!("\tthird line\n\n{}", "b".repeat(40))),
+ "a blank body line renders as a lone tab, so only the record \
+ separator is a true blank line: {rendered}"
+ );
+ assert!(rendered.ends_with("third line\n"));
+ }
+
+ /// A non-struct value yields no rows, never a panic — reflection is a
+ /// presentation convenience, not a correctness path.
+ #[rstest]
+ fn a_non_struct_value_renders_as_nothing() {
+ let empty = view(&42u32);
+ assert!(empty.lines.is_empty() && empty.body.is_none());
+ assert!(columns(&42u32).is_empty());
+ }
+}
crates/kernel/ents-attrs/src/lib.rs
@@ -1,0 +1,40 @@
+//! The `ents` attribute namespace: presentation policy declared once, on
+//! an entity's own fields, and read generically by any surface walking its
+//! [`facet::Shape`] — never by matching on the concrete entity type.
+//!
+//! A separate crate for the same reason `figue`'s own attribute crate is:
+//! a macro-expanded `#[macro_export]` macro cannot be referred to by
+//! absolute path from the crate that expands it, so the entity crates
+//! (`ents-model`, `ents-forge`) could not annotate their own fields if the
+//! grammar lived in either of them. Use as `use ents_attrs as ents;`, then
+//! `#[facet(ents::head)]` etc.
+
+extern crate self as ents_attrs;
+
+facet::define_attr_grammar! {
+ ns "ents";
+ crate_path ::ents_attrs;
+
+ /// Presentation roles a field declares via `#[facet(ents::...)]`.
+ pub enum Attr {
+ /// Never rendered generically: identity bound into the refname, or
+ /// domain-rendered by a bespoke line.
+ Skip,
+ /// A porcelain head-line token (values must be single words); also
+ /// a leading human list column.
+ Head,
+ /// A human list column, after the head columns.
+ Col,
+ /// Omitted from show and porcelain when the value is empty.
+ SkipEmpty,
+ /// An id value: 20 raw bytes render as hex; abbreviated in human
+ /// output, full in porcelain.
+ Id,
+ /// The message body: the last `field: value` line of show, the
+ /// tab-indented block of a porcelain record.
+ Body,
+ /// A compose field on an action variant: filled by
+ /// $GIT_EDITOR/$EDITOR when its flag is omitted.
+ Compose,
+ }
+}