1use crate::vissue_capnp::{OPERATIONS, operation};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Operation {
11 pub cli: String,
13 pub socket: String,
15 pub mcp: String,
17 pub mutates: bool,
19 pub local: bool,
21 pub aliases: Vec<String>,
23 pub shorthand_for: String,
25 pub note: String,
27 pub fields: Vec<Field>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Field {
34 pub cli: String,
36 pub tool: String,
38 pub socket: String,
40 pub note: String,
42 pub omittable: bool,
44 pub tool_type: String,
46 pub socket_type: String,
48}
49
50#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct SchemaRow {
178 pub cli: String,
180 pub socket: String,
182 pub mcp: String,
184 pub note: String,
186 pub field_notes: Vec<String>,
188}
189
190#[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 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 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 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 #[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 #[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 #[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 #[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 #[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; };
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 #[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 #[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 #[test]
413 fn the_names_are_distinct_and_present() {
414 let ops = operations();
415 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 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 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}