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/// The schema as its text states it, for comparison with the encoded constant.
187///
188/// This exists to close a hole in the arrangement rather than to be used at
189/// runtime. The constant the checks read is compiled into `vissue_capnp.rs`, which
190/// is committed and regenerated by hand. Edit `vissue.capnp`, forget to regenerate,
191/// and every check happily validates against the previous schema and passes. The
192/// schema would be authoritative in the documentation and not in fact.
193///
194/// So the text is read too, and a test compares the two. A deliberately small
195/// reader for one list of flat records, not a Cap'n Proto parser: it needs to run
196/// where `capnp` is not installed, which is everywhere this is built.
197///
198/// Returns one entry per operation: `(cli, socket, mcp, field-count)`.
199#[must_use]
200pub fn parse_schema_text(text: &str) -> Vec<(String, String, String, usize)> {
201 let Some(start) = text.find("const operations") else {
202 return Vec::new();
203 };
204 let body = &text[start..];
205 let mut out: Vec<(String, String, String, usize)> = Vec::new();
206 for line in body.lines() {
207 let trimmed = line.trim();
208 let quoted = |name: &str| -> Option<String> {
209 let at = trimmed.find(&format!("{name} = "))?;
210 let rest = &trimmed[at..];
211 let open = rest.find('"')?;
212 let close = rest[open + 1..].find('"')?;
213 Some(rest[open + 1..open + 1 + close].to_string())
214 };
215 // An operation row opens with its three surface names on one line.
216 if trimmed.starts_with("( cli = ")
217 && trimmed.contains("mutates = ")
218 && let (Some(cli), Some(socket), Some(mcp)) =
219 (quoted("cli"), quoted("socket"), quoted("mcp"))
220 {
221 out.push((cli, socket, mcp, 0));
222 continue;
223 }
224 // A field row also opens with `( cli = `, so it is told apart by carrying a
225 // `tool =` and no `socket = "issue/`. Counting them catches a field added to
226 // the text without a regeneration.
227 if trimmed.starts_with("( cli = ")
228 && trimmed.contains("tool = ")
229 && !trimmed.contains("mutates = ")
230 && let Some(last) = out.last_mut()
231 {
232 last.3 += 1;
233 }
234 }
235 out
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn schema_text() -> String {
243 let path =
244 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema/vissue.capnp");
245 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
246 }
247
248 /// The committed generated file and the schema text say the same thing.
249 ///
250 /// Without this the schema is authoritative only in the documentation.
251 /// `vissue_capnp.rs` is regenerated by hand, so an edit to the schema that
252 /// nobody regenerated leaves every other check validating the previous schema
253 /// and passing.
254 #[test]
255 fn the_generated_constant_matches_the_schema_text() {
256 let from_text = parse_schema_text(&schema_text());
257 assert!(
258 !from_text.is_empty(),
259 "no operations parsed from the schema text; the reader and the file have diverged"
260 );
261 let from_bytes: Vec<(String, String, String, usize)> = operations()
262 .into_iter()
263 .map(|o| (o.cli, o.socket, o.mcp, o.fields.len()))
264 .collect();
265 assert_eq!(
266 from_text, from_bytes,
267 "schema/vissue.capnp and the committed vissue_capnp.rs disagree; \
268 regenerate it, see schema/README.md"
269 );
270 }
271
272 /// The helpers three checks now share, so their own behaviour is pinned rather
273 /// than assumed by each caller.
274
275 #[test]
276 fn the_schema_constant_reads_back() {
277 let ops = operations();
278 assert!(
279 ops.len() >= 10,
280 "the encoded operation set looks truncated: {ops:?}"
281 );
282 assert!(ops.iter().any(|o| o.cli == "create"));
283 assert!(ops.iter().any(|o| o.cli == "vote"), "vote is missing");
284 }
285
286 /// Every mutating verb reaches the socket, itself or through the verb it is a
287 /// shorthand for. This was false for five verbs at once, and for `append` the
288 /// whole time the socket existed.
289 ///
290 /// A write that has to leave the socket puts a hole in the change stream exactly
291 /// where that write was, so the reachability is what matters rather than the
292 /// method count: `q` has no method and needs none, because `create` has one and
293 /// takes everything `q` takes.
294 #[test]
295 fn every_mutating_verb_reaches_the_socket() {
296 let ops = operations();
297 let mut missing = Vec::new();
298 for op in ops.iter().filter(|o| o.mutates && o.socket.is_empty()) {
299 if op.shorthand_for.is_empty() {
300 missing.push(format!("{} reaches no socket method", op.cli));
301 continue;
302 }
303 match ops.iter().find(|o| o.cli == op.shorthand_for) {
304 None => missing.push(format!(
305 "{} is a shorthand for {}, which is in no row",
306 op.cli, op.shorthand_for
307 )),
308 Some(target) if target.socket.is_empty() => missing.push(format!(
309 "{} is a shorthand for {}, which reaches no socket method either",
310 op.cli, op.shorthand_for
311 )),
312 Some(_) => {}
313 }
314 }
315 assert!(
316 missing.is_empty(),
317 "these change a file and no socket method can: {missing:?}"
318 );
319 }
320
321 /// A shorthand takes a subset of the fields of the verb it shortens.
322 ///
323 /// That subset is what makes the exemption above sound. A shorthand taking a
324 /// field its target does not is not a narrower spelling of it, and answering for
325 /// it with the target's socket method would drop that field on the floor.
326 #[test]
327 fn a_shorthand_takes_a_subset_of_what_it_shortens() {
328 let ops = operations();
329 let mut wrong = Vec::new();
330 for op in ops.iter().filter(|o| !o.shorthand_for.is_empty()) {
331 let Some(target) = ops.iter().find(|o| o.cli == op.shorthand_for) else {
332 continue; // reported by every_mutating_verb_reaches_the_socket
333 };
334 for field in &op.fields {
335 if field.cli.is_empty() {
336 continue;
337 }
338 if !target.fields.iter().any(|f| f.cli == field.cli) {
339 wrong.push(format!(
340 "{} takes --{} and {} does not",
341 op.cli, field.cli, target.cli
342 ));
343 }
344 }
345 }
346 assert!(
347 wrong.is_empty(),
348 "these shorthands take fields the verb they shorten does not: {wrong:?}"
349 );
350 }
351
352 /// A shorthand names a verb other than itself, and that verb is not itself a
353 /// shorthand. A cycle or a chain would make the reachability argument circular.
354 #[test]
355 fn a_shorthand_points_at_a_verb_that_stands_on_its_own() {
356 let ops = operations();
357 let mut wrong = Vec::new();
358 for op in ops.iter().filter(|o| !o.shorthand_for.is_empty()) {
359 if op.shorthand_for == op.cli {
360 wrong.push(format!("{} is a shorthand for itself", op.cli));
361 }
362 if let Some(target) = ops
363 .iter()
364 .find(|o| o.cli == op.shorthand_for)
365 .filter(|t| !t.shorthand_for.is_empty())
366 {
367 wrong.push(format!(
368 "{} shortens {}, which shortens {}",
369 op.cli, target.cli, target.shorthand_for
370 ));
371 }
372 }
373 assert!(wrong.is_empty(), "{wrong:?}");
374 }
375
376 /// A surface left empty says why, so a deliberate omission cannot pass for an
377 /// oversight or the other way round.
378 #[test]
379 fn a_missing_surface_carries_its_reason() {
380 for o in operations() {
381 if o.socket.is_empty() || o.mcp.is_empty() {
382 assert!(
383 !o.note.is_empty(),
384 "{} leaves a surface empty and says nothing about why",
385 o.cli
386 );
387 }
388 }
389 }
390
391 /// Names are not blank and not accidentally duplicated.
392 #[test]
393 fn the_names_are_distinct_and_present() {
394 let ops = operations();
395 // A row may have no subcommand, when the command line reaches the operation
396 // through a flag on another verb: `vissue_org` is `show --org`. It has to
397 // reach *some* surface and say why the others are empty, which the note
398 // check enforces, so the requirement here is a surface rather than a
399 // subcommand.
400 for o in &ops {
401 assert!(
402 !(o.cli.is_empty() && o.socket.is_empty() && o.mcp.is_empty()),
403 "an operation reaches no surface at all: {o:?}"
404 );
405 }
406 // Empty is not a name. Two rows without a subcommand are two operations the
407 // command line reaches through a flag, not a collision.
408 let mut clis: Vec<&str> = ops
409 .iter()
410 .map(|o| o.cli.as_str())
411 .filter(|c| !c.is_empty())
412 .collect();
413 clis.sort_unstable();
414 let before = clis.len();
415 clis.dedup();
416 assert_eq!(before, clis.len(), "two operations share a subcommand");
417
418 // Two subcommands may answer from one method, and one pair does: `identity`
419 // and `whoami` both reach `identity/get`. That is a fact about the surfaces
420 // rather than a mistake, so it is allowed when the rows say so. Silence is
421 // what is refused, because an undocumented duplicate is the shape a
422 // copy-paste error takes.
423 let mut by_method: std::collections::BTreeMap<&str, Vec<&Operation>> =
424 std::collections::BTreeMap::new();
425 for o in &ops {
426 if !o.socket.is_empty() {
427 by_method.entry(o.socket.as_str()).or_default().push(o);
428 }
429 }
430 for (method, sharers) in by_method {
431 if sharers.len() < 2 {
432 continue;
433 }
434 let silent: Vec<&str> = sharers
435 .iter()
436 .filter(|o| o.note.is_empty())
437 .map(|o| o.cli.as_str())
438 .collect();
439 assert!(
440 silent.is_empty(),
441 "{method} answers for {silent:?} and none of them says why"
442 );
443 }
444 }
445}