git-ents.gitmain
⌘K
foforge
conversations.rs522 lines · 16.6 KB · rusthistorycomment on this file
1//! Integration coverage for the comment command layer: the broadened
2//! `model.comment` (aboutness refused at creation, `model.comment-state`
3//! transitions, `model.comment-context`/`model.comment-thread`
4//! aggregation).
5
6#![allow(
7 clippy::expect_used,
8 clippy::unwrap_used,
9 clippy::indexing_slicing,
10 clippy::panic,
11 reason = "integration test: fixtures panic on setup failure"
12)]
13
14use ents_forge::comment::{self, ListFilter, NewComment};
15use ents_model::{Member, MemberId, Provenance};
16use ents_receive::{Identity, Mode, NullEventSink, TxResult};
17use ents_testutil::{Keypair, MemRefStore, ObjectStore};
18use gix_ref_store::RefStoreRead as _;
19use rstest::rstest;
20
21/// A throwaway on-disk repository holding one committed file — the
22/// content anchors capture against — alongside the in-memory ref/object
23/// fixtures every library test uses.
24/// A detached signer over some bytes, returning an armored signature.
25type Signer = Box<dyn Fn(&[u8]) -> String>;
26
27struct Fixture {
28 dir: tempfile::TempDir,
29 refs: MemRefStore,
30 objects: ObjectStore,
31 sign: Signer,
32}
33
34impl Fixture {
35 fn new() -> Self {
36 let dir = tempfile::tempdir().expect("tempdir");
37 let git = |args: &[&str]| {
38 let status = std::process::Command::new("git")
39 .arg("-C")
40 .arg(dir.path())
41 .args(["-c", "user.name=test", "-c", "user.email=test@example.com"])
42 .args(args)
43 .status()
44 .expect("git runs");
45 assert!(status.success());
46 };
47 git(&["init", "-q"]);
48 let contents: String = (1..=10).map(|n| format!("line {n}\n")).collect();
49 std::fs::write(dir.path().join("file.txt"), contents).unwrap();
50 git(&["add", "-A"]);
51 git(&["commit", "-q", "-m", "seed"]);
52 let key = Keypair::from_seed(1);
53 Self {
54 dir,
55 refs: MemRefStore::default(),
56 objects: ObjectStore::default(),
57 sign: Box::new(move |payload| key.sign(payload)),
58 }
59 }
60
61 fn path(&self) -> &std::path::Path {
62 self.dir.path()
63 }
64
65 fn identity(&self) -> Identity<'_> {
66 Identity {
67 actor: gix::actor::Signature {
68 name: "test".into(),
69 email: "test@ents.test".into(),
70 time: gix::date::Time {
71 seconds: 1_000,
72 offset: 0,
73 },
74 },
75 author: None,
76 sign: &*self.sign,
77 }
78 }
79
80 fn draft(&self) -> NewComment {
81 NewComment {
82 body: "looks off by one".to_owned(),
83 path: Some("file.txt".to_owned()),
84 lines: Some("3:4".to_owned()),
85 rev: "HEAD".to_owned(),
86 worktree: false,
87 context: None,
88 parent: None,
89 }
90 }
91
92 fn add(&self, draft: NewComment) -> String {
93 let (id, outcome) = comment::add(
94 &self.refs,
95 &self.objects,
96 &NullEventSink,
97 self.path(),
98 draft,
99 &self.identity(),
100 Mode::Advisory,
101 )
102 .expect("adds");
103 assert_eq!(outcome.result, TxResult::Applied);
104 id
105 }
106}
107
108// ---------------------------------------------------------------------
109// model.comment: aboutness is required at creation, never by the gate.
110// ---------------------------------------------------------------------
111
112/// The library refuses a comment about nothing and malformed aboutness
113/// arguments; every well-formed combination is accepted.
114// @relation(model.comment, model.comment-context, scope=function, role=Verifies)
115#[rstest]
116#[case::about_nothing(None, None, None, false)]
117#[case::lines_without_a_path(None, Some("issues/42"), Some("3:4"), false)]
118#[case::bad_context(None, Some("not a ref\u{7f}"), None, false)]
119#[case::context_only(None, Some("issues/42"), None, true)]
120#[case::anchored(Some("file.txt"), None, None, true)]
121#[case::anchored_and_contextual(Some("file.txt"), Some("reviews/7"), None, true)]
122fn add_refuses_a_comment_about_nothing(
123 #[case] path: Option<&str>,
124 #[case] context: Option<&str>,
125 #[case] lines: Option<&str>,
126 #[case] accepted: bool,
127) {
128 let fixture = Fixture::new();
129 let draft = NewComment {
130 body: "b".to_owned(),
131 path: path.map(str::to_owned),
132 lines: lines.map(str::to_owned),
133 rev: "HEAD".to_owned(),
134 worktree: false,
135 context: context.map(str::to_owned),
136 parent: None,
137 };
138 let result = comment::add(
139 &fixture.refs,
140 &fixture.objects,
141 &NullEventSink,
142 fixture.path(),
143 draft,
144 &fixture.identity(),
145 Mode::Advisory,
146 );
147 match (accepted, result) {
148 (true, Ok((_, outcome))) => assert_eq!(outcome.result, TxResult::Applied),
149 (false, Err(error)) => assert!(matches!(error, ents_forge::Error::InvalidArgument(_))),
150 (expected, got) => panic!("expected accepted={expected}, got {got:?}"),
151 }
152}
153
154/// A reply's parent must exist when the reply is created
155/// (`model.comment-thread`) — both through `reply` and through `add
156/// --parent`.
157// @relation(model.comment-thread, scope=function, role=Verifies)
158#[rstest]
159fn a_reply_to_a_missing_parent_is_refused() {
160 let fixture = Fixture::new();
161 let error = comment::reply(
162 &fixture.refs,
163 &fixture.objects,
164 &NullEventSink,
165 "no-such-id",
166 "reply".to_owned(),
167 &fixture.identity(),
168 Mode::Advisory,
169 )
170 .expect_err("refused");
171 assert!(matches!(error, ents_forge::Error::NotFound { .. }));
172
173 let mut draft = fixture.draft();
174 draft.parent = Some("no-such-id".to_owned());
175 let error = comment::add(
176 &fixture.refs,
177 &fixture.objects,
178 &NullEventSink,
179 fixture.path(),
180 draft,
181 &fixture.identity(),
182 Mode::Advisory,
183 )
184 .expect_err("refused");
185 assert!(matches!(error, ents_forge::Error::NotFound { .. }));
186}
187
188// ---------------------------------------------------------------------
189// model.comment-state: resolve and reopen are ordinary ref mutations.
190// ---------------------------------------------------------------------
191
192/// A new comment opens `open`; resolve records `resolved`; reopen records
193/// `open` again — three commits on one ref, never a deletion.
194// @relation(model.comment-state, scope=function, role=Verifies)
195#[rstest]
196fn resolve_and_reopen_advance_the_same_ref() {
197 let fixture = Fixture::new();
198 let id = fixture.add(fixture.draft());
199 let state = |fixture: &Fixture| {
200 comment::list(&fixture.refs, &fixture.objects).expect("lists")[0]
201 .1
202 .state
203 .clone()
204 };
205 assert_eq!(state(&fixture), "open");
206
207 let outcome = comment::resolve(
208 &fixture.refs,
209 &fixture.objects,
210 &NullEventSink,
211 &id,
212 &fixture.identity(),
213 Mode::Advisory,
214 None,
215 )
216 .expect("resolves");
217 assert_eq!(outcome.result, TxResult::Applied);
218 assert_eq!(state(&fixture), "resolved");
219
220 let outcome = comment::reopen(
221 &fixture.refs,
222 &fixture.objects,
223 &NullEventSink,
224 &id,
225 &fixture.identity(),
226 Mode::Advisory,
227 None,
228 )
229 .expect("reopens");
230 assert_eq!(outcome.result, TxResult::Applied);
231 assert_eq!(state(&fixture), "open");
232}
233
234// ---------------------------------------------------------------------
235// model.comment-context / model.comment-thread: threads are aggregation
236// queries over decomposed refs.
237// ---------------------------------------------------------------------
238
239/// `thread` aggregates the comments naming a context plus every reply
240/// reachable through parent links — a reply repeats neither anchor nor
241/// context, and no entity stored a list of anything.
242// @relation(model.comment-context, model.comment-thread, scope=function, role=Verifies)
243#[rstest]
244fn a_thread_aggregates_context_roots_and_their_replies() {
245 let fixture = Fixture::new();
246 let mut root_draft = fixture.draft();
247 root_draft.context = Some("reviews/7".to_owned());
248 let root = fixture.add(root_draft);
249 let (reply, outcome) = comment::reply(
250 &fixture.refs,
251 &fixture.objects,
252 &NullEventSink,
253 &root,
254 "agreed".to_owned(),
255 &fixture.identity(),
256 Mode::Advisory,
257 )
258 .expect("replies");
259 assert_eq!(outcome.result, TxResult::Applied);
260 // A second-level reply, and an unrelated comment that must stay out.
261 let (nested, _) = comment::reply(
262 &fixture.refs,
263 &fixture.objects,
264 &NullEventSink,
265 &reply,
266 "further".to_owned(),
267 &fixture.identity(),
268 Mode::Advisory,
269 )
270 .expect("replies");
271 let mut unrelated = fixture.draft();
272 unrelated.context = Some("issues/9".to_owned());
273 fixture.add(unrelated);
274
275 let thread = comment::thread(&fixture.refs, &fixture.objects, "reviews/7").expect("aggregates");
276 let mut ids: Vec<_> = thread.iter().map(|(id, _)| id.clone()).collect();
277 ids.sort();
278 let mut expected = vec![root.clone(), reply.clone(), nested.clone()];
279 expected.sort();
280 assert_eq!(ids, expected);
281
282 // The reply carried no anchor and no context of its own — aboutness
283 // is inherited from the thread root.
284 let replied = thread
285 .iter()
286 .find(|(id, _)| *id == reply)
287 .map(|(_, c)| c)
288 .expect("present");
289 assert_eq!(replied.anchor, None);
290 assert_eq!(replied.context, None);
291 assert_eq!(replied.parent, Some(root));
292}
293
294// ---------------------------------------------------------------------
295// lens.parity: the projected listing is one library call.
296// ---------------------------------------------------------------------
297
298/// `list_projected` filters by state and context and projects each anchor
299/// onto the working tree when asked — the exact call the CLI's
300/// machine-readable form and the editor lens both consume.
301// @relation(lens.parity, anchor.working-tree, scope=function, role=Verifies)
302#[rstest]
303fn list_projected_filters_and_projects_onto_the_working_tree() {
304 let fixture = Fixture::new();
305 let anchored = fixture.add(fixture.draft());
306 let mut contextual = fixture.draft();
307 contextual.path = None;
308 contextual.lines = None;
309 contextual.context = Some("issues/42".to_owned());
310 let unanchored = fixture.add(contextual);
311 comment::resolve(
312 &fixture.refs,
313 &fixture.objects,
314 &NullEventSink,
315 &unanchored,
316 &fixture.identity(),
317 Mode::Advisory,
318 None,
319 )
320 .expect("resolves");
321
322 // Dirty the working tree above the anchored range: the worktree
323 // projection relocates while a HEAD projection would say Current.
324 let dirty: String = std::iter::once("inserted\n".to_owned())
325 .chain((1..=10).map(|n| format!("line {n}\n")))
326 .collect();
327 std::fs::write(fixture.path().join("file.txt"), dirty).unwrap();
328
329 let (open_only, _unreadable) = comment::list_projected(
330 &fixture.refs,
331 &fixture.objects,
332 fixture.path(),
333 true,
334 &ListFilter {
335 state: Some("open".to_owned()),
336 context: None,
337 },
338 )
339 .expect("lists");
340 assert_eq!(open_only.len(), 1);
341 assert_eq!(open_only[0].id, anchored);
342 assert_eq!(
343 open_only[0].projection,
344 Some(ents_anchor::Projection::Relocated {
345 path: "file.txt".to_owned(),
346 lines: Some(ents_anchor::LineRange { start: 4, end: 5 }),
347 })
348 );
349
350 let (by_context, _unreadable) = comment::list_projected(
351 &fixture.refs,
352 &fixture.objects,
353 fixture.path(),
354 true,
355 &ListFilter {
356 state: None,
357 context: Some("issues/42".to_owned()),
358 },
359 )
360 .expect("lists");
361 assert_eq!(by_context.len(), 1);
362 assert_eq!(by_context[0].id, unanchored);
363 assert_eq!(by_context[0].projection, None, "no anchor, no projection");
364}
365
366/// `--worktree` end to end at the library layer: a comment anchored to
367/// dirty, uncommitted content is Current against the working tree and
368/// survives the content being discarded (its content is embedded).
369// @relation(anchor.working-tree, model.comment, scope=function, role=Verifies)
370#[rstest]
371fn a_worktree_anchored_comment_tracks_the_dirty_file() {
372 let fixture = Fixture::new();
373 let dirty: String = (1..=10)
374 .map(|n| {
375 if n == 5 {
376 "line five\n".to_owned()
377 } else {
378 format!("line {n}\n")
379 }
380 })
381 .collect();
382 std::fs::write(fixture.path().join("file.txt"), &dirty).unwrap();
383
384 let mut draft = fixture.draft();
385 draft.worktree = true;
386 draft.lines = Some("5".to_owned());
387 let id = fixture.add(draft);
388
389 let (_, projected) = comment::show(
390 &fixture.refs,
391 &fixture.objects,
392 fixture.path(),
393 &id,
394 "HEAD",
395 true,
396 )
397 .expect("shows");
398 let (anchor, projection) = projected.expect("anchored");
399 assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "line five\n");
400 assert_eq!(projection, ents_anchor::Projection::Current);
401
402 // Discard the dirty content: the anchor's own text still reads back
403 // (embedded), and the worktree projection reports the region edited.
404 let git = std::process::Command::new("git")
405 .arg("-C")
406 .arg(fixture.path())
407 .args(["checkout", "--", "file.txt"])
408 .status()
409 .expect("git runs");
410 assert!(git.success());
411 let (_, projected) = comment::show(
412 &fixture.refs,
413 &fixture.objects,
414 fixture.path(),
415 &id,
416 "HEAD",
417 true,
418 )
419 .expect("shows");
420 let (anchor, projection) = projected.expect("anchored");
421 assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "line five\n");
422 assert_eq!(
423 projection,
424 ents_anchor::Projection::Outdated {
425 path: "file.txt".to_owned(),
426 }
427 );
428}
429
430// ---------------------------------------------------------------------
431// model.comment-provenance: a state change pins the resolver's record.
432// ---------------------------------------------------------------------
433
434/// The commit message at `id`'s comment ref tip.
435fn tip_message(fixture: &Fixture, id: &str) -> String {
436 use gix_object::Find as _;
437 let name = ents_model::namespace::comment_ref(id).expect("valid");
438 let tip = fixture
439 .refs
440 .get(name.as_ref())
441 .expect("reads")
442 .expect("exists");
443 let mut buf = Vec::new();
444 let data = fixture
445 .objects
446 .try_find(&tip, &mut buf)
447 .expect("finds")
448 .expect("exists");
449 gix_object::CommitRef::from_bytes(data.data, tip.kind())
450 .expect("commit")
451 .message
452 .to_string()
453}
454
455/// Resolving with an enrolled member's key writes a `Key-for-<id>`
456/// trailer naming the member ref's tip commit at resolve time; a signer
457/// whose key matches no enrolled member writes none.
458#[rstest]
459// @relation(model.comment-provenance, scope=function, role=Verifies)
460fn resolving_pins_the_resolvers_member_record() {
461 let fixture = Fixture::new();
462 let id = fixture.add(fixture.draft());
463
464 // Enroll the fixture's signer as member `joey` through the real
465 // proposal path, then capture the member ref's tip.
466 let member = Member::new(
467 MemberId::new("joey"),
468 Keypair::from_seed(1).public_openssh(),
469 Provenance::AdminRegistered,
470 );
471 let name = ents_model::namespace::member_ref(&MemberId::new("joey")).expect("valid");
472 let outcome = ents_receive::propose_entity(
473 &fixture.refs,
474 &fixture.objects,
475 &NullEventSink,
476 name.clone(),
477 &member,
478 &fixture.identity(),
479 "Enroll joey",
480 Mode::Advisory,
481 )
482 .expect("enrolls");
483 assert_eq!(outcome.result, TxResult::Applied);
484 let member_tip = fixture
485 .refs
486 .get(name.as_ref())
487 .expect("reads")
488 .expect("exists");
489
490 comment::resolve(
491 &fixture.refs,
492 &fixture.objects,
493 &NullEventSink,
494 &id,
495 &fixture.identity(),
496 Mode::Advisory,
497 Some(&Keypair::from_seed(1).public_openssh()),
498 )
499 .expect("resolves");
500 let message = tip_message(&fixture, &id);
501 assert!(
502 message.contains(&format!("Key-for-joey: {member_tip}")),
503 "the resolve mutation pins the member record: {message}"
504 );
505
506 // A key matching no enrolled member (seed 2) writes no trailer.
507 comment::reopen(
508 &fixture.refs,
509 &fixture.objects,
510 &NullEventSink,
511 &id,
512 &fixture.identity(),
513 Mode::Advisory,
514 Some(&Keypair::from_seed(2).public_openssh()),
515 )
516 .expect("reopens");
517 let message = tip_message(&fixture, &id);
518 assert!(
519 !message.contains("Key-for-"),
520 "an unenrolled signer writes no trailer: {message}"
521 );
522}