Skip to main content

car_server_core/
rpc_manifest.rs

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            // Only an unqualified call can name one of handler.rs's top-level
75            // helpers. Using the trailing segment of `module::handle` would
76            // accidentally resolve it to an unrelated local `handle` function.
77            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
115// Only an unconditional leading host-refusal guard establishes this role.
116// Merely reading is_host (or conditionally restricting agents) is not enough.
117fn 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            // Method arms contain many implementation matches. They are not dispatch
256            // tables, even when one happens to inspect a variable named `method`.
257            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/// Extract the daemon's client-to-server string-literal dispatch arms.
298///
299/// The primary dispatcher and its pre-handshake auth sub-dispatch are selected
300/// structurally. Other string matches and literals in handler implementations
301/// are deliberately excluded. Every alias spelling is checked independently so
302/// adding a new literal to an already-documented arm still trips the ratchet.
303#[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
312/// Look up a role only after the method has been extracted from the real
313/// dispatcher. This explicit failure path is used by the generator's negative
314/// control: a method absent from the daemon cannot silently default to
315/// `operator`.
316pub 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    // This intentionally cheap lexical check is independent of the syn visitor.
374    // The bounded function regions let it see a nested same-scrutinee match that
375    // the visitor's deliberate early return would otherwise skip.
376    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
416/// A method is documented only by its own reference entry, never by a mention
417/// in another method's prose or in an example. Most entries are level-four
418/// headings. Compact grouped references may use a table row or a leading
419/// method-name bullet; only the identifying cells/prefix are considered.
420fn 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}