git-ents.gitmain
⌘K
foforge
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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 28 days ago

Reviews

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

Start a review

verdict

Cargo.lock @@ -1239,6 +1239,13 @@ "thiserror 2.0.18", ] +[[package]] +name = "ents-attrs" +version = "0.0.0" +dependencies = [ + "facet", +] + [[package]] name = "ents-effect" version = "0.0.0" @@ -1264,6 +1271,7 @@ version = "0.0.0" dependencies = [ "ents-anchor", + "ents-attrs", "ents-model", "ents-receive", "ents-testutil", @@ -1353,6 +1361,7 @@ version = "0.0.0" dependencies = [ "ents-anchor", + "ents-attrs", "facet", "facet-git-tree", "gix",
Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/kernel/ents-anchor", + "crates/kernel/ents-attrs", "crates/kernel/ents-effect", "crates/kernel/ents-gate", "crates/kernel/ents-gate-rules", @@ -30,6 +31,7 @@ [workspace.dependencies] ents-anchor = { path = "crates/kernel/ents-anchor" } +ents-attrs = { path = "crates/kernel/ents-attrs" } ents-effect = { path = "crates/kernel/ents-effect" } ents-forge = { path = "crates/forge/ents-forge" } ents-gate = { path = "crates/kernel/ents-gate" }
crates/forge/ents-forge/Cargo.toml @@ -7,6 +7,7 @@ [dependencies] ents-anchor = { workspace = true } +ents-attrs = { workspace = true } ents-model = { workspace = true } ents-receive = { workspace = true } facet = { workspace = true }
crates/kernel/ents-model/Cargo.toml @@ -7,6 +7,7 @@ [dependencies] ents-anchor = { workspace = true } +ents-attrs = { workspace = true } facet = { workspace = true } facet-git-tree = { workspace = true } gix = { workspace = true }
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/Cargo.toml @@ -1,0 +1,12 @@ +[package] +name = "ents-attrs" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +facet = { workspace = true } + +[lints] +workspace = true
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, + } +}