Skip to main content

robonix_atlas/
contract_registry.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Contract registry — loads `<robonix_source>/capabilities/**/*.toml` at
5// atlas startup and serves their parsed metadata to clients via
6// QueryContract / ListContracts. Clients no longer walk the filesystem
7// or parse contract TOMLs themselves; atlas is the single source of
8// truth for "what contracts exist and what's their wire shape".
9//
10// Scope is deliberately small: only the fields current TOMLs actually
11// carry (`[contract]` id/version/kind, `[mode]` type, `[io.msg].msg`,
12// `[io.srv].srv`). Richer metadata (summary / examples / safety /
13// capability-card-style fields) waits until the TOML schema grows.
14
15use anyhow::Context;
16use log::{info, warn};
17use serde::Deserialize;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use walkdir::WalkDir;
21
22use robonix_codegen::codegen::msg_parser::{
23    MsgResolver, MsgSpec, MsgTypeRef, ResolveContext, parse_ridl_type_ref,
24};
25
26use crate::pb;
27
28#[derive(Debug, Deserialize)]
29struct RawContract {
30    contract: ContractSection,
31    #[serde(default)]
32    mode: Option<ModeSection>,
33    #[serde(default)]
34    io: Option<IoSection>,
35}
36
37#[derive(Debug, Deserialize)]
38struct ContractSection {
39    id: String,
40    #[serde(default)]
41    version: Option<String>,
42    #[serde(default)]
43    kind: Option<String>,
44    /// Lib-relative IDL path (with extension), e.g. `sensor_msgs/msg/Image.msg`
45    /// or `pilot/srv/SubmitTask.srv`. Source of truth in the post-migration
46    /// schema; `[io.msg]` / `[io.srv]` are legacy and only honoured when
47    /// `idl` is absent so old TOMLs keep loading until they're rewritten.
48    #[serde(default)]
49    idl: Option<String>,
50    /// Generic one-line natural-language description for this contract
51    /// (what it does in the abstract). Consumers MERGE this with each
52    /// CapabilityProvider's instance-specific
53    /// `DeclareCapabilityRequest.description` at consume time -- the
54    /// two are complementary (generic + instance-specific), not
55    /// alternatives.
56    #[serde(default)]
57    description: Option<String>,
58}
59
60#[derive(Debug, Deserialize)]
61struct ModeSection {
62    #[serde(default, rename = "type")]
63    ty: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
67struct IoSection {
68    #[serde(default)]
69    msg: Option<MsgSubsection>,
70    #[serde(default)]
71    srv: Option<SrvSubsection>,
72}
73
74#[derive(Debug, Deserialize)]
75struct MsgSubsection {
76    #[serde(default)]
77    msg: Option<String>,
78}
79
80#[derive(Debug, Deserialize)]
81struct SrvSubsection {
82    #[serde(default)]
83    srv: Option<String>,
84}
85
86/// In-memory contract metadata. Built once at startup; never mutated
87/// while atlas runs. Wrapped in `Arc<ContractRegistry>` so handlers can
88/// read it without locks.
89#[derive(Debug, Default)]
90pub struct ContractRegistry {
91    by_id: HashMap<String, pb::ContractDescriptor>,
92}
93
94impl ContractRegistry {
95    /// Walk `<root>/**/*.toml` for one root and load it into the registry.
96    /// Convenience wrapper around `load_from_capability_roots(&[root])`.
97    pub fn load_from_capabilities_dir(root: &Path) -> anyhow::Result<Self> {
98        Self::load_from_capability_roots(std::slice::from_ref(&root))
99    }
100
101    /// Walk every `<root>/**/*.toml` across all `roots` (in order) and
102    /// merge the parsed `ContractDescriptor`s into one registry. Later
103    /// roots override earlier ones on duplicate `[contract].id`, which
104    /// matches the package-merge semantics: per-package
105    /// `<pkg>/capabilities/` can re-declare a contract from the global
106    /// `<robonix_source>/capabilities/`. Symlinks are followed so msg/srv
107    /// directories that live in `interfaces/lib/...` are reachable when
108    /// they are linked under `capabilities/`.
109    ///
110    /// After all TOMLs are loaded, this also indexes every `.msg`/`.srv`
111    /// under `<root>/lib/**/*` and tries to attach top-level field
112    /// schemas to each contract whose io_msg_type / io_srv_type points
113    /// at a known IDL. Failures here are non-fatal (the contract is
114    /// still served, just without field-level introspection).
115    ///
116    /// Malformed TOMLs and `*.toml` files without a `[contract].id` are
117    /// logged and skipped — one bad file must not take atlas down.
118    pub fn load_from_capability_roots(roots: &[&Path]) -> anyhow::Result<Self> {
119        let mut by_id: HashMap<String, pb::ContractDescriptor> = HashMap::new();
120        let mut total_roots_walked = 0usize;
121        for root in roots {
122            if !root.exists() {
123                warn!(
124                    "[atlas] contract registry: capabilities root missing: {} \
125                     (skipping)",
126                    root.display()
127                );
128                continue;
129            }
130            total_roots_walked += 1;
131            let mut loaded_from_root = 0usize;
132            for entry in WalkDir::new(root)
133                .follow_links(true)
134                .into_iter()
135                .filter_entry(|e| {
136                    // Hard convention: `<capabilities>/lib/` holds only
137                    // ROS msg/srv source for IDL codegen. Skip it so any
138                    // stray .toml under lib/ never lands in the contract
139                    // registry.
140                    !(e.file_type().is_dir() && e.file_name() == "lib" && e.depth() > 0)
141                })
142                .filter_map(|e| e.ok())
143            {
144                if !entry.file_type().is_file() {
145                    continue;
146                }
147                let path = entry.path();
148                if path.extension().and_then(|s| s.to_str()) != Some("toml") {
149                    continue;
150                }
151                match load_one(path) {
152                    Ok(desc) => {
153                        let id = desc.id.clone();
154                        if let Some(prev) = by_id.insert(id.clone(), desc) {
155                            warn!(
156                                "[atlas] contract registry: duplicate id '{id}' \
157                                 (was {}, now {}); keeping latest",
158                                prev.source_toml_path,
159                                path.display()
160                            );
161                        }
162                        loaded_from_root += 1;
163                    }
164                    Err(e) => warn!("[atlas] contract registry: skip {} ({e:#})", path.display()),
165                }
166            }
167            info!(
168                "[atlas] contract registry: {} contracts from {}",
169                loaded_from_root,
170                root.display()
171            );
172        }
173        info!(
174            "[atlas] contract registry: total {} unique contracts across {} root(s)",
175            by_id.len(),
176            total_roots_walked
177        );
178
179        attach_idl_fields(&mut by_id, roots);
180
181        Ok(Self { by_id })
182    }
183
184    pub fn get(&self, contract_id: &str) -> Option<&pb::ContractDescriptor> {
185        self.by_id.get(contract_id)
186    }
187
188    /// Return all contracts whose id starts with `prefix`. Empty prefix
189    /// returns every contract.
190    pub fn list_with_prefix(&self, prefix: &str) -> Vec<pb::ContractDescriptor> {
191        let prefix = prefix.trim();
192        let mut out: Vec<pb::ContractDescriptor> = self
193            .by_id
194            .values()
195            .filter(|c| prefix.is_empty() || c.id.starts_with(prefix))
196            .cloned()
197            .collect();
198        out.sort_by(|a, b| a.id.cmp(&b.id));
199        out
200    }
201
202    pub fn len(&self) -> usize {
203        self.by_id.len()
204    }
205
206    pub fn is_empty(&self) -> bool {
207        self.by_id.is_empty()
208    }
209}
210
211/// Derive (io_msg_type, io_srv_type) from a parsed contract toml. New
212/// schema: `[contract].idl = "<pkg>/(msg|srv)/<Name>.<ext>"`. Old
213/// schema (pre-migration): `[io.msg].msg = "..."` / `[io.srv].srv = "..."`.
214/// `idl` wins when both are present; old form is the fallback so TOMLs
215/// that haven't been rewritten still load.
216fn io_types_from_parsed(parsed: &RawContract) -> (String, String) {
217    if let Some(idl_raw) = parsed.contract.idl.as_deref() {
218        let idl = idl_raw.trim();
219        if !idl.is_empty()
220            && let Some((pkg, kind, name)) = parse_idl_path(idl)
221        {
222            let composed = format!("{pkg}/{kind}/{name}");
223            return match kind {
224                "msg" => (composed, String::new()),
225                "srv" => (String::new(), composed),
226                _ => (String::new(), String::new()),
227            };
228        }
229    }
230    match &parsed.io {
231        Some(io) => {
232            let msg = io
233                .msg
234                .as_ref()
235                .and_then(|m| m.msg.as_deref())
236                .map(|s| s.trim().to_string())
237                .unwrap_or_default();
238            let srv = io
239                .srv
240                .as_ref()
241                .and_then(|s| s.srv.as_deref())
242                .map(|s| s.trim().to_string())
243                .unwrap_or_default();
244            (msg, srv)
245        }
246        None => (String::new(), String::new()),
247    }
248}
249
250/// Parse a lib-relative IDL path like `sensor_msgs/msg/Image.msg`
251/// into (pkg, kind, name). Mirrors the codegen-side parser
252/// (`robonix-codegen::contract_gen::parse_idl_path`) but lives here so
253/// atlas doesn't need a build-dep on the full codegen crate just for one
254/// six-line helper.
255fn parse_idl_path(s: &str) -> Option<(&str, &'static str, &str)> {
256    let (stem, kind): (&str, &'static str) = if let Some(rest) = s.strip_suffix(".srv") {
257        (rest, "srv")
258    } else if let Some(rest) = s.strip_suffix(".msg") {
259        (rest, "msg")
260    } else {
261        return None;
262    };
263    let parts: Vec<&str> = stem.split('/').filter(|p| !p.is_empty()).collect();
264    if parts.is_empty() {
265        return None;
266    }
267    let n = parts.len();
268    let name = parts[n - 1];
269    let pkg = if n >= 3 && (parts[n - 2] == "srv" || parts[n - 2] == "msg") {
270        parts[n - 3]
271    } else {
272        ""
273    };
274    Some((pkg, kind, name))
275}
276
277fn load_one(path: &Path) -> anyhow::Result<pb::ContractDescriptor> {
278    let raw = std::fs::read_to_string(path)
279        .with_context(|| format!("read contract toml: {}", path.display()))?;
280    let parsed: RawContract =
281        toml::from_str(&raw).with_context(|| format!("parse contract toml: {}", path.display()))?;
282    let id = parsed.contract.id.trim().to_string();
283    if id.is_empty() {
284        anyhow::bail!("[contract].id is empty");
285    }
286    let (io_msg_type, io_srv_type) = io_types_from_parsed(&parsed);
287    let version = parsed
288        .contract
289        .version
290        .map(|s| s.trim().to_string())
291        .unwrap_or_default();
292    let kind_str = parsed
293        .contract
294        .kind
295        .map(|s| s.trim().to_string())
296        .unwrap_or_default();
297    let kind = match kind_str.as_str() {
298        "primitive" => pb::Kind::Primitive,
299        "service" => pb::Kind::Service,
300        "skill" => pb::Kind::Skill,
301        "" => pb::Kind::Unspecified,
302        other => {
303            return Err(anyhow::anyhow!(
304                "contract '{id}': unknown kind '{other}' (want primitive|service|skill)"
305            ));
306        }
307    };
308    let mode = parsed
309        .mode
310        .and_then(|m| m.ty)
311        .map(|s| s.trim().to_string())
312        .unwrap_or_default();
313    let description = parsed
314        .contract
315        .description
316        .map(|s| s.trim().to_string())
317        .unwrap_or_default();
318    Ok(pb::ContractDescriptor {
319        id,
320        version,
321        kind: kind as i32,
322        mode,
323        io_msg_type,
324        io_srv_type,
325        source_toml_path: path.to_string_lossy().into_owned(),
326        description,
327        // Filled later by attach_idl_fields() after every TOML has
328        // been loaded. Empty here is the right default.
329        msg_fields: Vec::new(),
330        srv_request_fields: Vec::new(),
331        srv_response_fields: Vec::new(),
332    })
333}
334
335/// After all TOMLs are loaded, walk every `<root>/lib/**/*.{msg,srv}`
336/// and attach top-level field schemas to each contract whose
337/// `io_msg_type` / `io_srv_type` resolves to a known IDL.
338///
339/// Failures are logged-and-skipped: a contract with no resolvable IDL
340/// (e.g. type "X" not present in any `lib/`) just keeps its empty
341/// `msg_fields` / `srv_*_fields`. Atlas startup must not fail because
342/// of a single missing `.msg`.
343fn attach_idl_fields(by_id: &mut HashMap<String, pb::ContractDescriptor>, roots: &[&Path]) {
344    // The msg_parser indexes from `include_paths`. For each capability
345    // root, the IDL files live under `<root>/lib`; everything else
346    // under the root is either contract TOMLs or non-IDL data.
347    let lib_paths: Vec<PathBuf> = roots
348        .iter()
349        .map(|r| r.join("lib"))
350        .filter(|p| p.exists())
351        .collect();
352    if lib_paths.is_empty() {
353        info!("[atlas] contract registry: no <root>/lib/ found — skipping IDL field attachment");
354        return;
355    }
356    let mut resolver = match MsgResolver::new(&lib_paths) {
357        Ok(r) => r,
358        Err(e) => {
359            warn!(
360                "[atlas] contract registry: MsgResolver init failed ({e:#}); \
361                 contracts will have no field-level schema"
362            );
363            return;
364        }
365    };
366    let mut msg_filled = 0usize;
367    let mut srv_filled = 0usize;
368    let mut msg_missing = 0usize;
369    let mut srv_missing = 0usize;
370    for desc in by_id.values_mut() {
371        if !desc.io_msg_type.is_empty() {
372            match resolve_msg_fields(&mut resolver, &desc.io_msg_type) {
373                Ok(fields) => {
374                    desc.msg_fields = fields;
375                    msg_filled += 1;
376                }
377                Err(e) => {
378                    warn!(
379                        "[atlas] contract registry: IDL resolve failed for \
380                         contract '{}' io_msg_type='{}': {e:#}",
381                        desc.id, desc.io_msg_type
382                    );
383                    msg_missing += 1;
384                }
385            }
386        }
387        if !desc.io_srv_type.is_empty() {
388            match resolve_srv_fields(&mut resolver, &desc.io_srv_type) {
389                Ok((req, resp)) => {
390                    desc.srv_request_fields = req;
391                    desc.srv_response_fields = resp;
392                    srv_filled += 1;
393                }
394                Err(e) => {
395                    warn!(
396                        "[atlas] contract registry: IDL resolve failed for \
397                         contract '{}' io_srv_type='{}': {e:#}",
398                        desc.id, desc.io_srv_type
399                    );
400                    srv_missing += 1;
401                }
402            }
403        }
404    }
405    info!(
406        "[atlas] contract registry: IDL fields — msg {msg_filled} ok / {msg_missing} missing, \
407         srv {srv_filled} ok / {srv_missing} missing"
408    );
409}
410
411/// Look up the .msg file for "pkg/msg/Name" (ROS-style fully-qualified
412/// type ref) and convert its top-level fields into the wire schema.
413fn resolve_msg_fields(
414    resolver: &mut MsgResolver,
415    type_ref: &str,
416) -> anyhow::Result<Vec<pb::FieldSpec>> {
417    let (pkg, name) = parse_ridl_type_ref(type_ref)
418        .with_context(|| format!("not a fully-qualified IDL type ref: {type_ref}"))?;
419    let ctx = ResolveContext {
420        namespace: None,
421        interface_kind: Some("msg"),
422        interface_name: Some(name.clone()),
423        field_name: None,
424    };
425    resolver.resolve_named_type(&pkg, &name, Some((type_ref, &ctx)))?;
426    let spec = resolver
427        .cache
428        .get(&(pkg.clone(), name.clone()))
429        .with_context(|| format!("MsgResolver cache miss for {pkg}/{name}"))?;
430    Ok(spec_to_field_specs(spec))
431}
432
433/// Same for "pkg/srv/Name" → (request_fields, response_fields).
434/// `parse_ridl_type_ref` accepts both `pkg/msg/Name` and
435/// `pkg/srv/Name`; we don't need a separate parser anymore.
436fn resolve_srv_fields(
437    resolver: &mut MsgResolver,
438    type_ref: &str,
439) -> anyhow::Result<(Vec<pb::FieldSpec>, Vec<pb::FieldSpec>)> {
440    let (pkg, name) = parse_ridl_type_ref(type_ref)
441        .with_context(|| format!("not a fully-qualified srv type ref: {type_ref}"))?;
442    let key = (pkg.clone(), name.clone());
443    if !resolver.srv_cache.contains_key(&key) {
444        let path = resolver
445            .srv_index
446            .get(&key)
447            .cloned()
448            .with_context(|| format!("MsgResolver srv_index has no entry for {pkg}/{name}"))?;
449        let parsed = robonix_codegen::codegen::msg_parser::parse_srv_file(&pkg, &name, &path)?;
450        resolver.srv_cache.insert(key.clone(), parsed);
451    }
452    let spec = resolver
453        .srv_cache
454        .get(&key)
455        .with_context(|| format!("srv_cache miss for {pkg}/{name}"))?;
456    Ok((
457        spec_to_field_specs(&spec.request),
458        spec_to_field_specs(&spec.response),
459    ))
460}
461
462fn spec_to_field_specs(spec: &MsgSpec) -> Vec<pb::FieldSpec> {
463    spec.fields
464        .iter()
465        .map(|f| {
466            let (type_name, is_primitive) = match &f.type_ref {
467                MsgTypeRef::Primitive(s) => (s.clone(), true),
468                MsgTypeRef::Named { package, name } => (format!("{package}/{name}"), false),
469            };
470            pb::FieldSpec {
471                name: f.name.clone(),
472                type_name,
473                is_primitive,
474                is_array: f.is_array,
475                array_size: f.array_size.unwrap_or(0) as u32,
476            }
477        })
478        .collect()
479}
480
481/// Resolve the list of capability roots atlas should load. Priority:
482///   1. explicit CLI/env paths (any non-empty entries from
483///      `--capabilities a,b,c` or `ROBONIX_ATLAS_CAPABILITIES=a,b,c`)
484///   2. `$ROBONIX_SOURCE_PATH/capabilities` as a single fallback root
485///
486/// Returns an empty vec if nothing is configured; atlas then runs with
487/// an empty registry (handlers return found=false on every query).
488///
489/// Per-package `<pkg>/capabilities/` dirs aren't included here — those
490/// can be added at deploy time by the rbnx CLI walking installed
491/// package paths and passing the merged list via `--capabilities`.
492pub fn resolve_capabilities_roots(explicit: &[String]) -> Vec<PathBuf> {
493    let cleaned: Vec<PathBuf> = explicit
494        .iter()
495        .map(|s| s.trim())
496        .filter(|s| !s.is_empty())
497        .map(PathBuf::from)
498        .collect();
499    if !cleaned.is_empty() {
500        return cleaned;
501    }
502    if let Ok(root) = std::env::var("ROBONIX_SOURCE_PATH") {
503        let trimmed = root.trim();
504        if !trimmed.is_empty() {
505            return vec![PathBuf::from(trimmed).join("capabilities")];
506        }
507    }
508    Vec::new()
509}