git-ents.gitmain
⌘K
foforge
lib.rs205 lines · 8.3 KB · rusthistorycomment on this file
1//! The forge domain: the [`Issue`], [`comment::Comment`],
2//! and [`review::Review`] entities, and the
3//! command business logic driving each — kernel-independent, unlike
4//! `ents-model`'s remaining entities,
5//! because a comment or review command needs `ents-anchor` (to capture and
6//! project a code anchor) and `ents-receive` (to propose the mutation),
7//! neither of which a purely declarative vocabulary crate like
8//! `ents-model` may depend on.
9//!
10//! This crate sits *above* the kernel in the dependency graph, not inside
11//! it: `ents-model`, `ents-anchor`, `ents-gate`, `ents-query`,
12//! `ents-receive`, `ents-effect`, `ents-sync`, and `ents-testutil` must
13//! never depend on `ents-forge` (verified by `grep -rn ents-forge
14//! crates/kernel crates/substrate` finding nothing) — `ents-forge` depends
15//! on them, never the reverse. `git-ents` (the CLI) depends on this crate
16//! and mounts each command through a thin wrapper that only adds
17//! signer/actor construction and CLI-facing error rendering
18//! (`crate::mutate::outcome_to_result` on the CLI side).
19//!
20//! # Spec coverage
21//!
22//! From `docs/spec/model.adoc` and `docs/spec/meta-ref.adoc`:
23//!
24//! - `model.issue` — [`Issue`] and the command layer around it
25//! ([`issue::new`], [`issue::edit`], [`issue::list`], [`issue::show`]).
26//! - `model.comment`, `model.comment-state`, `model.comment-context`,
27//! `model.comment-thread` — [`comment::Comment`] and the command layer
28//! around it ([`comment::add`], [`comment::reply`],
29//! [`comment::resolve`]/[`comment::reopen`], [`comment::thread`]).
30//! - `model.review`, `model.review-pin` — [`review::Review`] and
31//! [`review::new`] (writes both the entity ref and the retention pin),
32//! [`review::list`], [`review::show`] (reusing [`comment::thread`] for
33//! the review's discussion rather than a second aggregation).
34//! - `meta-ref.granularity`, `meta-ref.identity-binding` — one ref per
35//! issue/comment (`refs/meta/issues/<id>`, `refs/meta/comments/<id>`,
36//! `<id>` the oid of the entity's own genesis commit), and one ref per
37//! `(target, reviewer)` composite key for a review
38//! (`refs/meta/reviews/<target>/<member>`); see [`comment::add`],
39//! [`issue::new`], and [`review::new`] for how each id derives from the
40//! signed content itself rather than being minted.
41//! - `meta-ref.typed-tree` — every entity module's round-trip test.
42//! - `anchor.definition`, `anchor.projection`, `anchor.working-tree` —
43//! [`comment::add`], [`comment::show`], and [`comment::list_projected`],
44//! built directly on `ents_anchor::capture`/`capture_worktree` and
45//! `project`/`project_worktree`.
46//! - `lens.parity` — every operation the CLI, the web UI, or an editor
47//! lens offers over these entities is one of this crate's library
48//! functions; frontends only wire stores and render.
49//!
50//! # Examples
51//!
52//! Build an [`Issue`], and a [`comment::Comment`] anchored to a stand-in
53//! tree (`ents-anchor` owns capturing a real anchor from a repository;
54//! this crate only defines the entity slot and the command driving it) —
55//! both round-trip through `facet-git-tree` unchanged, the
56//! schema-is-the-struct property `meta-ref.typed-tree` requires.
57//!
58//! ```
59//! use ents_forge::Issue;
60//! use ents_forge::comment::Comment;
61//! use ents_model::MemberId;
62//! use facet_git_tree::RawTree;
63//! use gix_object::Write as _;
64//!
65//! let issue = Issue {
66//! title: "gate rejects a valid signature".to_owned(),
67//! body: "steps to reproduce...".to_owned(),
68//! state: "triaged".to_owned(),
69//! assignees: vec![MemberId::new("jdc")],
70//! labels: vec!["bug".to_owned()],
71//! };
72//! let (id, store) = facet_git_tree::serialize(&issue).expect("serialize");
73//! let back: Issue = facet_git_tree::deserialize(&id, &store).expect("deserialize");
74//! assert_eq!(back, issue);
75//!
76//! let store = facet_git_tree::ObjectStore::default();
77//! let anchor_tree = store.write(&gix_object::Tree { entries: vec![] }).expect("tree");
78//! let comment = Comment {
79//! body: "looks off by one".to_owned(),
80//! state: "open".to_owned(),
81//! anchor: Some(RawTree::new(anchor_tree)),
82//! context: Some("issues/42".to_owned()),
83//! parent: None,
84//! };
85//! let root = facet_git_tree::serialize_into(&comment, &store).expect("serialize");
86//! let back: Comment = facet_git_tree::deserialize(&root, &store).expect("deserialize");
87//! assert_eq!(back, comment);
88//! ```
89
90mod error;
91
92pub mod comment;
93pub mod issue;
94pub mod present;
95pub mod review;
96
97pub use error::{Error, Result};
98pub use issue::Issue;
99
100/// The genesis oid a comment or issue ref's name binds to — the final
101/// segment of `refs/meta/comments/<id>` or `refs/meta/issues/<id>`
102/// (`meta-ref.identity-binding`), read back from the
103/// [`gix::refs::FullName`] `ents_receive::propose_genesis` returns rather
104/// than tracked separately, since the ref name and the id are the same
105/// string by construction. Shared by [`comment::add`], [`comment::reply`],
106/// and [`issue::new`] rather than duplicated per module, unlike this
107/// crate's accepted small `commit_tree` copies: this one is a single
108/// one-liner with no domain logic to diverge per caller.
109pub(crate) fn genesis_id(ref_name: &gix::refs::FullName) -> String {
110 ref_name
111 .as_bstr()
112 .to_string()
113 .rsplit('/')
114 .next()
115 .unwrap_or_default()
116 .to_owned()
117}
118
119/// One meta ref a listing could not read back as this build's own entity
120/// shape — its tip's tree was written by an older or unrelated schema (or
121/// the ref does not even point at a commit), so deserialization failed.
122/// [`comment::list_all`] and [`issue::list_all`] return these alongside
123/// their readable rows rather than dropping them on the floor: a reader
124/// surfaces a marker, never a silent gap, for an entity this build can no
125/// longer speak the schema of (the same graceful-degradation stance
126/// `ents-web`'s per-entity "unreadable" rendering takes).
127///
128/// # Examples
129///
130/// ```
131/// use ents_forge::Unreadable;
132///
133/// let unreadable = Unreadable {
134/// refname: "refs/meta/comments/deadbeef".to_owned(),
135/// error: "object ... is not a blob".to_owned(),
136/// };
137/// assert!(unreadable.refname.starts_with("refs/meta/"));
138/// ```
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Unreadable {
141 /// The full refname whose tip could not be read back.
142 pub refname: String,
143 /// The underlying read/deserialization error, rendered as text — a
144 /// diagnostic for an operator, not a value to match on.
145 pub error: String,
146}
147
148/// A listing's readable `(id, entity)` rows alongside the refs it could
149/// not read — [`comment::list_all`] and [`issue::list_all`]'s shared
150/// return shape (see [`Unreadable`]'s own doc).
151///
152/// # Examples
153///
154/// ```
155/// let listing: ents_forge::Listing<ents_forge::Issue> = (Vec::new(), Vec::new());
156/// let (rows, unreadable) = listing;
157/// assert!(rows.is_empty() && unreadable.is_empty());
158/// ```
159pub type Listing<T> = (Vec<(String, T)>, Vec<Unreadable>);
160
161/// Abbreviate a genesis-oid entity id (`model.comment`, `model.issue`) to a
162/// short prefix for display — the same seven-hex-character length git's own
163/// short object id uses (`model.issue`: "porcelain abbreviates ids the way
164/// git abbreviates commit oids"). The full id, never this, belongs in a
165/// refname or in machine-readable output (`lens.parity`).
166///
167/// # Examples
168///
169/// ```
170/// use ents_forge::abbreviate_id;
171///
172/// assert_eq!(
173/// abbreviate_id("0123456789abcdef0123456789abcdef01234567"),
174/// "0123456"
175/// );
176/// assert_eq!(abbreviate_id("abc"), "abc");
177/// ```
178#[must_use]
179pub fn abbreviate_id(id: &str) -> &str {
180 id.get(..7).unwrap_or(id)
181}
182
183#[cfg(test)]
184mod tests {
185 use facet::Facet as _;
186 use rstest::rstest;
187
188 use super::*;
189
190 /// Every entity this crate owns keeps the same `model.extensibility`
191 /// guarantee `ents_model`'s own shape test pins for its remaining
192 /// entities: each type's reflected [`facet::Shape::type_identifier`]
193 /// is exactly its Rust struct name.
194 #[rstest]
195 #[case::comment(comment::Comment::SHAPE.type_identifier, "Comment")]
196 #[case::issue(Issue::SHAPE.type_identifier, "Issue")]
197 #[case::review(review::Review::SHAPE.type_identifier, "Review")]
198 // @relation(model.extensibility, scope=function, role=Verifies)
199 fn every_entity_shape_name_tracks_its_struct_declaration(
200 #[case] reflected: &str,
201 #[case] expected: &str,
202 ) {
203 assert_eq!(reflected, expected);
204 }
205}