vissue_core/surface.rs
1//! The operation set, read from the Cap'n Proto schema itself.
2//!
3//! Every verb reaches a caller through three surfaces: the command line, the
4//! control socket, and the MCP tool list. Each was declared in its own file in its
5//! own idiom, so a verb could exist on one and not the others. That happened
6//! repeatedly and nothing caught it: `vote` shipped on the command line alone,
7//! `append` had no socket method for as long as the socket existed, and a test
8//! asserting `issue/fold` was an unknown method became wrong the day fold got one.
9//!
10//! The set now lives in `schema/vissue.capnp` and this module reads it. Not a copy
11//! of it and not a parser for it: `capnp compile` encodes the constant into the
12//! generated `vissue_capnp.rs`, so the bytes this reads are the schema, and a
13//! surface is checked against them.
14//!
15//! No toolchain at build time. `capnp` the compiler is absent from the machines
16//! that build this, so the generated file is committed and regenerating it is a
17//! maintainer step; what ships is the encoded constant, which the pure-Rust `capnp`
18//! runtime reads anywhere.
19
20use crate::vissue_capnp::{OPERATIONS, operation};
21
22/// One verb, named on each surface that carries it.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Operation {
25 /// Subcommand name, as clap spells it.
26 pub cli: String,
27 /// Control-socket method, empty when the verb has none.
28 pub socket: String,
29 /// MCP tool name, empty when the verb is deliberately not a tool.
30 pub mcp: String,
31 /// Whether the verb changes a file.
32 pub mutates: bool,
33 /// Whether the verb only makes sense in the process it is typed into.
34 pub local: bool,
35 /// Other names the command line answers to for this verb.
36 pub aliases: Vec<String>,
37 /// The operation this verb is a narrower spelling of, empty for the ordinary case.
38 pub shorthand_for: String,
39 /// Why a surface is empty, when one is.
40 pub note: String,
41 /// Fields the verb takes, each named per surface.
42 pub fields: Vec<Field>,
43}
44
45/// One field of one verb, named per surface.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Field {
48 /// Flag name without dashes, empty when absent or positional.
49 pub cli: String,
50 /// MCP argument name, empty when the tool does not take it.
51 pub tool: String,
52 /// Control-socket parameter name, empty when the method does not take it.
53 pub socket: String,
54 /// Why a surface is empty, or why the names differ.
55 pub note: String,
56 /// Whether a caller may leave the field out on the wire, whatever the type says.
57 pub omittable: bool,
58 /// Rust type of the tool argument, empty when the tool does not take it.
59 pub tool_type: String,
60 /// Rust type of the socket parameter, empty when the method does not take it.
61 pub socket_type: String,
62}
63
64/// The operation set as the schema states it.
65///
66/// # Panics
67///
68/// Panics if the encoded constant cannot be read, which would mean the committed
69/// generated file is corrupt rather than that a caller did anything wrong.
70#[must_use]
71pub fn operations() -> Vec<Operation> {
72 let list = OPERATIONS
73 .get()
74 .expect("the encoded operation set in vissue_capnp.rs is unreadable");
75 list.iter().map(read_one).collect()
76}
77
78fn read_one(row: operation::Reader<'_>) -> Operation {
79 let text = |r: ::capnp::Result<::capnp::text::Reader<'_>>| -> String {
80 r.ok()
81 .and_then(|t| t.to_str().ok().map(str::to_string))
82 .unwrap_or_default()
83 };
84 let fields = row
85 .get_fields()
86 .map(|list| {
87 list.iter()
88 .map(|f| Field {
89 cli: text(f.get_cli()),
90 tool: text(f.get_tool()),
91 socket: text(f.get_socket()),
92 note: text(f.get_note()),
93 omittable: f.get_omittable(),
94 tool_type: text(f.get_tool_type()),
95 socket_type: text(f.get_socket_type()),
96 })
97 .collect()
98 })
99 .unwrap_or_default();
100 Operation {
101 cli: text(row.get_cli()),
102 socket: text(row.get_socket()),
103 mcp: text(row.get_mcp()),
104 mutates: row.get_mutates(),
105 local: row.get_local(),
106 aliases: row
107 .get_aliases()
108 .map(|list| {
109 list.iter()
110 .filter_map(|a| a.ok().and_then(|t| t.to_str().ok().map(str::to_string)))
111 .collect()
112 })
113 .unwrap_or_default(),
114 shorthand_for: text(row.get_shorthand_for()),
115 note: text(row.get_note()),
116 fields,
117 }
118}
119
120/// Verbs the schema records as reaching the socket, mutating or not.
121#[must_use]
122pub fn socket_methods() -> Vec<String> {
123 operations()
124 .into_iter()
125 .filter(|o| !o.socket.is_empty())
126 .map(|o| o.socket)
127 .collect()
128}
129
130/// Every subcommand the schema knows, including the local-only ones.
131#[must_use]
132pub fn cli_verbs() -> Vec<String> {
133 operations()
134 .into_iter()
135 .filter(|o| !o.cli.is_empty())
136 .flat_map(|o| std::iter::once(o.cli).chain(o.aliases))
137 .collect()
138}
139
140/// Flags clap puts on every subcommand, which a per-verb row does not repeat.
141///
142/// # Panics
143///
144/// Panics if the encoded constant cannot be read, which would mean the committed
145/// generated file is corrupt.
146#[must_use]
147pub fn global_flags() -> Vec<String> {
148 crate::vissue_capnp::GLOBAL_FLAGS
149 .get()
150 .expect("the encoded global flag list is unreadable")
151 .iter()
152 .filter_map(|f| f.ok().and_then(|t| t.to_str().ok().map(str::to_string)))
153 .collect()
154}
155
156/// Every mutating verb's socket method, skipping any the schema leaves empty.
157#[must_use]
158pub fn mutating_socket_methods() -> Vec<String> {
159 operations()
160 .into_iter()
161 .filter(|o| o.mutates && !o.socket.is_empty())
162 .map(|o| o.socket)
163 .collect()
164}
165
166/// Every mutating verb's subcommand.
167#[must_use]
168pub fn mutating_cli_verbs() -> Vec<String> {
169 operations()
170 .into_iter()
171 .filter(|o| o.mutates && !o.cli.is_empty())
172 .map(|o| o.cli)
173 .collect()
174}
175
176/// Every mutating verb's MCP tool, skipping the ones deliberately absent.
177#[must_use]
178pub fn mutating_mcp_tools() -> Vec<String> {
179 operations()
180 .into_iter()
181 .filter(|o| o.mutates && !o.mcp.is_empty())
182 .map(|o| o.mcp)
183 .collect()
184}
185
186/// One operation as the schema text states it.
187///
188/// Everything the encoded constant can drift from without changing a surface
189/// name or a field count, which is what made a note-only edit invisible.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct SchemaRow {
192 /// Subcommand name.
193 pub cli: String,
194 /// Control-socket method.
195 pub socket: String,
196 /// MCP tool name.
197 pub mcp: String,
198 /// The operation's own note.
199 pub note: String,
200 /// One note per field, in order, empty where a field has none.
201 pub field_notes: Vec<String>,
202}
203
204/// The schema as its text states it, for comparison with the encoded constant.
205///
206/// This exists to close a hole in the arrangement rather than to be used at
207/// runtime. The constant the checks read is compiled into `vissue_capnp.rs`, which
208/// is committed and regenerated by hand. Edit `vissue.capnp`, forget to regenerate,
209/// and every check happily validates against the previous schema and passes. The
210/// schema would be authoritative in the documentation and not in fact.
211///
212/// So the text is read too, and a test compares the two. A deliberately small
213/// reader for one list of flat records, not a Cap'n Proto parser: it needs to run
214/// where `capnp` is not installed, which is everywhere this is built.
215///
216/// Returns one [`SchemaRow`] per operation.
217#[must_use]
218pub fn parse_schema_text(text: &str) -> Vec<SchemaRow> {
219 let Some(start) = text.find("const operations") else {
220 return Vec::new();
221 };
222 let body = &text[start..];
223 let mut out: Vec<SchemaRow> = Vec::new();
224 for line in body.lines() {
225 let trimmed = line.trim();
226 let quoted = |name: &str| -> Option<String> {
227 let at = trimmed.find(&format!("{name} = "))?;
228 let rest = &trimmed[at..];
229 let open = rest.find('"')?;
230 let close = rest[open + 1..].find('"')?;
231 Some(rest[open + 1..open + 1 + close].to_string())
232 };
233 // An operation row opens with its three surface names on one line.
234 if trimmed.starts_with("( cli = ")
235 && trimmed.contains("mutates = ")
236 && let (Some(cli), Some(socket), Some(mcp)) =
237 (quoted("cli"), quoted("socket"), quoted("mcp"))
238 {
239 out.push(SchemaRow {
240 cli,
241 socket,
242 mcp,
243 note: String::new(),
244 field_notes: Vec::new(),
245 });
246 continue;
247 }
248 // An operation's note is on its own line under the row it belongs to.
249 // It is read because a note-only edit is otherwise invisible: the
250 // surfaces and the field count are unchanged, so the comparison passed
251 // while the constant still carried the previous prose.
252 if trimmed.starts_with("note = ")
253 && let (Some(note), Some(last)) = (quoted("note"), out.last_mut())
254 {
255 last.note = note;
256 continue;
257 }
258 // A field row also opens with `( cli = `, so it is told apart by carrying a
259 // `tool =` and no `socket = "issue/`. Its note rides on the same line.
260 if trimmed.starts_with("( cli = ")
261 && trimmed.contains("tool = ")
262 && !trimmed.contains("mutates = ")
263 && let Some(last) = out.last_mut()
264 {
265 last.field_notes.push(quoted("note").unwrap_or_default());
266 }
267 }
268 out
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn schema_text() -> String {
276 let path =
277 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema/vissue.capnp");
278 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
279 }
280
281 /// The committed generated file and the schema text say the same thing.
282 ///
283 /// Without this the schema is authoritative only in the documentation.
284 /// `vissue_capnp.rs` is regenerated by hand, so an edit to the schema that
285 /// nobody regenerated leaves every other check validating the previous schema
286 /// and passing.
287 #[test]
288 fn the_generated_constant_matches_the_schema_text() {
289 let from_text = parse_schema_text(&schema_text());
290 assert!(
291 !from_text.is_empty(),
292 "no operations parsed from the schema text; the reader and the file have diverged"
293 );
294 let from_bytes: Vec<SchemaRow> = operations()
295 .into_iter()
296 .map(|o| SchemaRow {
297 cli: o.cli,
298 socket: o.socket,
299 mcp: o.mcp,
300 note: o.note,
301 field_notes: o.fields.into_iter().map(|f| f.note).collect(),
302 })
303 .collect();
304 assert_eq!(
305 from_text, from_bytes,
306 "schema/vissue.capnp and the committed vissue_capnp.rs disagree; \
307 regenerate it, see schema/README.md"
308 );
309 }
310
311 /// The note is prose and the checks are about names, which is how a
312 /// note-only edit came to be invisible: the surfaces and the field count
313 /// were unchanged, so the constant kept the previous wording and every
314 /// check passed. The schema is meant to be authoritative in fact and not
315 /// only in the documentation.
316 #[test]
317 fn a_note_the_schema_states_reaches_the_constant() {
318 let ops = operations();
319 let backlinks = ops
320 .iter()
321 .find(|o| o.cli == "backlinks")
322 .expect("backlinks is in the operation set");
323 assert!(
324 backlinks.note.contains("scan every routed tracker"),
325 "the command line and tool half of the split is missing: {:?}",
326 backlinks.note
327 );
328 assert!(
329 backlinks.note.contains("the layout it was started on"),
330 "the socket half of the split is missing: {:?}",
331 backlinks.note
332 );
333 }
334
335 /// The helpers three checks now share, so their own behaviour is pinned rather
336 /// than assumed by each caller.
337
338 #[test]
339 fn the_schema_constant_reads_back() {
340 let ops = operations();
341 assert!(
342 ops.len() >= 10,
343 "the encoded operation set looks truncated: {ops:?}"
344 );
345 assert!(ops.iter().any(|o| o.cli == "create"));
346 assert!(ops.iter().any(|o| o.cli == "vote"), "vote is missing");
347 }
348
349 /// Every mutating verb reaches the socket, itself or through the verb it is a
350 /// shorthand for. This was false for five verbs at once, and for `append` the
351 /// whole time the socket existed.
352 ///
353 /// A write that has to leave the socket puts a hole in the change stream exactly
354 /// where that write was, so the reachability is what matters rather than the
355 /// method count: `q` has no method and needs none, because `create` has one and
356 /// takes everything `q` takes.
357 #[test]
358 fn every_mutating_verb_reaches_the_socket() {
359 let ops = operations();
360 let mut missing = Vec::new();
361 for op in ops.iter().filter(|o| o.mutates && o.socket.is_empty()) {
362 if op.shorthand_for.is_empty() {
363 missing.push(format!("{} reaches no socket method", op.cli));
364 continue;
365 }
366 match ops.iter().find(|o| o.cli == op.shorthand_for) {
367 None => missing.push(format!(
368 "{} is a shorthand for {}, which is in no row",
369 op.cli, op.shorthand_for
370 )),
371 Some(target) if target.socket.is_empty() => missing.push(format!(
372 "{} is a shorthand for {}, which reaches no socket method either",
373 op.cli, op.shorthand_for
374 )),
375 Some(_) => {}
376 }
377 }
378 assert!(
379 missing.is_empty(),
380 "these change a file and no socket method can: {missing:?}"
381 );
382 }
383
384 /// A shorthand takes a subset of the fields of the verb it shortens.
385 ///
386 /// That subset is what makes the exemption above sound. A shorthand taking a
387 /// field its target does not is not a narrower spelling of it, and answering for
388 /// it with the target's socket method would drop that field on the floor.
389 #[test]
390 fn a_shorthand_takes_a_subset_of_what_it_shortens() {
391 let ops = operations();
392 let mut wrong = Vec::new();
393 for op in ops.iter().filter(|o| !o.shorthand_for.is_empty()) {
394 let Some(target) = ops.iter().find(|o| o.cli == op.shorthand_for) else {
395 continue; // reported by every_mutating_verb_reaches_the_socket
396 };
397 for field in &op.fields {
398 if field.cli.is_empty() {
399 continue;
400 }
401 if !target.fields.iter().any(|f| f.cli == field.cli) {
402 wrong.push(format!(
403 "{} takes --{} and {} does not",
404 op.cli, field.cli, target.cli
405 ));
406 }
407 }
408 }
409 assert!(
410 wrong.is_empty(),
411 "these shorthands take fields the verb they shorten does not: {wrong:?}"
412 );
413 }
414
415 /// A shorthand names a verb other than itself, and that verb is not itself a
416 /// shorthand. A cycle or a chain would make the reachability argument circular.
417 #[test]
418 fn a_shorthand_points_at_a_verb_that_stands_on_its_own() {
419 let ops = operations();
420 let mut wrong = Vec::new();
421 for op in ops.iter().filter(|o| !o.shorthand_for.is_empty()) {
422 if op.shorthand_for == op.cli {
423 wrong.push(format!("{} is a shorthand for itself", op.cli));
424 }
425 if let Some(target) = ops
426 .iter()
427 .find(|o| o.cli == op.shorthand_for)
428 .filter(|t| !t.shorthand_for.is_empty())
429 {
430 wrong.push(format!(
431 "{} shortens {}, which shortens {}",
432 op.cli, target.cli, target.shorthand_for
433 ));
434 }
435 }
436 assert!(wrong.is_empty(), "{wrong:?}");
437 }
438
439 /// A surface left empty says why, so a deliberate omission cannot pass for an
440 /// oversight or the other way round.
441 #[test]
442 fn a_missing_surface_carries_its_reason() {
443 for o in operations() {
444 if o.socket.is_empty() || o.mcp.is_empty() {
445 assert!(
446 !o.note.is_empty(),
447 "{} leaves a surface empty and says nothing about why",
448 o.cli
449 );
450 }
451 }
452 }
453
454 /// Names are not blank and not accidentally duplicated.
455 #[test]
456 fn the_names_are_distinct_and_present() {
457 let ops = operations();
458 // A row may have no subcommand, when the command line reaches the operation
459 // through a flag on another verb: `vissue_org` is `show --org`. It has to
460 // reach *some* surface and say why the others are empty, which the note
461 // check enforces, so the requirement here is a surface rather than a
462 // subcommand.
463 for o in &ops {
464 assert!(
465 !(o.cli.is_empty() && o.socket.is_empty() && o.mcp.is_empty()),
466 "an operation reaches no surface at all: {o:?}"
467 );
468 }
469 // Empty is not a name. Two rows without a subcommand are two operations the
470 // command line reaches through a flag, not a collision.
471 let mut clis: Vec<&str> = ops
472 .iter()
473 .map(|o| o.cli.as_str())
474 .filter(|c| !c.is_empty())
475 .collect();
476 clis.sort_unstable();
477 let before = clis.len();
478 clis.dedup();
479 assert_eq!(before, clis.len(), "two operations share a subcommand");
480
481 // Two subcommands may answer from one method, and one pair does: `identity`
482 // and `whoami` both reach `identity/get`. That is a fact about the surfaces
483 // rather than a mistake, so it is allowed when the rows say so. Silence is
484 // what is refused, because an undocumented duplicate is the shape a
485 // copy-paste error takes.
486 let mut by_method: std::collections::BTreeMap<&str, Vec<&Operation>> =
487 std::collections::BTreeMap::new();
488 for o in &ops {
489 if !o.socket.is_empty() {
490 by_method.entry(o.socket.as_str()).or_default().push(o);
491 }
492 }
493 for (method, sharers) in by_method {
494 if sharers.len() < 2 {
495 continue;
496 }
497 let silent: Vec<&str> = sharers
498 .iter()
499 .filter(|o| o.note.is_empty())
500 .map(|o| o.cli.as_str())
501 .collect();
502 assert!(
503 silent.is_empty(),
504 "{method} answers for {silent:?} and none of them says why"
505 );
506 }
507 }
508}