git-ents.gitmain
⌘K
foforge
lens.rs410 lines · 13.8 KB · rusthistorycomment on this file
1//! Integration coverage for `docs/spec/lens.adoc`, driving the [`Lens`]
2//! request handlers directly against a fixture repository — the strategy
3//! the engineering conventions select for a protocol surface: construct the
4//! server in-process with a real working tree and a comment anchored into
5//! it, then assert each handler's derived LSP value, rather than spawning a
6//! stdio process and parsing frames. The JSON-RPC framing is `lsp-server`'s
7//! own tested concern; what this crate owns is the derivation, so that is
8//! what these tests exercise.
9//!
10//! The seams are `ents-testutil`'s in-memory `MemRefStore`/`ObjectStore`
11//! (the same pair every library crate's tests use) paired with a real
12//! on-disk repository for the working tree the anchors project onto —
13//! `ents_forge::comment::add` embeds the anchored bytes into the object
14//! store, so the two stay consistent even though only one is on disk.
15
16#![allow(
17 clippy::expect_used,
18 clippy::unwrap_used,
19 clippy::indexing_slicing,
20 clippy::panic,
21 reason = "integration test"
22)]
23
24use std::path::Path;
25use std::process::Command;
26
27use ents_forge::comment::{self, NewComment};
28use ents_lens::{CMD_COMPOSE, CMD_RESOLVE, CMD_VIEW, Lens, Signing};
29use ents_receive::{Identity, Mode, NullEventSink};
30use ents_testutil::{Keypair, MemRefStore, ObjectStore};
31use lsp_types::{DiagnosticSeverity, HoverContents, Position, Range, Url};
32use serde_json::json;
33
34/// A fixture repository, its in-memory seams, and a deterministic signing
35/// key — everything a [`Lens`] needs to be wired the way `git ents lsp`
36/// wires it.
37struct Fixture {
38 dir: tempfile::TempDir,
39 refs: MemRefStore,
40 objects: ObjectStore,
41 key: Keypair,
42}
43
44impl Fixture {
45 /// A repository holding `file.txt` with ten numbered lines, committed.
46 fn new() -> Self {
47 let dir = tempfile::tempdir().expect("tempdir");
48 gix::init(dir.path()).expect("init");
49 let contents: String = (1..=10).map(|n| format!("line {n}\n")).collect();
50 commit_file(dir.path(), "file.txt", &contents);
51 Self {
52 dir,
53 refs: MemRefStore::default(),
54 objects: ObjectStore::default(),
55 key: Keypair::from_seed(1),
56 }
57 }
58
59 fn uri(&self, rel: &str) -> Url {
60 Url::from_file_path(self.dir.path().join(rel)).expect("file uri")
61 }
62
63 fn actor(&self) -> gix::actor::Signature {
64 gix::actor::Signature {
65 name: "jdc".into(),
66 email: "jdc@ents.test".into(),
67 time: gix::date::Time {
68 seconds: 1_000,
69 offset: 0,
70 },
71 }
72 }
73
74 /// Add a comment through the same library call the CLI makes
75 /// (`lens.parity`), anchored to `lines` of `file.txt` against the
76 /// working tree.
77 fn add_comment(&self, body: &str, lines: Option<&str>) -> String {
78 let new = NewComment {
79 body: body.to_owned(),
80 path: Some("file.txt".to_owned()),
81 lines: lines.map(str::to_owned),
82 rev: "HEAD".to_owned(),
83 worktree: true,
84 context: None,
85 parent: None,
86 };
87 let key = &self.key;
88 let sign = |payload: &[u8]| key.sign(payload);
89 let identity = Identity {
90 actor: self.actor(),
91 author: None,
92 sign: &sign,
93 };
94 let (id, _outcome) = comment::add(
95 &self.refs,
96 &self.objects,
97 &NullEventSink,
98 self.dir.path(),
99 new,
100 &identity,
101 Mode::Advisory,
102 )
103 .expect("adds a comment");
104 id
105 }
106
107 /// Consume the fixture into a wired [`Lens`] (the seams move in, exactly
108 /// as `git ents lsp`'s composition root moves `LocalRoot`'s seams in).
109 fn into_lens(self) -> (Lens<ObjectStore>, tempfile::TempDir) {
110 let key = Keypair::from_seed(1);
111 let signing = Signing::new(
112 self.actor(),
113 Box::new(move |payload| key.sign(payload)),
114 self.key.public_openssh(),
115 );
116 let lens = Lens::new(
117 Box::new(self.refs),
118 self.objects,
119 Box::new(NullEventSink),
120 Mode::Advisory,
121 signing,
122 self.dir.path().to_owned(),
123 );
124 (lens, self.dir)
125 }
126}
127
128fn commit_file(dir: &Path, path: &str, contents: &str) {
129 std::fs::write(dir.join(path), contents).expect("write");
130 run_git(dir, &["add", "-A"]);
131 run_git(
132 dir,
133 &[
134 "-c",
135 "user.name=test",
136 "-c",
137 "user.email=test@example.com",
138 "commit",
139 "-q",
140 "-m",
141 "seed",
142 ],
143 );
144}
145
146fn run_git(dir: &Path, args: &[&str]) {
147 let status = Command::new("git")
148 .arg("-C")
149 .arg(dir)
150 .args(args)
151 .status()
152 .expect("git runs");
153 assert!(status.success(), "git {args:?} failed");
154}
155
156/// `lens.lenses`: an open comment whose anchor projects onto the document
157/// surfaces as code lenses at its projected line, identifying the comment
158/// and offering View/Reply/Resolve as commands. `lens.diagnostics`: the
159/// same comment is also a hint-severity diagnostic at the same range.
160#[test]
161// @relation(lens.lenses, lens.diagnostics, scope=function, role=Verifies)
162fn code_lenses_and_hint_diagnostics_surface_an_open_comment() {
163 let fixture = Fixture::new();
164 fixture.add_comment("this looks off by one", Some("5:5"));
165 let uri = fixture.uri("file.txt");
166 let (lens, _dir) = fixture.into_lens();
167
168 let lenses = lens.code_lenses(&uri).expect("code lenses");
169 assert_eq!(lenses.len(), 3, "one View/Reply/Resolve set");
170 // Line 5 is 0-based line 4.
171 assert_eq!(lenses[0].range.start.line, 4);
172 let commands: Vec<&str> = lenses
173 .iter()
174 .filter_map(|lens| lens.command.as_ref().map(|c| c.command.as_str()))
175 .collect();
176 assert!(commands.contains(&CMD_VIEW));
177 assert!(commands.contains(&"ents.reply"));
178 assert!(commands.contains(&CMD_RESOLVE));
179 assert!(
180 lenses[0]
181 .command
182 .as_ref()
183 .unwrap()
184 .title
185 .contains("off by one")
186 );
187
188 let diagnostics = lens.diagnostics(&uri).expect("diagnostics");
189 assert_eq!(diagnostics.len(), 1);
190 // `lens.diagnostics` is binding: hint severity, never a warning/error.
191 assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
192 assert_eq!(diagnostics[0].range.start.line, 4);
193}
194
195/// `lens.hover`: hovering the anchored range returns the whole thread —
196/// the root comment and its reply, bodies and authorship — as markup.
197#[test]
198// @relation(lens.hover, scope=function, role=Verifies)
199fn hover_returns_the_full_thread() {
200 let fixture = Fixture::new();
201 let root = fixture.add_comment("root remark", Some("5:5"));
202 // A reply, created through the same library the lens uses.
203 let key = Keypair::from_seed(1);
204 let sign = |payload: &[u8]| key.sign(payload);
205 let identity = Identity {
206 actor: fixture.actor(),
207 author: None,
208 sign: &sign,
209 };
210 comment::reply(
211 &fixture.refs,
212 &fixture.objects,
213 &NullEventSink,
214 &root,
215 "a reply body".to_owned(),
216 &identity,
217 Mode::Advisory,
218 )
219 .expect("replies");
220 let uri = fixture.uri("file.txt");
221 let (lens, _dir) = fixture.into_lens();
222
223 let hover = lens
224 .hover(
225 &uri,
226 Position {
227 line: 4,
228 character: 0,
229 },
230 )
231 .expect("hover")
232 .expect("a comment is anchored at line 5");
233 let HoverContents::Markup(markup) = hover.contents else {
234 panic!("hover must be markup");
235 };
236 assert!(markup.value.contains("root remark"));
237 assert!(markup.value.contains("a reply body"));
238 assert!(
239 markup.value.contains("jdc"),
240 "authorship from the commit chain"
241 );
242
243 // Hovering an unrelated line yields nothing.
244 assert!(
245 lens.hover(
246 &uri,
247 Position {
248 line: 0,
249 character: 0
250 }
251 )
252 .expect("hover")
253 .is_none()
254 );
255}
256
257/// `lens.compose`: a code action on a selection offers "Leave an ents
258/// comment", whose command opens the template; running it writes the
259/// template under `.git/` and asks the client to open that file.
260#[test]
261// @relation(lens.compose, scope=function, role=Verifies)
262fn code_action_and_compose_open_the_template() {
263 let fixture = Fixture::new();
264 let uri = fixture.uri("file.txt");
265 let (lens, dir) = fixture.into_lens();
266
267 let range = Range {
268 start: Position {
269 line: 1,
270 character: 0,
271 },
272 end: Position {
273 line: 2,
274 character: 0,
275 },
276 };
277 let actions = lens.code_actions(&uri, range).expect("code actions");
278 assert_eq!(actions.len(), 1);
279 let lsp_types::CodeActionOrCommand::CodeAction(action) = &actions[0] else {
280 panic!("expected a code action");
281 };
282 assert_eq!(action.title, "Leave an ents comment");
283 let command = action.command.as_ref().expect("carries a command");
284 assert_eq!(command.command, CMD_COMPOSE);
285
286 // Running the command writes the template and asks to open it.
287 let outcome = lens
288 .execute_command(
289 CMD_COMPOSE,
290 &[json!({ "path": "file.txt", "lines": "2:2" })],
291 )
292 .expect("compose");
293 let template = outcome.show_document.expect("opens the template");
294 assert_eq!(
295 template,
296 dir.path().join(".git").join("ENTS_COMMENT_EDITMSG")
297 );
298 let written = std::fs::read_to_string(&template).expect("template written");
299 assert!(written.contains("ents-compose-path: file.txt"));
300 assert!(written.contains("Lines starting with '#' are ignored"));
301}
302
303/// `lens.compose` end to end: saving the template with a non-empty body
304/// creates the comment (anchored to the working tree, `lens.working-tree`),
305/// and it then surfaces as a code lens; an empty body aborts.
306#[test]
307// @relation(lens.compose, lens.working-tree, lens.parity, scope=function, role=Verifies)
308fn saving_a_nonempty_body_creates_the_comment_and_empty_aborts() {
309 let fixture = Fixture::new();
310 let uri = fixture.uri("file.txt");
311 let (lens, dir) = fixture.into_lens();
312 let template = dir.path().join(".git").join("ENTS_COMMENT_EDITMSG");
313 let template_uri = Url::from_file_path(&template).unwrap();
314
315 // Start a compose targeting line 3.
316 lens.execute_command(
317 CMD_COMPOSE,
318 &[json!({ "path": "file.txt", "lines": "3:3" })],
319 )
320 .expect("compose");
321
322 // An empty save aborts: no comment, template removed.
323 std::fs::write(
324 &template,
325 "\n# only comments here\n# ents-compose-path: file.txt\n# ents-compose-lines: 3:3\n",
326 )
327 .unwrap();
328 lens.did_save(&template_uri).expect("save");
329 assert!(lens.code_lenses(&uri).expect("lenses").is_empty());
330 assert!(!template.exists(), "aborted compose removes the template");
331
332 // Re-start and save a real body: the comment is created and surfaces.
333 lens.execute_command(
334 CMD_COMPOSE,
335 &[json!({ "path": "file.txt", "lines": "3:3" })],
336 )
337 .expect("compose");
338 std::fs::write(
339 &template,
340 "the third line is wrong\n# ignored\n# ents-compose-path: file.txt\n# ents-compose-lines: 3:3\n",
341 )
342 .unwrap();
343 lens.did_save(&template_uri).expect("save");
344
345 let lenses = lens.code_lenses(&uri).expect("lenses");
346 assert_eq!(lenses.len(), 3, "the composed comment now surfaces");
347 assert_eq!(lenses[0].range.start.line, 2, "anchored at line 3");
348 assert!(
349 lenses[0]
350 .command
351 .as_ref()
352 .unwrap()
353 .title
354 .contains("third line is wrong")
355 );
356}
357
358/// `lens.parity` + `model.comment-state`: View returns the thread, and
359/// Resolve — the same library call the CLI runs — drops the comment from
360/// the next publish, since only open comments surface (`lens.lenses`).
361#[test]
362// @relation(lens.parity, lens.lenses, scope=function, role=Verifies)
363fn view_returns_the_thread_and_resolve_hides_it() {
364 let fixture = Fixture::new();
365 let id = fixture.add_comment("please fix", Some("5:5"));
366 let uri = fixture.uri("file.txt");
367 let (lens, _dir) = fixture.into_lens();
368
369 let view = lens
370 .execute_command(CMD_VIEW, &[json!(id)])
371 .expect("view")
372 .response
373 .expect("view returns the thread");
374 assert!(view.as_str().unwrap().contains("please fix"));
375
376 // Resolve, then the open-only publish no longer shows it.
377 let outcome = lens
378 .execute_command(CMD_RESOLVE, &[json!(id)])
379 .expect("resolve");
380 assert!(outcome.refresh, "a mutation asks for a diagnostics refresh");
381 assert!(lens.code_lenses(&uri).expect("lenses").is_empty());
382 assert!(lens.diagnostics(&uri).expect("diags").is_empty());
383}
384
385/// `lens.working-tree`: the open buffer stands in for disk, so a comment's
386/// range tracks unsaved edits — prepending two lines in the buffer shifts
387/// the projected lens down by two.
388#[test]
389// @relation(lens.working-tree, scope=function, role=Verifies)
390fn the_buffer_overrides_disk_so_ranges_track_unsaved_edits() {
391 let fixture = Fixture::new();
392 fixture.add_comment("watch this line", Some("5:5"));
393 let uri = fixture.uri("file.txt");
394 let (mut lens, _dir) = fixture.into_lens();
395
396 // On disk the anchor is line 5 (0-based 4).
397 let on_disk = lens.code_lenses(&uri).expect("lenses");
398 assert_eq!(on_disk[0].range.start.line, 4);
399
400 // The client sends a buffer with two extra lines prepended, unsaved.
401 let buffer: String = std::iter::once("added a".to_owned())
402 .chain(std::iter::once("added b".to_owned()))
403 .chain((1..=10).map(|n| format!("line {n}")))
404 .collect::<Vec<_>>()
405 .join("\n");
406 lens.did_open(uri.clone(), format!("{buffer}\n"));
407
408 let shifted = lens.code_lenses(&uri).expect("lenses");
409 assert_eq!(shifted[0].range.start.line, 6, "line 5 shifted to line 7");
410}