Skip to main content

vissue_core/
surface.rs

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