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 #[serde(default = "default_true")]
66 llm_callable: bool,
67}
68
69fn default_true() -> bool {
70 true
71}
72
73#[derive(Debug, Deserialize)]
74struct ModeSection {
75 #[serde(default, rename = "type")]
76 ty: Option<String>,
77}
78
79#[derive(Debug, Deserialize)]
80struct IoSection {
81 #[serde(default)]
82 msg: Option<MsgSubsection>,
83 #[serde(default)]
84 srv: Option<SrvSubsection>,
85}
86
87#[derive(Debug, Deserialize)]
88struct MsgSubsection {
89 #[serde(default)]
90 msg: Option<String>,
91}
92
93#[derive(Debug, Deserialize)]
94struct SrvSubsection {
95 #[serde(default)]
96 srv: Option<String>,
97}
98
99#[derive(Debug, Default)]
103pub struct ContractRegistry {
104 by_id: HashMap<String, pb::ContractDescriptor>,
105}
106
107impl ContractRegistry {
108 pub fn load_from_capabilities_dir(root: &Path) -> anyhow::Result<Self> {
111 Self::load_from_capability_roots(std::slice::from_ref(&root))
112 }
113
114 pub fn load_from_capability_roots(roots: &[&Path]) -> anyhow::Result<Self> {
132 let mut by_id: HashMap<String, pb::ContractDescriptor> = HashMap::new();
133 let mut total_roots_walked = 0usize;
134 for root in roots {
135 if !root.exists() {
136 warn!(
137 "[atlas] contract registry: capabilities root missing: {} \
138 (skipping)",
139 root.display()
140 );
141 continue;
142 }
143 total_roots_walked += 1;
144 let mut loaded_from_root = 0usize;
145 for entry in WalkDir::new(root)
146 .follow_links(true)
147 .into_iter()
148 .filter_entry(|e| {
149 !(e.file_type().is_dir() && e.file_name() == "lib" && e.depth() > 0)
154 })
155 .filter_map(|e| e.ok())
156 {
157 if !entry.file_type().is_file() {
158 continue;
159 }
160 let path = entry.path();
161 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
162 continue;
163 }
164 match load_one(path) {
165 Ok(desc) => {
166 let id = desc.id.clone();
167 if let Some(prev) = by_id.insert(id.clone(), desc) {
168 warn!(
169 "[atlas] contract registry: duplicate id '{id}' \
170 (was {}, now {}); keeping latest",
171 prev.source_toml_path,
172 path.display()
173 );
174 }
175 loaded_from_root += 1;
176 }
177 Err(e) => warn!("[atlas] contract registry: skip {} ({e:#})", path.display()),
178 }
179 }
180 info!(
181 "[atlas] contract registry: {} contracts from {}",
182 loaded_from_root,
183 root.display()
184 );
185 }
186 info!(
187 "[atlas] contract registry: total {} unique contracts across {} root(s)",
188 by_id.len(),
189 total_roots_walked
190 );
191
192 attach_idl_fields(&mut by_id, roots);
193
194 Ok(Self { by_id })
195 }
196
197 pub fn get(&self, contract_id: &str) -> Option<&pb::ContractDescriptor> {
198 self.by_id.get(contract_id)
199 }
200
201 pub fn list_with_prefix(&self, prefix: &str) -> Vec<pb::ContractDescriptor> {
204 let prefix = prefix.trim();
205 let mut out: Vec<pb::ContractDescriptor> = self
206 .by_id
207 .values()
208 .filter(|c| prefix.is_empty() || c.id.starts_with(prefix))
209 .cloned()
210 .collect();
211 out.sort_by(|a, b| a.id.cmp(&b.id));
212 out
213 }
214
215 pub fn len(&self) -> usize {
216 self.by_id.len()
217 }
218
219 pub fn is_empty(&self) -> bool {
220 self.by_id.is_empty()
221 }
222}
223
224fn io_types_from_parsed(parsed: &RawContract) -> (String, String) {
230 if let Some(idl_raw) = parsed.contract.idl.as_deref() {
231 let idl = idl_raw.trim();
232 if !idl.is_empty()
233 && let Some((pkg, kind, name)) = parse_idl_path(idl)
234 {
235 let composed = format!("{pkg}/{kind}/{name}");
236 return match kind {
237 "msg" => (composed, String::new()),
238 "srv" => (String::new(), composed),
239 _ => (String::new(), String::new()),
240 };
241 }
242 }
243 match &parsed.io {
244 Some(io) => {
245 let msg = io
246 .msg
247 .as_ref()
248 .and_then(|m| m.msg.as_deref())
249 .map(|s| s.trim().to_string())
250 .unwrap_or_default();
251 let srv = io
252 .srv
253 .as_ref()
254 .and_then(|s| s.srv.as_deref())
255 .map(|s| s.trim().to_string())
256 .unwrap_or_default();
257 (msg, srv)
258 }
259 None => (String::new(), String::new()),
260 }
261}
262
263fn parse_idl_path(s: &str) -> Option<(&str, &'static str, &str)> {
269 let (stem, kind): (&str, &'static str) = if let Some(rest) = s.strip_suffix(".srv") {
270 (rest, "srv")
271 } else {
272 let rest = s.strip_suffix(".msg")?;
273 (rest, "msg")
274 };
275 let parts: Vec<&str> = stem.split('/').filter(|p| !p.is_empty()).collect();
276 if parts.is_empty() {
277 return None;
278 }
279 let n = parts.len();
280 let name = parts[n - 1];
281 let pkg = if n >= 3 && (parts[n - 2] == "srv" || parts[n - 2] == "msg") {
282 parts[n - 3]
283 } else {
284 ""
285 };
286 Some((pkg, kind, name))
287}
288
289fn load_one(path: &Path) -> anyhow::Result<pb::ContractDescriptor> {
290 let raw = std::fs::read_to_string(path)
291 .with_context(|| format!("read contract toml: {}", path.display()))?;
292 let parsed: RawContract =
293 toml::from_str(&raw).with_context(|| format!("parse contract toml: {}", path.display()))?;
294 let id = parsed.contract.id.trim().to_string();
295 if id.is_empty() {
296 anyhow::bail!("[contract].id is empty");
297 }
298 let (io_msg_type, io_srv_type) = io_types_from_parsed(&parsed);
299 let version = parsed
300 .contract
301 .version
302 .map(|s| s.trim().to_string())
303 .unwrap_or_default();
304 let kind_str = parsed
305 .contract
306 .kind
307 .map(|s| s.trim().to_string())
308 .unwrap_or_default();
309 let kind = match kind_str.as_str() {
310 "primitive" => pb::Kind::Primitive,
311 "service" => pb::Kind::Service,
312 "skill" => pb::Kind::Skill,
313 "" => pb::Kind::Unspecified,
314 other => {
315 return Err(anyhow::anyhow!(
316 "contract '{id}': unknown kind '{other}' (want primitive|service|skill)"
317 ));
318 }
319 };
320 let mode = parsed
321 .mode
322 .and_then(|m| m.ty)
323 .map(|s| s.trim().to_string())
324 .unwrap_or_default();
325 let description = parsed
326 .contract
327 .description
328 .map(|s| s.trim().to_string())
329 .unwrap_or_default();
330 Ok(pb::ContractDescriptor {
331 id,
332 version,
333 kind: kind as i32,
334 mode,
335 io_msg_type,
336 io_srv_type,
337 source_toml_path: path.to_string_lossy().into_owned(),
338 description,
339 cross_namespace: parsed.contract.cross_namespace,
340 llm_callable: Some(parsed.contract.llm_callable),
341 msg_fields: Vec::new(),
344 srv_request_fields: Vec::new(),
345 srv_response_fields: Vec::new(),
346 })
347}
348
349fn attach_idl_fields(by_id: &mut HashMap<String, pb::ContractDescriptor>, roots: &[&Path]) {
358 let lib_paths: Vec<PathBuf> = roots
362 .iter()
363 .map(|r| r.join("lib"))
364 .filter(|p| p.exists())
365 .collect();
366 if lib_paths.is_empty() {
367 info!("[atlas] contract registry: no <root>/lib/ found — skipping IDL field attachment");
368 return;
369 }
370 let mut resolver = match MsgResolver::new(&lib_paths) {
371 Ok(r) => r,
372 Err(e) => {
373 warn!(
374 "[atlas] contract registry: MsgResolver init failed ({e:#}); \
375 contracts will have no field-level schema"
376 );
377 return;
378 }
379 };
380 let mut msg_filled = 0usize;
381 let mut srv_filled = 0usize;
382 let mut msg_missing = 0usize;
383 let mut srv_missing = 0usize;
384 for desc in by_id.values_mut() {
385 if !desc.io_msg_type.is_empty() {
386 match resolve_msg_fields(&mut resolver, &desc.io_msg_type) {
387 Ok(fields) => {
388 desc.msg_fields = fields;
389 msg_filled += 1;
390 }
391 Err(e) => {
392 warn!(
393 "[atlas] contract registry: IDL resolve failed for \
394 contract '{}' io_msg_type='{}': {e:#}",
395 desc.id, desc.io_msg_type
396 );
397 msg_missing += 1;
398 }
399 }
400 }
401 if !desc.io_srv_type.is_empty() {
402 match resolve_srv_fields(&mut resolver, &desc.io_srv_type) {
403 Ok((req, resp)) => {
404 desc.srv_request_fields = req;
405 desc.srv_response_fields = resp;
406 srv_filled += 1;
407 }
408 Err(e) => {
409 warn!(
410 "[atlas] contract registry: IDL resolve failed for \
411 contract '{}' io_srv_type='{}': {e:#}",
412 desc.id, desc.io_srv_type
413 );
414 srv_missing += 1;
415 }
416 }
417 }
418 }
419 info!(
420 "[atlas] contract registry: IDL fields — msg {msg_filled} ok / {msg_missing} missing, \
421 srv {srv_filled} ok / {srv_missing} missing"
422 );
423}
424
425fn resolve_msg_fields(
428 resolver: &mut MsgResolver,
429 type_ref: &str,
430) -> anyhow::Result<Vec<pb::FieldSpec>> {
431 let (pkg, name) = parse_ridl_type_ref(type_ref)
432 .with_context(|| format!("not a fully-qualified IDL type ref: {type_ref}"))?;
433 let ctx = ResolveContext {
434 namespace: None,
435 interface_kind: Some("msg"),
436 interface_name: Some(name.clone()),
437 field_name: None,
438 };
439 resolver.resolve_named_type(&pkg, &name, Some((type_ref, &ctx)))?;
440 let spec = resolver
441 .cache
442 .get(&(pkg.clone(), name.clone()))
443 .with_context(|| format!("MsgResolver cache miss for {pkg}/{name}"))?;
444 Ok(spec_to_field_specs(spec))
445}
446
447fn resolve_srv_fields(
451 resolver: &mut MsgResolver,
452 type_ref: &str,
453) -> anyhow::Result<(Vec<pb::FieldSpec>, Vec<pb::FieldSpec>)> {
454 let (pkg, name) = parse_ridl_type_ref(type_ref)
455 .with_context(|| format!("not a fully-qualified srv type ref: {type_ref}"))?;
456 let key = (pkg.clone(), name.clone());
457 if !resolver.srv_cache.contains_key(&key) {
458 let path = resolver
459 .srv_index
460 .get(&key)
461 .cloned()
462 .with_context(|| format!("MsgResolver srv_index has no entry for {pkg}/{name}"))?;
463 let parsed = robonix_codegen::codegen::msg_parser::parse_srv_file(&pkg, &name, &path)?;
464 resolver.srv_cache.insert(key.clone(), parsed);
465 }
466 let spec = resolver
467 .srv_cache
468 .get(&key)
469 .with_context(|| format!("srv_cache miss for {pkg}/{name}"))?;
470 Ok((
471 spec_to_field_specs(&spec.request),
472 spec_to_field_specs(&spec.response),
473 ))
474}
475
476fn spec_to_field_specs(spec: &MsgSpec) -> Vec<pb::FieldSpec> {
477 spec.fields
478 .iter()
479 .map(|f| {
480 let (type_name, is_primitive) = match &f.type_ref {
481 MsgTypeRef::Primitive(s) => (s.clone(), true),
482 MsgTypeRef::Named { package, name } => (format!("{package}/{name}"), false),
483 };
484 pb::FieldSpec {
485 name: f.name.clone(),
486 type_name,
487 is_primitive,
488 is_array: f.is_array,
489 array_size: f.array_size.unwrap_or(0) as u32,
490 }
491 })
492 .collect()
493}
494
495pub fn resolve_capabilities_roots(explicit: &[String]) -> Vec<PathBuf> {
507 let cleaned: Vec<PathBuf> = explicit
508 .iter()
509 .map(|s| s.trim())
510 .filter(|s| !s.is_empty())
511 .map(PathBuf::from)
512 .collect();
513 if !cleaned.is_empty() {
514 return cleaned;
515 }
516 if let Ok(root) = std::env::var("ROBONIX_SOURCE_PATH") {
517 let trimmed = root.trim();
518 if !trimmed.is_empty() {
519 return vec![PathBuf::from(trimmed).join("capabilities")];
520 }
521 }
522 Vec::new()
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn cross_namespace_is_explicit_and_defaults_to_false() {
531 let shared: RawContract = toml::from_str(
532 r#"
533 [contract]
534 id = "robonix/primitive/health/stream"
535 cross_namespace = true
536
537 [mode]
538 type = "topic_out"
539 "#,
540 )
541 .expect("shared contract TOML");
542 assert!(shared.contract.cross_namespace);
543
544 let regular: RawContract = toml::from_str(
545 r#"
546 [contract]
547 id = "robonix/primitive/camera/rgb"
548
549 [mode]
550 type = "topic_out"
551 "#,
552 )
553 .expect("regular contract TOML");
554 assert!(!regular.contract.cross_namespace);
555 }
556
557 #[test]
558 fn llm_callable_is_explicit_and_defaults_to_true() {
559 let hidden: RawContract = toml::from_str(
560 r#"
561 [contract]
562 id = "robonix/service/verifier/verify"
563 llm_callable = false
564
565 [mode]
566 type = "rpc"
567 "#,
568 )
569 .expect("hidden contract TOML");
570 assert!(!hidden.contract.llm_callable);
571
572 let regular: RawContract = toml::from_str(
573 r#"
574 [contract]
575 id = "robonix/service/example/run"
576
577 [mode]
578 type = "rpc"
579 "#,
580 )
581 .expect("regular contract TOML");
582 assert!(regular.contract.llm_callable);
583 }
584}