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