git-ents.gitmain
⌘K
foforge
command.rs729 lines · 26.4 KB · rusthistorycomment on this file
1//! The `comment` command's business logic: create a comment about
2//! something (`model.comment`), reply to one (`model.comment-thread`),
3//! resolve and reopen one (`model.comment-state`), and read them back —
4//! projected onto a revision or the working tree (`anchor.projection`,
5//! `anchor.working-tree`).
6//!
7//! Generalized over the same trait-object/generic seam
8//! `ents_effect::run` uses (`&dyn RefStore`/`RefStoreRead`,
9//! `impl Find`/`Find + Write`, `&dyn ents_receive::EventSink`) rather than
10//! any concrete composition-root type, so this crate never depends on a
11//! CLI or a specific store implementation — a composition root wires the
12//! concrete types and calls these functions, never the other way around.
13//! `lens.parity` makes this binding: the CLI, the web UI, and the editor
14//! lens are three callers of exactly these functions.
15
16use ents_anchor::{Anchor, LineRange, Projection, project, project_worktree, snippet};
17use ents_receive::{Identity, Mode, Outcome, propose_entity, propose_genesis};
18use facet_git_tree::RawTree;
19use gix_hash::ObjectId;
20use gix_object::{CommitRef, Find, Kind, Write};
21use gix_ref_store::{RefStore, RefStoreRead};
22
23use super::Comment;
24use super::entity::read_comment;
25use crate::error::{Error, Result};
26
27/// The tree of the commit at `oid` — read back into a typed entity by
28/// every command below. Duplicated in `ents_effect::run` and the CLI's own
29/// `crate::commands::commit_tree` rather than shared: three ~15-line
30/// copies of "read a commit's tree oid via `Find`" is the accepted pattern
31/// in this codebase, not a gap to close with a shared utility.
32fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
33 let mut buf = Vec::new();
34 let data = objects
35 .try_find(&oid, &mut buf)
36 .map_err(|source| Error::InvalidArgument(source.to_string()))?
37 .ok_or_else(|| Error::NotFound {
38 what: oid.to_string(),
39 })?;
40 if data.kind != Kind::Commit {
41 return Err(Error::NotFound {
42 what: oid.to_string(),
43 });
44 }
45 let commit = CommitRef::from_bytes(data.data, oid.kind())
46 .map_err(|source| Error::InvalidArgument(source.to_string()))?;
47 Ok(commit.tree())
48}
49
50/// Read the [`Comment`] at `id`'s ref tip, or [`Error::NotFound`] when no
51/// such ref exists.
52fn comment_at(refs: &dyn RefStoreRead, objects: &impl Find, id: &str) -> Result<Comment> {
53 let ref_name = ents_model::namespace::comment_ref(id)?;
54 let Some(tip) = refs.get(ref_name.as_ref())? else {
55 return Err(Error::NotFound {
56 what: format!("comment {id}"),
57 });
58 };
59 let tree = commit_tree(objects, tip)?;
60 read_comment(&tree, objects)
61}
62
63/// `git ents comment list`: every comment recorded in this repository.
64///
65/// A ref whose tip this build cannot read back as a [`Comment`] is
66/// silently absent here — a caller that must surface those refs instead
67/// of dropping them (`ents-web`'s comments page) uses [`list_all`], which
68/// this is the readable-rows-only view of.
69///
70/// # Errors
71///
72/// Propagates a ref-store or object read failure.
73///
74/// # Examples
75///
76/// ```
77/// use ents_forge::comment::list;
78/// use ents_testutil::{ObjectStore, MemRefStore};
79///
80/// let refs = MemRefStore::default();
81/// let objects = ObjectStore::default();
82/// assert!(list(&refs, &objects).expect("reads").is_empty());
83/// ```
84pub fn list(refs: &dyn RefStoreRead, objects: &impl Find) -> Result<Vec<(String, Comment)>> {
85 Ok(list_all(refs, objects)?.0)
86}
87
88/// [`list`] plus the refs it could not read: every readable comment, and
89/// one [`crate::Unreadable`] per `refs/meta/comments/*` ref whose tip this
90/// build's [`Comment`] shape could not read back (written by an older or
91/// unrelated schema, or not pointing at a commit at all). Nothing under
92/// the prefix is ever silently dropped — a failed read lands in the second
93/// vec with its refname and error text, for a caller to surface however
94/// its own surface degrades (see [`crate::Unreadable`]'s own doc).
95///
96/// # Errors
97///
98/// Propagates a ref-store read failure — a per-ref *entity* read failure
99/// is a row in the second vec, never an error.
100///
101/// # Examples
102///
103/// ```
104/// use ents_forge::comment::list_all;
105/// use ents_testutil::{ObjectStore, MemRefStore};
106///
107/// let refs = MemRefStore::default();
108/// let objects = ObjectStore::default();
109/// let (rows, unreadable) = list_all(&refs, &objects).expect("reads");
110/// assert!(rows.is_empty());
111/// assert!(unreadable.is_empty());
112/// ```
113pub fn list_all(refs: &dyn RefStoreRead, objects: &impl Find) -> Result<crate::Listing<Comment>> {
114 let mut out = Vec::new();
115 let mut unreadable = Vec::new();
116 for entry in refs.iter_prefix("refs/meta/comments/")? {
117 let (name, tip) = entry?;
118 let path = name.as_bstr().to_string();
119 let Some(id) = path.strip_prefix("refs/meta/comments/") else {
120 continue;
121 };
122 match commit_tree(objects, tip).and_then(|tree| read_comment(&tree, objects)) {
123 Ok(comment) => out.push((id.to_owned(), comment)),
124 Err(error) => unreadable.push(crate::Unreadable {
125 refname: path.clone(),
126 error: error.to_string(),
127 }),
128 }
129 }
130 Ok((out, unreadable))
131}
132
133/// One row of [`list_projected`]: the comment, and — when it carries an
134/// anchor — that anchor and its projection onto the requested target.
135#[derive(Debug, Clone)]
136pub struct Listed {
137 /// The comment's id (its refname below `refs/meta/comments/`).
138 pub id: String,
139 /// The comment itself.
140 pub comment: Comment,
141 /// The comment's anchor, read back from its embedded tree, or `None`
142 /// for a comment with no anchor — carried alongside [`Listed::projection`]
143 /// so a [`Projection::Current`] outcome still knows the anchored
144 /// path and lines it applies at.
145 pub anchor: Option<Anchor>,
146 /// Where the anchor lands on the projection target, or `None` for a
147 /// comment with no anchor.
148 pub projection: Option<Projection>,
149}
150
151/// Which filters [`list_projected`] applies before projecting.
152#[derive(Debug, Clone, Default)]
153pub struct ListFilter {
154 /// Keep only comments in this state (`model.comment-state`).
155 pub state: Option<String>,
156 /// Keep only comments naming this context (`model.comment-context`).
157 pub context: Option<String>,
158}
159
160/// `git ents comment list [--worktree] [--state ...] [--context ...]`:
161/// every matching comment, each anchor projected onto the working tree
162/// (`anchor.working-tree`) when `worktree` is set, onto `HEAD` otherwise —
163/// the listing `lens.parity` requires to be one library call shared by the
164/// CLI's machine-readable form, the web UI, and the editor lens. Refs
165/// whose stored tree this build cannot read back come back as the second
166/// element, exactly as [`list_all`] reports them, so no caller can drop
167/// them silently.
168///
169/// # Errors
170///
171/// Propagates a ref-store, object read, repository open, or projection
172/// failure.
173// @relation(lens.parity, model.comment-state, model.comment-context, scope=function)
174pub fn list_projected(
175 refs: &dyn RefStoreRead,
176 objects: &impl Find,
177 repo_path: &std::path::Path,
178 worktree: bool,
179 filter: &ListFilter,
180) -> Result<(Vec<Listed>, Vec<crate::Unreadable>)> {
181 let repo = gix::open(repo_path)?;
182 let mut out = Vec::new();
183 let (all, unreadable) = list_all(refs, objects)?;
184 for (id, comment) in all {
185 if let Some(state) = &filter.state
186 && comment.state != *state
187 {
188 continue;
189 }
190 if let Some(context) = &filter.context
191 && comment.context.as_ref() != Some(context)
192 {
193 continue;
194 }
195 let (anchor, projection) = match &comment.anchor {
196 None => (None, None),
197 Some(raw) => {
198 let anchor = facet_git_tree::deserialize::<Anchor>(&raw.oid(), objects)?;
199 let projection = if worktree {
200 project_worktree(&repo, &anchor, None)?
201 } else {
202 project(&repo, &anchor, "HEAD")?
203 };
204 (Some(anchor), Some(projection))
205 }
206 };
207 out.push(Listed {
208 id,
209 comment,
210 anchor,
211 projection,
212 });
213 }
214 Ok((out, unreadable))
215}
216
217/// What `git ents comment add` writes, before the mechanism-side
218/// arguments: the body, what the comment is about (`model.comment` — at
219/// least one of an anchored path, a context, or a parent), and where its
220/// anchor captures from (`rev`, or the working tree per
221/// `anchor.working-tree` when `worktree` is set).
222#[derive(Debug, Clone)]
223pub struct NewComment {
224 /// The comment's body text.
225 pub body: String,
226 /// Repository-relative path to anchor to, or `None` for an unanchored
227 /// comment (about a context or a parent instead).
228 pub path: Option<String>,
229 /// Lines to anchor, as `<start>[:<end>]`; requires `path`.
230 pub lines: Option<String>,
231 /// Revision to anchor against; ignored when `worktree` is set.
232 pub rev: String,
233 /// Anchor against the working tree's on-disk bytes instead of `rev`
234 /// (`anchor.working-tree`).
235 pub worktree: bool,
236 /// Canonical ref path below `refs/meta/` of the entity this comment
237 /// belongs to, such as `issues/<id>` (`model.comment-context`).
238 pub context: Option<String>,
239 /// Id of the comment this one replies to (`model.comment-thread`);
240 /// [`reply`] is the porcelain shortcut that sets only this.
241 pub parent: Option<String>,
242}
243
244/// `git ents comment add`: create a comment about something.
245///
246/// Returns the comment's id — its genesis commit's own oid
247/// (`model.comment`, `meta-ref.identity-binding`) — alongside the raw
248/// [`Outcome`] `receive` reached — callers interpret it themselves (the
249/// CLI's own `outcome_to_result`, for instance), the same shape
250/// `ents_effect::run::run_one` returns its own raw `Outcome` in.
251///
252/// # Errors
253///
254/// [`Error::InvalidArgument`] if the comment is about nothing — no path,
255/// no context, no parent (`model.comment`: refused at creation by the
256/// writing tool, never by the gate) — if `lines` does not parse as
257/// `<start>[:<end>]` or names lines without a path, or if `context` does
258/// not form a valid ref path below `refs/meta/`; [`Error::NotFound`] if
259/// `parent` names no existing comment (`model.comment-thread`); otherwise
260/// propagates capture, serialization, or `receive` failures.
261// @relation(model.comment, model.comment-state, model.comment-context, model.comment-thread, meta-ref.identity-binding, lens.parity, scope=function)
262pub fn add(
263 refs: &dyn RefStore,
264 objects: &(impl Find + Write),
265 events: &dyn ents_receive::EventSink,
266 repo_path: &std::path::Path,
267 new: NewComment,
268 identity: &Identity<'_>,
269 mode: Mode,
270) -> Result<(String, Outcome)> {
271 // A comment about nothing is refused here, at creation, by the
272 // writing tool — the gate stays content-agnostic (`model.comment`).
273 if new.path.is_none() && new.context.is_none() && new.parent.is_none() {
274 return Err(Error::InvalidArgument(
275 "a comment must be about something: anchor it to a path, name a context, \
276 or reply to a parent"
277 .into(),
278 ));
279 }
280 if new.lines.is_some() && new.path.is_none() {
281 return Err(Error::InvalidArgument(
282 "--lines needs a path to anchor to".into(),
283 ));
284 }
285 if let Some(context) = &new.context {
286 validate_context(context)?;
287 }
288 if let Some(parent) = &new.parent {
289 // The parent must exist when the reply is created
290 // (`model.comment-thread`).
291 comment_at(refs, objects, parent)?;
292 }
293
294 let anchor = match &new.path {
295 None => None,
296 Some(path) => {
297 let repo = gix::open(repo_path)?;
298 let range = new.lines.map(|text| parse_line_range(&text)).transpose()?;
299 Some(if new.worktree {
300 ents_anchor::capture_worktree(&repo, path, range)?
301 } else {
302 ents_anchor::capture(&repo, &new.rev, path, range)?
303 })
304 }
305 };
306 let anchor = anchor
307 .map(|anchor| facet_git_tree::serialize_into(&anchor, objects))
308 .transpose()?
309 .map(RawTree::new);
310
311 let comment = Comment {
312 body: new.body,
313 // A new comment's state is `open` (`model.comment-state`).
314 state: "open".to_owned(),
315 anchor,
316 context: new.context,
317 parent: new.parent,
318 };
319
320 let subject = match &new.path {
321 Some(path) => format!("Comment on {path}"),
322 None => "Comment".to_owned(),
323 };
324
325 // A comment's id is the oid of its own genesis commit — sign-then-name,
326 // never a locally minted id (`model.comment`, `meta-ref.identity-binding`).
327 let (ref_name, outcome) = propose_genesis(
328 refs,
329 objects,
330 events,
331 &comment,
332 |oid| ents_model::namespace::comment_ref(&oid.to_string()),
333 identity,
334 &subject,
335 mode,
336 )?;
337 Ok((crate::genesis_id(&ref_name), outcome))
338}
339
340/// `git ents comment reply`: a comment whose parent is `parent_id`
341/// (`model.comment-thread`) — its aboutness is inherited from its thread
342/// root, so no anchor or context is required or set.
343///
344/// # Errors
345///
346/// [`Error::NotFound`] if `parent_id` names no existing comment; otherwise
347/// see [`add`].
348// @relation(model.comment-thread, lens.parity, scope=function)
349pub fn reply(
350 refs: &dyn RefStore,
351 objects: &(impl Find + Write),
352 events: &dyn ents_receive::EventSink,
353 parent_id: &str,
354 body: String,
355 identity: &Identity<'_>,
356 mode: Mode,
357) -> Result<(String, Outcome)> {
358 // The parent must exist when the reply is created.
359 comment_at(refs, objects, parent_id)?;
360 let comment = Comment {
361 body,
362 state: "open".to_owned(),
363 anchor: None,
364 context: None,
365 parent: Some(parent_id.to_owned()),
366 };
367 let (ref_name, outcome) = propose_genesis(
368 refs,
369 objects,
370 events,
371 &comment,
372 |oid| ents_model::namespace::comment_ref(&oid.to_string()),
373 identity,
374 &format!("Reply to comment {parent_id}"),
375 mode,
376 )?;
377 Ok((crate::genesis_id(&ref_name), outcome))
378}
379
380/// `git ents comment resolve`: record state `resolved` as an ordinary
381/// mutation commit on the comment's own ref — never a deletion, so the
382/// conversation stays auditable (`model.comment-state`). When
383/// `resolver_key` (the signer's openssh public key) matches an enrolled
384/// member, the mutation carries a `Key-for-<member-id>` trailer naming
385/// that member ref's tip commit oid (`model.comment-provenance`); an
386/// unenrolled signer writes no trailer.
387///
388/// # Errors
389///
390/// [`Error::NotFound`] if `id` has no comment ref; otherwise propagates
391/// read, serialization, or `receive` failures.
392// @relation(model.comment-state, model.comment-provenance, lens.parity, scope=function)
393pub fn resolve(
394 refs: &dyn RefStore,
395 objects: &(impl Find + Write),
396 events: &dyn ents_receive::EventSink,
397 id: &str,
398 identity: &Identity<'_>,
399 mode: Mode,
400 resolver_key: Option<&str>,
401) -> Result<Outcome> {
402 set_state(
403 refs,
404 objects,
405 events,
406 id,
407 "resolved",
408 identity,
409 mode,
410 resolver_key,
411 )
412}
413
414/// `git ents comment reopen`: record state `open` again, the same way
415/// [`resolve`] records `resolved` (`model.comment-state`).
416///
417/// # Errors
418///
419/// See [`resolve`].
420// @relation(model.comment-state, lens.parity, scope=function)
421pub fn reopen(
422 refs: &dyn RefStore,
423 objects: &(impl Find + Write),
424 events: &dyn ents_receive::EventSink,
425 id: &str,
426 identity: &Identity<'_>,
427 mode: Mode,
428 resolver_key: Option<&str>,
429) -> Result<Outcome> {
430 set_state(
431 refs,
432 objects,
433 events,
434 id,
435 "open",
436 identity,
437 mode,
438 resolver_key,
439 )
440}
441
442/// The shared state mutation [`resolve`] and [`reopen`] are: read the
443/// comment at `id`, set `state`, and propose the new tree on top of the
444/// old tip -- carrying the resolver's `Key-for-<member-id>` trailer when
445/// `resolver_key` names an enrolled member (`model.comment-provenance`).
446// @relation(model.comment-provenance, scope=function)
447#[expect(
448 clippy::too_many_arguments,
449 reason = "the shared mutation seam plus the provenance key; the only callers are resolve/reopen's thin forwards"
450)]
451fn set_state(
452 refs: &dyn RefStore,
453 objects: &(impl Find + Write),
454 events: &dyn ents_receive::EventSink,
455 id: &str,
456 state: &str,
457 identity: &Identity<'_>,
458 mode: Mode,
459 resolver_key: Option<&str>,
460) -> Result<Outcome> {
461 let mut comment = comment_at(refs, objects, id)?;
462 comment.state = state.to_owned();
463 let ref_name = ents_model::namespace::comment_ref(id)?;
464 let mut message = format!("Mark comment {id} {state}");
465 if let Some(trailer) = resolver_key.and_then(|key| key_trailer(refs, objects, key)) {
466 message.push_str("\n\n");
467 message.push_str(&trailer);
468 }
469 Ok(propose_entity(
470 refs, objects, events, ref_name, &comment, identity, &message, mode,
471 )?)
472}
473
474/// The `Key-for-<member-id>: <oid>` trailer for the enrolled member whose
475/// stored key matches `pubkey` (`model.comment-provenance`): the oid is
476/// the member ref's tip commit at this moment, pinning the resolver's
477/// whole enrolled record -- key, state, provenance -- into the mutation
478/// chain. `None` when no member's key matches (an unenrolled signer
479/// writes no trailer) or when the member listing cannot be read; a state
480/// mutation never fails for want of provenance.
481// @relation(model.comment-provenance, scope=function)
482fn key_trailer(refs: &dyn RefStore, objects: &impl Find, pubkey: &str) -> Option<String> {
483 let entries = refs.iter_prefix("refs/meta/member/").ok()?;
484 for entry in entries.flatten() {
485 let (name, tip) = entry;
486 let path = name.as_bstr().to_string();
487 let Some(id) = path.strip_prefix("refs/meta/member/") else {
488 continue;
489 };
490 let Ok(tree) = commit_tree(objects, tip) else {
491 continue;
492 };
493 let Ok(member) = facet_git_tree::deserialize::<ents_model::Member>(&tree, objects) else {
494 continue;
495 };
496 if member.key == pubkey {
497 return Some(format!("Key-for-{id}: {tip}"));
498 }
499 }
500 None
501}
502
503/// `git ents comment show`: `id`'s comment and — when it carries an
504/// anchor — that anchor, projected onto `rev` or (with `worktree`) onto
505/// the working tree (`anchor.working-tree`).
506///
507/// # Errors
508///
509/// [`Error::NotFound`] if `id` has no comment ref.
510// @relation(lens.parity, scope=function)
511pub fn show(
512 refs: &dyn RefStoreRead,
513 objects: &impl Find,
514 repo_path: &std::path::Path,
515 id: &str,
516 rev: &str,
517 worktree: bool,
518) -> Result<(Comment, Option<(Anchor, Projection)>)> {
519 let comment = comment_at(refs, objects, id)?;
520 let Some(raw) = &comment.anchor else {
521 return Ok((comment, None));
522 };
523 let anchor = facet_git_tree::deserialize::<Anchor>(&raw.oid(), objects)?;
524 let repo = gix::open(repo_path)?;
525 let projection = if worktree {
526 project_worktree(&repo, &anchor, None)?
527 } else {
528 project(&repo, &anchor, rev)?
529 };
530 let _ = snippet(&anchor)?; // Confirm the anchored text still reads back.
531 Ok((comment, Some((anchor, projection))))
532}
533
534/// The thread of `context` (`model.comment-context`,
535/// `model.comment-thread`): every comment naming `context` directly, plus
536/// every reply whose parent chain reaches one — an aggregation query over
537/// decomposed comment refs, never a list any entity stores
538/// (`meta-ref.granularity`). Rows come back sorted by id, roots and
539/// replies alike; the `parent` field reconstructs the tree.
540///
541/// # Errors
542///
543/// Propagates a ref-store or object read failure.
544// @relation(model.comment-context, model.comment-thread, scope=function)
545pub fn thread(
546 refs: &dyn RefStoreRead,
547 objects: &impl Find,
548 context: &str,
549) -> Result<Vec<(String, Comment)>> {
550 let all = list(refs, objects)?;
551 let mut included: std::collections::BTreeMap<&str, &Comment> = all
552 .iter()
553 .filter(|(_, comment)| comment.context.as_deref() == Some(context))
554 .map(|(id, comment)| (id.as_str(), comment))
555 .collect();
556 // Close over parent links: a reply names a comment already in the
557 // thread, transitively — no comment stores a list of its replies.
558 loop {
559 let mut grew = false;
560 for (id, comment) in &all {
561 if included.contains_key(id.as_str()) {
562 continue;
563 }
564 if let Some(parent) = &comment.parent
565 && included.contains_key(parent.as_str())
566 {
567 included.insert(id.as_str(), comment);
568 grew = true;
569 }
570 }
571 if !grew {
572 break;
573 }
574 }
575 Ok(included
576 .into_iter()
577 .map(|(id, comment)| (id.to_owned(), comment.clone()))
578 .collect())
579}
580
581/// Every open (per `filter`) comment whose anchor lands on `target_path`
582/// in the working tree, each projected onto `buffer` — the editor's
583/// in-memory bytes for that document — when one is given, or the on-disk
584/// file otherwise (`anchor.working-tree`, `lens.working-tree`).
585///
586/// This is the per-document, buffer-aware companion to [`list_projected`]:
587/// [`list_projected`] projects every comment onto the plain on-disk working
588/// tree at once, but the editor lens holds one open document at a time and
589/// must project onto that document's unsaved buffer content when it differs
590/// from disk, so ranges track edits the user has not saved yet. Projection
591/// on the working tree never follows a rename ([`project_worktree`] consults
592/// only [`Anchor::path`]), so a comment lands on exactly its own anchored
593/// path: filtering to `target_path` here is the whole of "which comments
594/// belong to this document". A [`Projection::Deleted`] row is still
595/// returned; the caller decides whether a deleted anchor surfaces (the lens
596/// omits it, since a deleted anchor no longer projects onto the buffer).
597///
598/// The lens calls this rather than reimplementing the read-and-project loop
599/// itself (`lens.parity`): listing and projection are one mechanism shared
600/// by the CLI, the web UI, and the editor.
601///
602/// # Errors
603///
604/// Propagates a ref-store, object read, repository open, or projection
605/// failure.
606// @relation(lens.parity, lens.working-tree, model.comment-state, scope=function)
607pub fn list_for_document(
608 refs: &dyn RefStoreRead,
609 objects: &impl Find,
610 repo_path: &std::path::Path,
611 target_path: &str,
612 buffer: Option<&[u8]>,
613 filter: &ListFilter,
614) -> Result<(Vec<Listed>, Vec<crate::Unreadable>)> {
615 let repo = gix::open(repo_path)?;
616 let mut out = Vec::new();
617 let (all, unreadable) = list_all(refs, objects)?;
618 for (id, comment) in all {
619 if let Some(state) = &filter.state
620 && comment.state != *state
621 {
622 continue;
623 }
624 if let Some(context) = &filter.context
625 && comment.context.as_ref() != Some(context)
626 {
627 continue;
628 }
629 let Some(raw) = &comment.anchor else {
630 continue;
631 };
632 let anchor = facet_git_tree::deserialize::<Anchor>(&raw.oid(), objects)?;
633 if anchor.path != target_path {
634 continue;
635 }
636 let projection = project_worktree(&repo, &anchor, buffer)?;
637 out.push(Listed {
638 id,
639 comment,
640 anchor: Some(anchor),
641 projection: Some(projection),
642 });
643 }
644 Ok((out, unreadable))
645}
646
647/// The thread rooted at `root_id` (`model.comment-thread`): the comment
648/// with that id, plus every reply whose parent chain reaches it — the
649/// anchored-comment counterpart to [`thread`], which aggregates by
650/// `context` instead. Rows come back sorted by id, root and replies alike;
651/// the `parent` field reconstructs the tree.
652///
653/// The lens shows a code lens per anchored root comment and, on hover,
654/// the whole conversation attached to it (`lens.hover`); an anchored root
655/// need carry no `context`, so [`thread`]'s context aggregation cannot find
656/// its replies — this seeds the same parent-closure walk from the root id
657/// directly.
658///
659/// # Errors
660///
661/// Propagates a ref-store or object read failure.
662// @relation(model.comment-thread, lens.hover, scope=function)
663pub fn thread_of(
664 refs: &dyn RefStoreRead,
665 objects: &impl Find,
666 root_id: &str,
667) -> Result<Vec<(String, Comment)>> {
668 let all = list(refs, objects)?;
669 let mut included: std::collections::BTreeMap<&str, &Comment> = all
670 .iter()
671 .filter(|(id, _comment)| id == root_id)
672 .map(|(id, comment)| (id.as_str(), comment))
673 .collect();
674 // Close over parent links exactly as `thread` does, but seeded with the
675 // root id rather than a context: a reply names a comment already in the
676 // thread, transitively.
677 loop {
678 let mut grew = false;
679 for (id, comment) in &all {
680 if included.contains_key(id.as_str()) {
681 continue;
682 }
683 if let Some(parent) = &comment.parent
684 && included.contains_key(parent.as_str())
685 {
686 included.insert(id.as_str(), comment);
687 grew = true;
688 }
689 }
690 if !grew {
691 break;
692 }
693 }
694 Ok(included
695 .into_iter()
696 .map(|(id, comment)| (id.to_owned(), comment.clone()))
697 .collect())
698}
699/// Validate a `model.comment-context` value: the canonical ref path below
700/// `refs/meta/` of the entity the comment belongs to, such as
701/// `issues/<id>` — checked by building the full refname it names.
702fn validate_context(context: &str) -> Result<()> {
703 let full = format!("refs/meta/{context}");
704 if context.is_empty() || gix::refs::FullName::try_from(full).is_err() {
705 return Err(Error::InvalidArgument(format!(
706 "context {context:?} is not a ref path below refs/meta/"
707 )));
708 }
709 Ok(())
710}
711
712/// Parse a `<start>[:<end>]` line-range argument.
713///
714/// # Errors
715///
716/// [`Error::InvalidArgument`] if either half does not parse as a `u64`.
717fn parse_line_range(text: &str) -> Result<LineRange> {
718 let (start, end) = match text.split_once(':') {
719 Some((s, e)) => (s, e),
720 None => (text, text),
721 };
722 let start: u64 = start
723 .parse()
724 .map_err(|_source| Error::InvalidArgument(format!("bad line range: {text}")))?;
725 let end: u64 = end
726 .parse()
727 .map_err(|_source| Error::InvalidArgument(format!("bad line range: {text}")))?;
728 Ok(LineRange { start, end })
729}