1use 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 #[serde(default)]
49 idl: Option<String>,
50 #[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#[derive(Debug, Default)]
90pub struct ContractRegistry {
91 by_id: HashMap<String, pb::ContractDescriptor>,
92}
93
94impl ContractRegistry {
95 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 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 !(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 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
211fn 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
250fn 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 msg_fields: Vec::new(),
330 srv_request_fields: Vec::new(),
331 srv_response_fields: Vec::new(),
332 })
333}
334
335fn attach_idl_fields(by_id: &mut HashMap<String, pb::ContractDescriptor>, roots: &[&Path]) {
344 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
411fn 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
433fn 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
481pub 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}