1use 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 #[serde(default)]
49 idl: Option<String>,
50 #[serde(default)]
57 description: Option<String>,
58 #[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#[derive(Debug, Default)]
94pub struct ContractRegistry {
95 by_id: HashMap<String, pb::ContractDescriptor>,
96}
97
98impl ContractRegistry {
99 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 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 !(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 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
215fn 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
254fn 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 msg_fields: Vec::new(),
334 srv_request_fields: Vec::new(),
335 srv_response_fields: Vec::new(),
336 })
337}
338
339fn attach_idl_fields(by_id: &mut HashMap<String, pb::ContractDescriptor>, roots: &[&Path]) {
348 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
415fn 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
437fn 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
485pub 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}