Skip to main content

robonix_codegen/codegen/
contract_gen.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Generate `robonix_contracts.proto` from `<root>/capabilities/**/*.toml`.
3//
4// `[mode].type` → `robonix_contracts.proto` (see `<root>/capabilities/README.md`).
5// Streaming: `rpc_server_stream` uses the .srv response (exactly one field) as stream element; `rpc_client_stream` uses the request (exactly one field).
6
7use anyhow::{Context, Result, bail};
8use serde::Deserialize;
9use std::collections::BTreeSet;
10use std::fmt::Write as _;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use super::msg_parser::{MsgField, MsgResolver, MsgTypeRef, SrvSpec};
15use super::proto_gen::proto_package_name;
16
17#[derive(Debug, Deserialize)]
18struct ContractToml {
19    contract: ContractMeta,
20    mode: ModeSpec,
21}
22
23#[derive(Debug, Deserialize)]
24struct ContractMeta {
25    id: String,
26    #[allow(dead_code)]
27    version: String,
28    #[allow(dead_code)]
29    kind: String,
30    /// IDL reference: full path under one of the merged lib roots
31    /// (`<robonix>/capabilities/lib/` or `<pkg>/capabilities/lib/`),
32    /// with the `.srv` / `.msg` extension. Examples:
33    ///   `system/pilot/srv/SubmitTask.srv`        → lib/.../srv/SubmitTask.srv
34    ///   `common_interfaces/sensor_msgs/msg/Image.msg` → lib/.../msg/Image.msg
35    idl: String,
36}
37
38#[derive(Debug, Deserialize)]
39struct ModeSpec {
40    #[serde(rename = "type")]
41    mode_type: String,
42}
43
44/// Internal IDL-reference triple after parsing `<lib-relative path>`.
45/// Constructed in resolve_contract_io from the `[contract].idl` schema;
46/// passed to the per-mode resolvers.
47struct IdlRef<'a> {
48    /// Original path string from the toml field, kept for error messages.
49    path: &'a str,
50}
51
52/// `(ROS package, srv name)` pairs from every contract's
53/// `[contract].idl` when it points at a `.srv`. Used by proto generation:
54/// only these `.srv` files get `*_Request` / `*_Response` messages.
55pub fn collect_referenced_srvs(contracts_dir: &Path) -> Result<BTreeSet<(String, String)>> {
56    let paths = collect_tomls(contracts_dir)?;
57    let mut set = BTreeSet::new();
58    for p in paths {
59        let raw =
60            fs::read_to_string(&p).with_context(|| format!("read contract {}", p.display()))?;
61        let c: ContractToml =
62            toml::from_str(&raw).with_context(|| format!("parse TOML {}", p.display()))?;
63        let idl = c.contract.idl.trim();
64        match parse_idl_path(idl) {
65            Some((pkg, "srv", name)) => {
66                set.insert((pkg.to_string(), name.to_string()));
67            }
68            Some((_, "msg", _)) => {
69                // `idl` points at a `.msg` (topic mode); not a srv reference.
70            }
71            _ => bail!(
72                "contract {}: [contract].idl must be a lib-relative file path ending in .srv or .msg, got {idl:?}",
73                c.contract.id
74            ),
75        }
76    }
77    Ok(set)
78}
79
80pub fn generate(
81    resolver: &mut MsgResolver,
82    contracts_dirs: &[PathBuf],
83    out_dir: &Path,
84    verbose: bool,
85) -> Result<()> {
86    let mut paths: Vec<PathBuf> = Vec::new();
87    for d in contracts_dirs {
88        for p in collect_tomls(d)? {
89            paths.push(p);
90        }
91    }
92    if paths.is_empty() {
93        if verbose {
94            for d in contracts_dirs {
95                eprintln!(
96                    "[robonix-codegen] contracts: no .toml under {}",
97                    d.display()
98                );
99            }
100        }
101        return Ok(());
102    }
103
104    // De-dup on contract id: later root wins, matching atlas's
105    // contract-registry merge semantics. This lets a per-package
106    // contract override a global one of the same id during codegen.
107    let mut by_id: std::collections::BTreeMap<String, (PathBuf, ContractToml)> =
108        std::collections::BTreeMap::new();
109    for p in paths {
110        let raw =
111            fs::read_to_string(&p).with_context(|| format!("read contract {}", p.display()))?;
112        let c: ContractToml =
113            toml::from_str(&raw).with_context(|| format!("parse TOML {}", p.display()))?;
114        by_id.insert(c.contract.id.clone(), (p, c));
115    }
116    let mut contracts: Vec<(PathBuf, ContractToml)> = by_id.into_values().collect();
117    contracts.sort_by(|a, b| a.1.contract.id.cmp(&b.1.contract.id));
118
119    let mut out = String::new();
120    writeln!(&mut out, "// @generated by robonix-codegen (--contracts).")?;
121    writeln!(&mut out, "// Do not edit by hand.")?;
122    writeln!(&mut out, "syntax = \"proto3\";")?;
123    writeln!(&mut out)?;
124    writeln!(&mut out, "package robonix.contracts;")?;
125    writeln!(&mut out)?;
126    writeln!(&mut out, "import \"google/protobuf/empty.proto\";")?;
127    writeln!(&mut out)?;
128
129    let mut imports: BTreeSet<String> = BTreeSet::new();
130    let mut needs_string_wire = false;
131
132    let mut proto_types: Vec<(String, ResolvedType, ResolvedType)> = Vec::new();
133    for (_, c) in &contracts {
134        let (in_t, out_t) = resolve_contract_io(c, resolver, &mut imports, &mut needs_string_wire)?;
135        proto_types.push((c.contract.id.clone(), in_t, out_t));
136    }
137
138    for imp in &imports {
139        writeln!(&mut out, "import \"{imp}\";",)?;
140    }
141    if !imports.is_empty() {
142        writeln!(&mut out)?;
143    }
144
145    if needs_string_wire {
146        writeln!(
147            &mut out,
148            "// Wrapper for contracts that use primitive/string until shared IDL exists."
149        )?;
150        writeln!(&mut out, "message StringWire {{")?;
151        writeln!(&mut out, "  string value = 1;")?;
152        writeln!(&mut out, "}}")?;
153        writeln!(&mut out)?;
154    }
155
156    for ((_, c), (_, in_t, out_t)) in contracts.iter().zip(proto_types.iter()) {
157        let mode = c.mode.mode_type.trim();
158        let svc = contract_id_to_service_name(&c.contract.id);
159        // RPC method name:
160        //   - srv-backed contracts (rpc / rpc_*_stream): use the .srv
161        //     filename basename (canonical PascalCase). Existing
162        //     consumers across the codebase expect this.
163        //   - msg-backed contracts (topic_in / topic_out): use the
164        //     contract_id leaf (e.g. `robonix/primitive/audio/mic` →
165        //     `mic` → CamelCased to `Mic`). Don't use the .msg basename
166        //     (`AudioChunk` etc.) — that would change the wire-level
167        //     gRPC method name and break existing client code.
168        let idl_kind = parse_idl_path(c.contract.idl.trim()).map(|(_, kind, _)| kind);
169        let method_raw = if idl_kind == Some("srv") {
170            parse_idl_path(c.contract.idl.trim())
171                .map(|(_, _, name)| name.to_string())
172                .unwrap_or_else(|| c.contract.id.clone())
173        } else {
174            c.contract
175                .id
176                .rsplit_once('/')
177                .map(|(_, leaf)| leaf.to_string())
178                .unwrap_or_else(|| c.contract.id.clone())
179        };
180        // RPC method names must be UpperCamelCase. `.srv` filenames are
181        // already CamelCase by ROS convention so this is identity for
182        // them; the fallback (contract id leaf, e.g. `scan_2d` for
183        // topic-style contracts) gets normalised here.
184        let method = upper_camel(&method_raw);
185        writeln!(
186            &mut out,
187            "// contract: {} (v{})",
188            c.contract.id, c.contract.version
189        )?;
190        writeln!(&mut out, "service {svc} {{")?;
191
192        let rpc = match mode {
193            "rpc" => format_unary(&method, in_t, out_t),
194            "rpc_server_stream" | "topic_out" => format_stream_out(&method, in_t, out_t),
195            "rpc_client_stream" | "topic_in" => format_stream_in(&method, in_t, out_t),
196            "rpc_bidirectional_stream" => format_bidi_stream(&method, in_t, out_t),
197            other => bail!(
198                "unknown [mode].type '{other}' in contract {} (expected rpc | rpc_server_stream | rpc_client_stream | topic_out | topic_in)",
199                c.contract.id
200            ),
201        };
202        writeln!(&mut out, "  {rpc}")?;
203
204        writeln!(&mut out, "}}")?;
205        writeln!(&mut out)?;
206    }
207
208    let outfile = out_dir.join("robonix_contracts.proto");
209    fs::write(&outfile, &out).with_context(|| format!("write {}", outfile.display()))?;
210    if verbose {
211        eprintln!(
212            "[robonix-codegen] contracts: wrote {} ({} services)",
213            outfile.display(),
214            contracts.len()
215        );
216    }
217
218    super::contract_proto_modules_gen::write(out_dir, verbose)?;
219    Ok(())
220}
221
222#[derive(Clone)]
223enum ResolvedType {
224    ProtoFqn(String),
225    GoogleEmpty,
226    /// Reserved escape hatch for raw string-typed contracts. Currently
227    /// unused (no contract opts in); kept so the plumbing is in place
228    /// for the rare case it's needed.
229    #[allow(dead_code)]
230    StringWire,
231}
232
233fn format_stream_out(method: &str, input: &ResolvedType, output: &ResolvedType) -> String {
234    format!(
235        "rpc {method}({}) returns (stream {});",
236        empty_or_type(input),
237        stream_element(output)
238    )
239}
240
241fn format_stream_in(method: &str, input: &ResolvedType, output: &ResolvedType) -> String {
242    format!(
243        "rpc {method}(stream {}) returns ({});",
244        stream_element(input),
245        unary_return(output)
246    )
247}
248
249fn format_bidi_stream(method: &str, input: &ResolvedType, output: &ResolvedType) -> String {
250    format!(
251        "rpc {method}(stream {}) returns (stream {});",
252        stream_element(input),
253        stream_element(output)
254    )
255}
256
257fn format_unary(method: &str, input: &ResolvedType, output: &ResolvedType) -> String {
258    format!(
259        "rpc {method}({}) returns ({});",
260        unary_arg(input),
261        unary_return(output)
262    )
263}
264
265fn empty_or_type(t: &ResolvedType) -> String {
266    match t {
267        ResolvedType::GoogleEmpty => "google.protobuf.Empty".to_string(),
268        ResolvedType::ProtoFqn(s) => s.clone(),
269        ResolvedType::StringWire => "robonix.contracts.StringWire".to_string(),
270    }
271}
272
273fn unary_arg(t: &ResolvedType) -> String {
274    empty_or_type(t)
275}
276
277fn unary_return(t: &ResolvedType) -> String {
278    match t {
279        ResolvedType::GoogleEmpty => "google.protobuf.Empty".to_string(),
280        ResolvedType::ProtoFqn(s) => s.clone(),
281        ResolvedType::StringWire => "robonix.contracts.StringWire".to_string(),
282    }
283}
284
285fn stream_element(t: &ResolvedType) -> String {
286    unary_arg(t)
287}
288
289fn srv_stream_field_to_resolved(
290    contract_id: &str,
291    srv_path: &str,
292    section: &str,
293    field: &MsgField,
294    resolver: &mut MsgResolver,
295    imports: &mut BTreeSet<String>,
296    needs_string_wire: &mut bool,
297) -> Result<ResolvedType> {
298    if field.is_array {
299        bail!(
300            "contract {contract_id}: [{section}] stream element must be a single message, not an array (in {srv_path})"
301        );
302    }
303    field_to_resolved_type(field, resolver, imports, needs_string_wire)
304}
305
306fn resolve_contract_io(
307    c: &ContractToml,
308    resolver: &mut MsgResolver,
309    imports: &mut BTreeSet<String>,
310    needs_string_wire: &mut bool,
311) -> Result<(ResolvedType, ResolvedType)> {
312    let mode = c.mode.mode_type.trim();
313    let idl_path = c.contract.idl.trim();
314    let (_, kind, _) = parse_idl_path(idl_path).ok_or_else(|| {
315        anyhow::anyhow!(
316            "contract {}: [contract].idl must be a lib-relative file path ending in .srv or .msg, got {idl_path:?}",
317            c.contract.id
318        )
319    })?;
320
321    // Verify the file actually exists at this path under at least one
322    // configured lib root. The `idl` field is interpreted as the literal
323    // lib-relative file path (with extension); codegen joins each
324    // lib_root with this path and checks file existence.
325    if !idl_path_exists(idl_path, resolver) {
326        bail!(
327            "contract {}: idl path {idl_path:?} doesn't resolve to a file under any lib root ({})",
328            c.contract.id,
329            resolver.include_paths.len()
330        );
331    }
332
333    let idl = IdlRef { path: idl_path };
334
335    match (mode, kind) {
336        ("rpc", "srv") => resolve_srv_contract_pair(idl.path, resolver, imports, needs_string_wire),
337        ("rpc_server_stream", "srv") => {
338            resolve_srv_server_stream(&idl, &c.contract.id, resolver, imports, needs_string_wire)
339        }
340        ("rpc_client_stream", "srv") => {
341            resolve_srv_client_stream(&idl, &c.contract.id, resolver, imports, needs_string_wire)
342        }
343        ("rpc_bidirectional_stream", "srv") => {
344            resolve_srv_bidi_stream(&idl, &c.contract.id, resolver, imports, needs_string_wire)
345        }
346        ("topic_out", "msg") => {
347            let elem = resolve_io(idl.path, resolver, imports, needs_string_wire)?;
348            Ok((ResolvedType::GoogleEmpty, elem))
349        }
350        ("topic_in", "msg") => {
351            let elem = resolve_io(idl.path, resolver, imports, needs_string_wire)?;
352            Ok((elem, ResolvedType::GoogleEmpty))
353        }
354        ("rpc" | "rpc_server_stream" | "rpc_client_stream" | "rpc_bidirectional_stream", "msg") => {
355            bail!(
356                "contract {}: mode={mode:?} requires a `.srv` IDL but [contract].idl points at a `.msg` ({idl_path:?})",
357                c.contract.id
358            )
359        }
360        ("topic_out" | "topic_in", "srv") => {
361            bail!(
362                "contract {}: mode={mode:?} requires a `.msg` IDL but [contract].idl points at a `.srv` ({idl_path:?})",
363                c.contract.id
364            )
365        }
366        (other, _) => bail!(
367            "unknown [mode].type {other:?} in contract {}",
368            c.contract.id
369        ),
370    }
371}
372
373/// Parse the user-written `idl` path. The path includes the file extension
374/// (`.srv` / `.msg`) and is interpreted as the literal lib-relative file
375/// path — codegen will look it up at `<lib_root>/<idl>` for each lib root.
376///
377/// Returns `(pkg, kind, name)` where:
378///   - `kind` is derived from the file extension (`"srv"` / `"msg"`)
379///   - `name` is the file basename without extension (e.g. `SubmitTask`)
380///   - `pkg` is the directory immediately above `srv/` / `msg/` when
381///     the path follows the conventional `<...>/<pkg>/{srv,msg}/<Name>`
382///     layout — needed by the downstream MsgResolver lookup. For flat
383///     layouts (no `/srv/` or `/msg/` subdir), `pkg` is empty: the
384///     existing resolver doesn't index those, and the caller will get
385///     a clear "not indexed" error from the resolver itself.
386fn parse_idl_path(
387    s: &str,
388) -> Option<(
389    &str,         /* pkg */
390    &'static str, /* kind */
391    &str,         /* name */
392)> {
393    let (stem, kind): (&str, &'static str) = if let Some(rest) = s.strip_suffix(".srv") {
394        (rest, "srv")
395    } else if let Some(rest) = s.strip_suffix(".msg") {
396        (rest, "msg")
397    } else {
398        return None;
399    };
400    let parts: Vec<&str> = stem.split('/').filter(|p| !p.is_empty()).collect();
401    if parts.is_empty() {
402        return None;
403    }
404    let n = parts.len();
405    let name = parts[n - 1];
406    let pkg = if n >= 3 && (parts[n - 2] == "srv" || parts[n - 2] == "msg") {
407        parts[n - 3]
408    } else {
409        ""
410    };
411    Some((pkg, kind, name))
412}
413
414/// Verify the user-written idl path resolves to an actual file under
415/// at least one of the resolver's include_paths. The path is the literal
416/// file path — codegen joins it directly with each lib root.
417fn idl_path_exists(idl: &str, resolver: &MsgResolver) -> bool {
418    for root in &resolver.include_paths {
419        if root.join(idl).is_file() {
420            return true;
421        }
422    }
423    false
424}
425
426fn resolve_srv_server_stream(
427    idl: &IdlRef,
428    contract_id: &str,
429    resolver: &mut MsgResolver,
430    imports: &mut BTreeSet<String>,
431    needs_string_wire: &mut bool,
432) -> Result<(ResolvedType, ResolvedType)> {
433    let p = idl.path;
434    let Some((pkg, "srv", name)) = parse_idl_path(p) else {
435        bail!("[contract].idl must end with /srv/Name for rpc modes, got {p:?}");
436    };
437    resolver
438        .resolve_srv(pkg, name)
439        .with_context(|| format!("resolve srv {p}"))?;
440    let spec = resolver
441        .srv_spec(pkg, name)
442        .ok_or_else(|| anyhow::anyhow!("internal: srv {p} not cached"))?
443        .clone();
444
445    let res = &spec.response;
446    if res.fields.len() != 1 {
447        bail!(
448            "contract {contract_id}: [mode] rpc_server_stream requires the .srv response section to have exactly one field (stream element type), got {} in {p}",
449            res.fields.len()
450        );
451    }
452    let in_t = srv_request_to_contract_input(&spec, resolver, imports, needs_string_wire)?;
453    let out_t = srv_stream_field_to_resolved(
454        contract_id,
455        p,
456        "response",
457        &res.fields[0],
458        resolver,
459        imports,
460        needs_string_wire,
461    )?;
462    Ok((in_t, out_t))
463}
464
465fn resolve_srv_client_stream(
466    idl: &IdlRef,
467    contract_id: &str,
468    resolver: &mut MsgResolver,
469    imports: &mut BTreeSet<String>,
470    needs_string_wire: &mut bool,
471) -> Result<(ResolvedType, ResolvedType)> {
472    let p = idl.path;
473    let Some((pkg, "srv", name)) = parse_idl_path(p) else {
474        bail!("[contract].idl must end with /srv/Name for rpc modes, got {p:?}");
475    };
476    resolver
477        .resolve_srv(pkg, name)
478        .with_context(|| format!("resolve srv {p}"))?;
479    let spec = resolver
480        .srv_spec(pkg, name)
481        .ok_or_else(|| anyhow::anyhow!("internal: srv {p} not cached"))?
482        .clone();
483
484    let req = &spec.request;
485    if req.fields.len() != 1 {
486        bail!(
487            "contract {contract_id}: [mode] rpc_client_stream requires the .srv request section to have exactly one field (stream element type), got {} in {p}",
488            req.fields.len()
489        );
490    }
491    let in_t = srv_stream_field_to_resolved(
492        contract_id,
493        p,
494        "request",
495        &req.fields[0],
496        resolver,
497        imports,
498        needs_string_wire,
499    )?;
500    let out_t = srv_response_to_contract_output(&spec, resolver, imports, needs_string_wire)?;
501    Ok((in_t, out_t))
502}
503
504/// Bidirectional stream: the `.srv` Request and Response sections are the
505/// per-message stream element types (each must have exactly one field, same
506/// rule as server-stream / client-stream). Mirrors gRPC bidi shape:
507/// `rpc M(stream RequestType) returns (stream ResponseType)`.
508fn resolve_srv_bidi_stream(
509    idl: &IdlRef,
510    contract_id: &str,
511    resolver: &mut MsgResolver,
512    imports: &mut BTreeSet<String>,
513    needs_string_wire: &mut bool,
514) -> Result<(ResolvedType, ResolvedType)> {
515    let p = idl.path;
516    let Some((pkg, "srv", name)) = parse_idl_path(p) else {
517        bail!("[contract].idl must end with /srv/Name for rpc modes, got {p:?}");
518    };
519    resolver
520        .resolve_srv(pkg, name)
521        .with_context(|| format!("resolve srv {p}"))?;
522    let spec = resolver
523        .srv_spec(pkg, name)
524        .ok_or_else(|| anyhow::anyhow!("internal: srv {p} not cached"))?
525        .clone();
526
527    let req = &spec.request;
528    let res = &spec.response;
529    if req.fields.len() != 1 {
530        bail!(
531            "contract {contract_id}: [mode] rpc_bidirectional_stream requires the .srv request section to have exactly one field (client→server stream element type), got {} in {p}",
532            req.fields.len()
533        );
534    }
535    if res.fields.len() != 1 {
536        bail!(
537            "contract {contract_id}: [mode] rpc_bidirectional_stream requires the .srv response section to have exactly one field (server→client stream element type), got {} in {p}",
538            res.fields.len()
539        );
540    }
541    let in_t = srv_stream_field_to_resolved(
542        contract_id,
543        p,
544        "request",
545        &req.fields[0],
546        resolver,
547        imports,
548        needs_string_wire,
549    )?;
550    let out_t = srv_stream_field_to_resolved(
551        contract_id,
552        p,
553        "response",
554        &res.fields[0],
555        resolver,
556        imports,
557        needs_string_wire,
558    )?;
559    Ok((in_t, out_t))
560}
561
562fn srv_request_to_contract_input(
563    srv: &SrvSpec,
564    resolver: &mut MsgResolver,
565    imports: &mut BTreeSet<String>,
566    needs_string_wire: &mut bool,
567) -> Result<ResolvedType> {
568    let req = &srv.request;
569    if req.fields.len() == 1 {
570        return field_to_resolved_type(&req.fields[0], resolver, imports, needs_string_wire);
571    }
572    imports.insert(format!("{}.proto", srv.package));
573    Ok(ResolvedType::ProtoFqn(format!(
574        "{}.{}",
575        proto_package_name(&srv.package),
576        req.name
577    )))
578}
579
580/// Empty `.srv` response section → `google.protobuf.Empty`; else the generated `*_Response` message.
581fn srv_response_to_contract_output(
582    srv: &SrvSpec,
583    resolver: &mut MsgResolver,
584    imports: &mut BTreeSet<String>,
585    _needs_string_wire: &mut bool,
586) -> Result<ResolvedType> {
587    let res = &srv.response;
588    if res.fields.is_empty() {
589        return Ok(ResolvedType::GoogleEmpty);
590    }
591    for f in &res.fields {
592        if let MsgTypeRef::Named { package, name } = &f.type_ref {
593            resolver.resolve_named_type(package, name, None)?;
594        }
595    }
596    imports.insert(format!("{}.proto", srv.package));
597    Ok(ResolvedType::ProtoFqn(format!(
598        "{}.{}",
599        proto_package_name(&srv.package),
600        res.name
601    )))
602}
603
604fn resolve_srv_contract_pair(
605    path: &str,
606    resolver: &mut MsgResolver,
607    imports: &mut BTreeSet<String>,
608    _needs_string_wire: &mut bool,
609) -> Result<(ResolvedType, ResolvedType)> {
610    let p = path.trim();
611    if let Some((pkg, "srv", name)) = parse_idl_path(p) {
612        resolver
613            .resolve_srv(pkg, name)
614            .with_context(|| format!("resolve srv {p}"))?;
615        imports.insert(format!("{pkg}.proto"));
616        let req = format!("{name}_Request");
617        let res = format!("{name}_Response");
618        return Ok((
619            ResolvedType::ProtoFqn(format!("{}.{}", proto_package_name(pkg), req)),
620            ResolvedType::ProtoFqn(format!("{}.{}", proto_package_name(pkg), res)),
621        ));
622    }
623    bail!("[contract].idl must end with /srv/Name for rpc modes, got {p:?}");
624}
625
626fn field_to_resolved_type(
627    field: &MsgField,
628    resolver: &mut MsgResolver,
629    imports: &mut BTreeSet<String>,
630    needs_string_wire: &mut bool,
631) -> Result<ResolvedType> {
632    match &field.type_ref {
633        MsgTypeRef::Primitive(_) => bail!(
634            "contract I/O field `{}` must use a named ROS message type, not a primitive",
635            field.name
636        ),
637        MsgTypeRef::Named { package, name } => resolve_io(
638            &format!("{package}/msg/{name}"),
639            resolver,
640            imports,
641            needs_string_wire,
642        ),
643    }
644}
645
646/// Resolve a nested ROS-style type reference from inside a .srv/.msg
647/// file (3-segment `pkg/msg/Name` or `pkg/srv/Name`). Different from
648/// the user-facing top-level `idl` field, which uses the new
649/// extension-bearing path format and is resolved via `parse_idl_path`.
650fn resolve_io(
651    spec: &str,
652    resolver: &mut MsgResolver,
653    imports: &mut BTreeSet<String>,
654    _needs_string_wire: &mut bool,
655) -> Result<ResolvedType> {
656    let s = spec.trim();
657    // First try the new extension-bearing path format (used when this
658    // function is called from topic_out / topic_in / bidi resolvers
659    // with the user's `idl` field value).
660    if (s.ends_with(".srv") || s.ends_with(".msg"))
661        && let Some((pkg, kind, name)) = parse_idl_path(s)
662    {
663        return match kind {
664            "msg" => {
665                resolver
666                    .resolve_named_type(pkg, name, None)
667                    .with_context(|| {
668                        format!("resolve msg {pkg}/{name} referenced from contract")
669                    })?;
670                imports.insert(format!("{pkg}.proto"));
671                Ok(ResolvedType::ProtoFqn(format!(
672                    "{}.{}",
673                    proto_package_name(pkg),
674                    name
675                )))
676            }
677            "srv" => {
678                resolver.resolve_srv(pkg, name).with_context(|| {
679                    format!("resolve srv {pkg}/{name} referenced from contract")
680                })?;
681                imports.insert(format!("{pkg}.proto"));
682                let req = format!("{}_Request", name);
683                Ok(ResolvedType::ProtoFqn(format!(
684                    "{}.{}",
685                    proto_package_name(pkg),
686                    req
687                )))
688            }
689            _ => unreachable!(),
690        };
691    }
692    // Otherwise: nested ROS-style reference inside an IDL file
693    // (`pkg/msg/Name` / `pkg/Name` for same-pkg refs handled by parser).
694    let parts: Vec<&str> = s.split('/').collect();
695    match parts.as_slice() {
696        [pkg, "msg", name] => {
697            resolver
698                .resolve_named_type(pkg, name, None)
699                .with_context(|| format!("resolve msg {pkg}/{name} referenced from contract"))?;
700            imports.insert(format!("{pkg}.proto"));
701            Ok(ResolvedType::ProtoFqn(format!(
702                "{}.{}",
703                proto_package_name(pkg),
704                name
705            )))
706        }
707        [pkg, "srv", name] => {
708            resolver
709                .resolve_srv(pkg, name)
710                .with_context(|| format!("resolve srv {pkg}/{name} referenced from contract"))?;
711            imports.insert(format!("{pkg}.proto"));
712            let req = format!("{}_Request", name);
713            Ok(ResolvedType::ProtoFqn(format!(
714                "{}.{}",
715                proto_package_name(pkg),
716                req
717            )))
718        }
719        _ => bail!(
720            "unsupported IDL reference {s:?} (expected `<pkg>/msg/<Name>` or `<pkg>/srv/<Name>` for nested refs, or a lib-relative path ending in .srv/.msg for top-level idl)"
721        ),
722    }
723}
724
725#[allow(dead_code)]
726fn parse_ros_path(s: &str) -> Option<(&str, &str, &str)> {
727    let parts: Vec<&str> = s.split('/').collect();
728    if parts.len() != 3 {
729        return None;
730    }
731    Some((parts[0], parts[1], parts[2]))
732}
733
734/// Convert an arbitrary identifier to UpperCamelCase. Splits on `_`/`-`/
735/// digit-letter boundaries and capitalises each segment.
736/// `submit_task` → `SubmitTask`; `scan_2d` → `Scan2d`; `SubmitTask` → `SubmitTask`.
737fn upper_camel(s: &str) -> String {
738    let mut out = String::with_capacity(s.len());
739    let mut capitalize_next = true;
740    for ch in s.chars() {
741        if ch == '_' || ch == '-' {
742            capitalize_next = true;
743            continue;
744        }
745        if capitalize_next {
746            out.extend(ch.to_uppercase());
747            capitalize_next = false;
748        } else {
749            out.push(ch);
750        }
751    }
752    out
753}
754
755/// Uniform PascalCase per `/`-segment. No prefix stripping.
756/// `robonix/primitive/chassis/move` → `RobonixPrimitiveChassisMove`.
757/// `mycomp/a/b/c`                   → `MycompABC`.
758fn contract_id_to_service_name(id: &str) -> String {
759    id.split('/')
760        .filter(|x| !x.is_empty())
761        .map(|seg| {
762            seg.split('_')
763                .filter(|p| !p.is_empty())
764                .map(|p| {
765                    let mut c = p.chars();
766                    match c.next() {
767                        None => String::new(),
768                        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
769                    }
770                })
771                .collect::<String>()
772        })
773        .collect::<String>()
774}
775
776fn collect_tomls(dir: &Path) -> Result<Vec<PathBuf>> {
777    let mut v = Vec::new();
778    collect_tomls_inner(dir, &mut v)?;
779    v.sort();
780    Ok(v)
781}
782
783fn collect_tomls_inner(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
784    if !dir.is_dir() {
785        bail!("contracts directory does not exist: {}", dir.display());
786    }
787    for entry in fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
788        let entry = entry?;
789        let p = entry.path();
790        if p.is_dir() {
791            // Hard convention: `<capabilities>/lib/` holds only ROS
792            // msg/srv source for the IDL resolver. Skip it here so
793            // any stray .toml dropped under lib/ never gets picked up
794            // as a contract.
795            if p.file_name().and_then(|s| s.to_str()) == Some("lib") {
796                continue;
797            }
798            collect_tomls_inner(&p, out)?;
799        } else if p.extension().and_then(|x| x.to_str()) == Some("toml") {
800            out.push(p);
801        }
802    }
803    Ok(())
804}