1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3
4use syn::visit::{self, Visit};
5use syn::{Expr, ExprCall, ExprField, ExprMatch, ExprPath, Item, ItemFn, Lit, Member, Pat};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
8pub enum RpcCallerRole {
9 Agent,
10 Owner,
11 Operator,
12 Host,
13}
14
15impl RpcCallerRole {
16 pub fn as_str(self) -> &'static str {
17 match self {
18 Self::Agent => "agent",
19 Self::Owner => "owner",
20 Self::Operator => "operator",
21 Self::Host => "host",
22 }
23 }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ExtractedRpcMethod {
28 pub method: String,
29 pub documented: bool,
30 pub role: RpcCallerRole,
31}
32
33#[derive(Debug, Clone, Default)]
34struct FunctionSignals {
35 callees: BTreeSet<String>,
36 host: bool,
37 owner: bool,
38 agent: bool,
39 owner_client: bool,
40 session_client_id: bool,
41}
42
43impl FunctionSignals {
44 fn merge(&mut self, other: &Self) {
45 self.host |= other.host;
46 self.owner |= other.owner;
47 self.agent |= other.agent;
48 self.owner_client |= other.owner_client;
49 self.session_client_id |= other.session_client_id;
50 self.callees.extend(other.callees.iter().cloned());
51 }
52
53 fn role(&self) -> RpcCallerRole {
54 if self.host {
55 RpcCallerRole::Host
56 } else if self.owner || (self.owner_client && self.session_client_id) {
57 RpcCallerRole::Owner
58 } else if self.agent {
59 RpcCallerRole::Agent
60 } else {
61 RpcCallerRole::Operator
62 }
63 }
64}
65
66#[derive(Default)]
67struct SignalVisitor {
68 signals: FunctionSignals,
69}
70
71impl<'ast> Visit<'ast> for SignalVisitor {
72 fn visit_expr_call(&mut self, node: &'ast ExprCall) {
73 if let Expr::Path(path) = node.func.as_ref() {
74 if path.qself.is_none() && path.path.segments.len() == 1 {
78 let callee = path.path.segments[0].ident.to_string();
79 match callee.as_str() {
80 "require_approval_authority" | "require_agent_permissions_authority" => {
81 self.signals.host = true;
82 }
83 "authorize_run_access" => self.signals.owner = true,
84 _ => {}
85 }
86 self.signals.callees.insert(callee);
87 }
88 }
89 visit::visit_expr_call(self, node);
90 }
91
92 fn visit_expr_field(&mut self, node: &'ast ExprField) {
93 let Member::Named(member) = &node.member else {
94 visit::visit_expr_field(self, node);
95 return;
96 };
97 if is_path(node.base.as_ref(), "session") {
98 match member.to_string().as_str() {
99 "agent_id" => self.signals.agent = true,
100 "client_id" => self.signals.session_client_id = true,
101 _ => {}
102 }
103 }
104 visit::visit_expr_field(self, node);
105 }
106
107 fn visit_expr_path(&mut self, node: &'ast ExprPath) {
108 if node.qself.is_none() && node.path.is_ident("owner_client") {
109 self.signals.owner_client = true;
110 }
111 visit::visit_expr_path(self, node);
112 }
113}
114
115fn starts_with_host_refusal(function: &ItemFn) -> bool {
118 let Some(syn::Stmt::Expr(Expr::If(guard), _)) = function.block.stmts.first() else {
119 return false;
120 };
121 let Expr::Unary(negated) = guard.cond.as_ref() else {
122 return false;
123 };
124 if !matches!(negated.op, syn::UnOp::Not(_)) {
125 return false;
126 }
127 let Expr::MethodCall(load) = negated.expr.as_ref() else {
128 return false;
129 };
130 let Expr::Field(field) = load.receiver.as_ref() else {
131 return false;
132 };
133 if load.method != "load"
134 || !is_path(field.base.as_ref(), "session")
135 || !matches!(&field.member, Member::Named(name) if name == "is_host")
136 {
137 return false;
138 }
139 let [syn::Stmt::Expr(Expr::Return(ret), _)] = guard.then_branch.stmts.as_slice() else {
140 return false;
141 };
142 matches!(ret.expr.as_deref(), Some(Expr::Call(call)) if is_path(call.func.as_ref(), "Err"))
143}
144
145fn top_level_function_signals(syntax: &syn::File) -> BTreeMap<String, FunctionSignals> {
146 syntax
147 .items
148 .iter()
149 .filter_map(|item| {
150 let Item::Fn(function) = item else {
151 return None;
152 };
153 let mut visitor = SignalVisitor::default();
154 visitor.visit_block(&function.block);
155 visitor.signals.host |= starts_with_host_refusal(function);
156 Some((function.sig.ident.to_string(), visitor.signals))
157 })
158 .collect()
159}
160
161fn resolved_signals(
162 direct: &FunctionSignals,
163 functions: &BTreeMap<String, FunctionSignals>,
164) -> FunctionSignals {
165 fn visit_callee(
166 name: &str,
167 functions: &BTreeMap<String, FunctionSignals>,
168 visiting: &mut BTreeSet<String>,
169 resolved: &mut FunctionSignals,
170 ) {
171 if !visiting.insert(name.to_string()) {
172 return;
173 }
174 if let Some(signals) = functions.get(name) {
175 resolved.merge(signals);
176 for callee in &signals.callees {
177 visit_callee(callee, functions, visiting, resolved);
178 }
179 }
180 visiting.remove(name);
181 }
182
183 let mut resolved = direct.clone();
184 let mut visiting = BTreeSet::new();
185 for callee in &direct.callees {
186 visit_callee(callee, functions, &mut visiting, &mut resolved);
187 }
188 resolved
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192enum DispatchFunction {
193 Primary,
194 DaemonOwnedAuth,
195}
196
197struct DispatchVisitor<'functions> {
198 function: Option<DispatchFunction>,
199 functions: &'functions BTreeMap<String, FunctionSignals>,
200 alias_groups: Vec<(Vec<String>, RpcCallerRole)>,
201 selected_tables: usize,
202 unsupported_arms: usize,
203}
204
205impl<'functions> DispatchVisitor<'functions> {
206 fn new(functions: &'functions BTreeMap<String, FunctionSignals>) -> Self {
207 Self {
208 function: None,
209 functions,
210 alias_groups: Vec::new(),
211 selected_tables: 0,
212 unsupported_arms: 0,
213 }
214 }
215}
216
217impl<'ast> Visit<'ast> for DispatchVisitor<'_> {
218 fn visit_item_fn(&mut self, node: &'ast ItemFn) {
219 let previous = self.function;
220 self.function = match node.sig.ident.to_string().as_str() {
221 "run_dispatch" => Some(DispatchFunction::Primary),
222 "dispatch_daemon_owned_auth" => Some(DispatchFunction::DaemonOwnedAuth),
223 _ => None,
224 };
225 visit::visit_item_fn(self, node);
226 self.function = previous;
227 }
228
229 fn visit_expr_match(&mut self, node: &'ast ExprMatch) {
230 let is_dispatch = match self.function {
231 Some(DispatchFunction::Primary) => is_as_str_call_on(&node.expr, "method_owned"),
232 Some(DispatchFunction::DaemonOwnedAuth) => is_path(&node.expr, "method"),
233 None => false,
234 };
235 if is_dispatch {
236 self.selected_tables += 1;
237 for arm in &node.arms {
238 let mut methods = Vec::new();
239 collect_string_patterns(&arm.pat, &mut methods);
240 if methods.is_empty() {
241 if !matches!(&arm.pat, Pat::Wild(_)) {
242 self.unsupported_arms += 1;
243 }
244 } else {
245 let role = if self.function == Some(DispatchFunction::DaemonOwnedAuth) {
246 RpcCallerRole::Host
247 } else {
248 let mut direct = SignalVisitor::default();
249 direct.visit_expr(&arm.body);
250 resolved_signals(&direct.signals, self.functions).role()
251 };
252 self.alias_groups.push((methods, role));
253 }
254 }
255 return;
258 }
259 visit::visit_expr_match(self, node);
260 }
261}
262
263fn is_as_str_call_on(expression: &Expr, identifier: &str) -> bool {
264 let Expr::MethodCall(call) = expression else {
265 return false;
266 };
267 call.method == "as_str" && call.args.is_empty() && is_path(&call.receiver, identifier)
268}
269
270fn is_path(expression: &Expr, identifier: &str) -> bool {
271 matches!(
272 expression,
273 Expr::Path(path)
274 if path.qself.is_none()
275 && path.path.segments.len() == 1
276 && path.path.is_ident(identifier)
277 )
278}
279
280fn collect_string_patterns(pattern: &Pat, out: &mut Vec<String>) {
281 match pattern {
282 Pat::Lit(literal) => {
283 if let Lit::Str(value) = &literal.lit {
284 out.push(value.value());
285 }
286 }
287 Pat::Or(patterns) => {
288 for case in &patterns.cases {
289 collect_string_patterns(case, out);
290 }
291 }
292 Pat::Paren(pattern) => collect_string_patterns(&pattern.pat, out),
293 _ => {}
294 }
295}
296
297#[cfg_attr(not(test), allow(dead_code))]
304pub fn extract_rpc_methods(
305 handler_source: &str,
306 protocol_documentation: &str,
307) -> Result<Vec<ExtractedRpcMethod>, String> {
308 extract_rpc_methods_and_table_count(handler_source, protocol_documentation)
309 .map(|(methods, _)| methods)
310}
311
312pub fn role_for_method(
317 methods: &[ExtractedRpcMethod],
318 method: &str,
319) -> Result<RpcCallerRole, String> {
320 methods
321 .iter()
322 .find(|item| item.method == method)
323 .map(|item| item.role)
324 .ok_or_else(|| format!("cannot derive caller role for unknown method `{method}`"))
325}
326
327fn extract_rpc_methods_and_table_count(
328 handler_source: &str,
329 protocol_documentation: &str,
330) -> Result<(Vec<ExtractedRpcMethod>, usize), String> {
331 let syntax = syn::parse_file(handler_source)
332 .map_err(|error| format!("parse car-server-core/src/handler.rs: {error}"))?;
333 let functions = top_level_function_signals(&syntax);
334 let mut visitor = DispatchVisitor::new(&functions);
335 visitor.visit_file(&syntax);
336 if visitor.alias_groups.is_empty() {
337 return Err("found no JSON-RPC string-literal dispatch arms".into());
338 }
339 if visitor.unsupported_arms != 0 {
340 return Err(format!(
341 "found {} non-wildcard JSON-RPC dispatch arm(s) without a string-literal method pattern",
342 visitor.unsupported_arms
343 ));
344 }
345
346 let selected_tables = visitor.selected_tables;
347 let mut methods = BTreeMap::new();
348 for (aliases, role) in visitor.alias_groups {
349 for method in aliases {
350 let documented = documentation_mentions(protocol_documentation, &method);
351 if methods.insert(method.clone(), (documented, role)).is_some() {
352 return Err(format!(
353 "duplicate JSON-RPC dispatch method `{method}` in handler.rs"
354 ));
355 }
356 }
357 }
358
359 Ok((
360 methods
361 .into_iter()
362 .map(|(method, (documented, role))| ExtractedRpcMethod {
363 method,
364 documented,
365 role,
366 })
367 .collect(),
368 selected_tables,
369 ))
370}
371
372fn cross_check_dispatch_table_count(handler_source: &str, selected: usize) -> Result<(), String> {
373 let textual = count_in_function(
377 handler_source,
378 "pub async fn run_dispatch",
379 "\nasync fn send_response",
380 "match method_owned.as_str()",
381 )? + count_in_function(
382 handler_source,
383 "async fn dispatch_daemon_owned_auth",
384 "\nasync fn handle_auth_start",
385 "match method {",
386 )?;
387 if textual != selected {
388 return Err(format!(
389 "JSON-RPC dispatch-table cross-check failed: syn selected {selected}, handler text contains {textual}"
390 ));
391 }
392 Ok(())
393}
394
395fn count_in_function(
396 source: &str,
397 start_marker: &str,
398 end_marker: &str,
399 needle: &str,
400) -> Result<usize, String> {
401 if source.matches(start_marker).count() != 1 {
402 return Err(format!(
403 "JSON-RPC dispatch-table cross-check expected exactly one `{start_marker}`"
404 ));
405 }
406 let start = source.find(start_marker).expect("count checked above");
407 let remainder = &source[start..];
408 let end = remainder.find(end_marker).ok_or_else(|| {
409 format!(
410 "JSON-RPC dispatch-table cross-check found no `{end_marker}` after `{start_marker}`"
411 )
412 })?;
413 Ok(remainder[..end].matches(needle).count())
414}
415
416fn documentation_mentions(documentation: &str, method: &str) -> bool {
421 let mut in_a2a_alias_table = false;
422 for line in documentation.lines() {
423 let line = line.trim();
424 if line == "| v1.0 PascalCase | v0.3 slash form |" {
425 in_a2a_alias_table = true;
426 continue;
427 }
428 if let Some(heading) = line.strip_prefix("#### ") {
429 in_a2a_alias_table = false;
430 if heading.starts_with('`') && code_spans_mention_method(heading, method) {
431 return true;
432 }
433 continue;
434 }
435 if line.starts_with('|') {
436 let mut cells = line.split('|').skip(1);
437 if cells
438 .next()
439 .is_some_and(|cell| code_spans_mention_method(cell, method))
440 {
441 return true;
442 }
443 if in_a2a_alias_table
444 && cells
445 .next()
446 .is_some_and(|cell| code_spans_mention_method(cell, method))
447 {
448 return true;
449 }
450 continue;
451 }
452 in_a2a_alias_table = false;
453 let Some(bullet) = line.strip_prefix("- ") else {
454 continue;
455 };
456 let bullet = bullet.strip_prefix("**").unwrap_or(bullet);
457 if !bullet.starts_with('`') {
458 continue;
459 }
460 let identifier_prefix = bullet.split_once(" —").map_or(bullet, |(prefix, _)| prefix);
461 if code_spans_mention_method(identifier_prefix, method) {
462 return true;
463 }
464 }
465 false
466}
467
468fn code_spans_mention_method(text: &str, method: &str) -> bool {
469 text.split('`')
470 .skip(1)
471 .step_by(2)
472 .any(|code| text_mentions_method(code, method))
473}
474
475fn text_mentions_method(text: &str, method: &str) -> bool {
476 text.match_indices(method).any(|(start, _)| {
477 let before = text[..start].chars().next_back();
478 let end = start + method.len();
479 let after = text[end..].chars().next();
480 !before.is_some_and(is_method_character) && !after.is_some_and(is_method_character)
481 })
482}
483
484fn is_method_character(character: char) -> bool {
485 character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '/' | '-')
486}
487
488pub fn emit_rpc_inventory(manifest_dir: &Path, out_dir: &Path) -> Result<(), String> {
489 let workspace = manifest_dir
490 .parent()
491 .and_then(Path::parent)
492 .ok_or("car-cli is not under <workspace>/crates/car-cli")?;
493 let repository = workspace
494 .parent()
495 .ok_or("car-rs workspace has no repository parent")?;
496 let handler = workspace.join("crates/car-server-core/src/handler.rs");
497 let protocol = repository.join("docs/websocket-protocol.md");
498 println!("cargo:rerun-if-changed={}", handler.display());
499 println!("cargo:rerun-if-changed={}", protocol.display());
500
501 let source = read(&handler)?;
502 let documentation = read(&protocol)?;
503 let (methods, selected_tables) = extract_rpc_methods_and_table_count(&source, &documentation)?;
504 cross_check_dispatch_table_count(&source, selected_tables)?;
505 let values = methods
506 .iter()
507 .map(|item| {
508 let role = role_for_method(&methods, &item.method)?;
509 Ok(serde_json::json!({
510 "method": item.method,
511 "documented": item.documented,
512 "role": role.as_str(),
513 }))
514 })
515 .collect::<Result<Vec<_>, String>>()?;
516 let mut bytes = serde_json::to_vec_pretty(&values)
517 .map_err(|error| format!("serialize JSON-RPC capability inventory: {error}"))?;
518 bytes.push(b'\n');
519 let output_path = out_dir.join("capability-rpc-methods.json");
520 std::fs::write(&output_path, bytes)
521 .map_err(|error| format!("write {}: {error}", output_path.display()))
522}
523
524fn read(path: &Path) -> Result<String, String> {
525 std::fs::read_to_string(path).map_err(|error| format!("read {}: {error}", path.display()))
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn direct_host_refusal_is_distinct_from_host_observation_or_conditional_access() {
534 let source = r#"
535 async fn run_dispatch() {
536 match method_owned.as_str() {
537 "session.clear_halt" => clear(&session),
538 "read" => read(&session),
539 "conditional" => conditional(&session),
540 "not_refusal" => not_refusal(&session),
541 _ => (),
542 }
543 }
544 fn clear(session: &Session) {
545 if !session.is_host.load(Ordering::Acquire) { return Err("host required"); }
546 Ok(())
547 }
548 fn read(session: &Session) { let role = session.is_host.load(Ordering::Acquire); }
549 fn conditional(session: &Session) {
550 if bound && !session.is_host.load(Ordering::Acquire) { return Err("bound"); }
551 }
552 fn not_refusal(session: &Session) {
553 if !session.is_host.load(Ordering::Acquire) { return Ok(false); }
554 }
555 "#;
556 let methods = extract_rpc_methods(source, "").unwrap();
557 assert_eq!(
558 role_for_method(&methods, "session.clear_halt").unwrap(),
559 RpcCallerRole::Host
560 );
561 for method in ["read", "conditional", "not_refusal"] {
562 assert_eq!(
563 role_for_method(&methods, method).unwrap(),
564 RpcCallerRole::Operator
565 );
566 }
567 }
568
569 const FIXTURE: &str = r#"
570 async fn run_dispatch() {
571 let _ = "not.a.dispatch.arm";
572 match unrelated.as_str() {
573 "also.not_dispatched" => (),
574 _ => (),
575 }
576 match method_owned.as_str() {
577 "alpha.one" => handle_agent(&session),
578 "beta/two"
579 | "BetaTwo"
580 | "beta.two" => (),
581 "infer.cancel" => handle_r16_owner(&session),
582 "runs.cancel" => handle_authorized_owner(),
583 "permission.approve" => handle_host(&session),
584 _ => (),
585 }
586 }
587
588 fn handle_agent(session: &Session) {
589 let _ = &session.agent_id;
590 }
591
592 fn handle_r16_owner(session: &Session) {
593 let _ = &session.client_id;
594 let _ = owner_client;
595 }
596
597 fn handle_authorized_owner() {
598 authorize_run_access();
599 }
600
601 fn handle_host(session: &Session) {
602 require_approval_authority(session);
603 }
604
605 async fn dispatch_daemon_owned_auth(method: &str) {
606 match method {
607 "session.auth" => (),
608 _ => (),
609 }
610 }
611
612 fn another_function(method: &str) {
613 match method {
614 "not.dispatched" => (),
615 _ => (),
616 }
617 }
618 "#;
619
620 #[test]
621 fn extracts_multiline_aliases_and_both_dispatchers_only() {
622 let documentation = r#"
623#### `alpha.one`
624| `beta/two` | alias reference |
625#### `session.auth`
626A cross-reference to `infer.cancel` and `infer.cancel.v1` is not its own entry.
627"#;
628 let methods = extract_rpc_methods(FIXTURE, documentation).unwrap();
629 assert_eq!(
630 methods,
631 vec![
632 ExtractedRpcMethod {
633 method: "BetaTwo".into(),
634 documented: false,
635 role: RpcCallerRole::Operator,
636 },
637 ExtractedRpcMethod {
638 method: "alpha.one".into(),
639 documented: true,
640 role: RpcCallerRole::Agent,
641 },
642 ExtractedRpcMethod {
643 method: "beta.two".into(),
644 documented: false,
645 role: RpcCallerRole::Operator,
646 },
647 ExtractedRpcMethod {
648 method: "beta/two".into(),
649 documented: true,
650 role: RpcCallerRole::Operator,
651 },
652 ExtractedRpcMethod {
653 method: "infer.cancel".into(),
654 documented: false,
655 role: RpcCallerRole::Owner,
656 },
657 ExtractedRpcMethod {
658 method: "permission.approve".into(),
659 documented: false,
660 role: RpcCallerRole::Host,
661 },
662 ExtractedRpcMethod {
663 method: "runs.cancel".into(),
664 documented: false,
665 role: RpcCallerRole::Owner,
666 },
667 ExtractedRpcMethod {
668 method: "session.auth".into(),
669 documented: true,
670 role: RpcCallerRole::Host,
671 },
672 ]
673 );
674 }
675
676 #[test]
677 fn role_lookup_refuses_a_method_absent_from_the_real_dispatcher() {
678 let methods = extract_rpc_methods(FIXTURE, "").unwrap();
679 let error = role_for_method(&methods, "not.real").unwrap_err();
680 assert_eq!(
681 error,
682 "cannot derive caller role for unknown method `not.real`"
683 );
684 }
685
686 #[test]
687 fn exact_documentation_boundary_rejects_capability_suffixes() {
688 assert!(!documentation_mentions(
689 "#### `infer.cancel.v1`",
690 "infer.cancel"
691 ));
692 assert!(documentation_mentions(
693 "#### `infer.cancel`",
694 "infer.cancel"
695 ));
696 assert!(documentation_mentions(
697 "#### `diagnostics.secret_store_activity {}`",
698 "diagnostics.secret_store_activity"
699 ));
700 assert!(!documentation_mentions(
701 "ordinary prose can say verify without documenting the RPC",
702 "verify"
703 ));
704 }
705
706 #[test]
707 fn deleting_a_method_section_is_not_masked_by_a_cross_reference() {
708 let with_section = r#"
709#### `infer.cancel`
710- **Params**: `{ request_id }`
711
712#### `infer.deadline`
713- **Returns**: the same status vocabulary as `infer.cancel`.
714"#;
715 let without_section = r#"
716#### `infer.deadline`
717- **Returns**: the same status vocabulary as `infer.cancel`.
718"#;
719 assert!(documentation_mentions(with_section, "infer.cancel"));
720 assert!(!documentation_mentions(without_section, "infer.cancel"));
721 }
722
723 #[test]
724 fn compact_table_and_bullet_entries_are_dedicated_references() {
725 let documentation = r#"
726| v1.0 PascalCase | v0.3 slash form |
727|---|---|
728| `SendStreamingMessage` | `message/stream` |
729- **`permission.approve`** / **`permission.reject`** — Params `{ fingerprint }`.
730"#;
731 for method in [
732 "SendStreamingMessage",
733 "message/stream",
734 "permission.approve",
735 "permission.reject",
736 ] {
737 assert!(documentation_mentions(documentation, method), "{method}");
738 }
739 }
740
741 #[test]
742 fn rejects_non_literal_dispatch_arms_and_duplicate_methods() {
743 let unsupported = FIXTURE.replacen(
744 "\"infer.cancel\" => handle_r16_owner(&session)",
745 "METHOD => ()",
746 1,
747 );
748 let error = extract_rpc_methods(&unsupported, "").unwrap_err();
749 assert!(
750 error.contains("without a string-literal method pattern"),
751 "{error}"
752 );
753
754 let duplicate = FIXTURE.replacen("\"session.auth\" => ()", "\"alpha.one\" => ()", 1);
755 let error = extract_rpc_methods(&duplicate, "").unwrap_err();
756 assert!(
757 error.contains("duplicate JSON-RPC dispatch method `alpha.one`"),
758 "{error}"
759 );
760 }
761
762 #[test]
763 fn textual_table_count_detects_a_nested_same_scrutinee_match() {
764 let fixture = r#"
765pub async fn run_dispatch() {
766 match method_owned.as_str() {
767 "alpha.one" => (),
768 _ => (),
769 }
770}
771async fn send_response() {}
772async fn dispatch_daemon_owned_auth(method: &str) {
773 match method {
774 "session.auth" => (),
775 _ => (),
776 }
777}
778async fn handle_auth_start() {}
779"#;
780 let (_, selected) = extract_rpc_methods_and_table_count(fixture, "").unwrap();
781 assert_eq!(selected, 2);
782 cross_check_dispatch_table_count(fixture, selected).unwrap();
783
784 let nested = fixture.replacen(
785 "\"alpha.one\" => (),",
786 "\"alpha.one\" => { match method_owned.as_str() { \"nested\" => (), _ => () } },",
787 1,
788 );
789 let (_, selected) = extract_rpc_methods_and_table_count(&nested, "").unwrap();
790 assert_eq!(selected, 2);
791 let error = cross_check_dispatch_table_count(&nested, selected).unwrap_err();
792 assert!(
793 error.contains("syn selected 2, handler text contains 3"),
794 "{error}"
795 );
796 }
797}