Skip to main content

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}