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