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/// 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}