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