Skip to main content

runx_parser/skill/
catalog.rs

1use runx_contracts::JsonObject;
2use serde::{Deserialize, Serialize};
3
4use crate::ValidationError;
5
6use super::FIELDS;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum CatalogKind {
11    Skill,
12    Graph,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum CatalogAudience {
18    Public,
19    Builder,
20    Operator,
21    System,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum CatalogVisibility {
27    Public,
28    Internal,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum CatalogRole {
34    Canonical,
35    Branded,
36    Context,
37    GraphStage,
38    RuntimePath,
39    HarnessFixture,
40}
41
42impl CatalogKind {
43    pub fn as_str(&self) -> &'static str {
44        match self {
45            CatalogKind::Skill => "skill",
46            CatalogKind::Graph => "graph",
47        }
48    }
49}
50
51impl CatalogAudience {
52    pub fn as_str(&self) -> &'static str {
53        match self {
54            CatalogAudience::Public => "public",
55            CatalogAudience::Builder => "builder",
56            CatalogAudience::Operator => "operator",
57            CatalogAudience::System => "system",
58        }
59    }
60}
61
62impl CatalogVisibility {
63    pub fn as_str(&self) -> &'static str {
64        match self {
65            CatalogVisibility::Public => "public",
66            CatalogVisibility::Internal => "internal",
67        }
68    }
69}
70
71impl CatalogRole {
72    pub fn as_str(&self) -> &'static str {
73        match self {
74            CatalogRole::Canonical => "canonical",
75            CatalogRole::Branded => "branded",
76            CatalogRole::Context => "context",
77            CatalogRole::GraphStage => "graph-stage",
78            CatalogRole::RuntimePath => "runtime-path",
79            CatalogRole::HarnessFixture => "harness-fixture",
80        }
81    }
82}
83
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct CatalogMetadata {
87    pub kind: CatalogKind,
88    pub audience: CatalogAudience,
89    pub visibility: CatalogVisibility,
90    pub role: CatalogRole,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub canonical_skill: Option<String>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub provider: Option<String>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub runtime_path: Option<String>,
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub part_of: Vec<String>,
99}
100
101pub(crate) fn validate_catalog_metadata(
102    value: Option<JsonObject>,
103    label: &str,
104) -> Result<Option<CatalogMetadata>, ValidationError> {
105    let Some(value) = value else {
106        return Ok(None);
107    };
108    let kind = parse_catalog_kind(&value, label)?;
109    let audience = parse_catalog_audience(&value, label)?;
110    let visibility = parse_catalog_visibility(&value, label)?;
111    let role = parse_catalog_role(&value, label)?;
112    validate_catalog_role(visibility, role, label)?;
113    let canonical_skill = FIELDS.optional_string(
114        value.get("canonical_skill"),
115        &format!("{label}.canonical_skill"),
116    )?;
117    let provider = FIELDS.optional_string(value.get("provider"), &format!("{label}.provider"))?;
118    let runtime_path =
119        FIELDS.optional_string(value.get("runtime_path"), &format!("{label}.runtime_path"))?;
120    let part_of = FIELDS
121        .optional_string_array(value.get("part_of"), &format!("{label}.part_of"))?
122        .unwrap_or_default();
123    validate_catalog_bindings(role, &canonical_skill, &provider, &part_of, label)?;
124    Ok(Some(CatalogMetadata {
125        kind,
126        audience,
127        visibility,
128        role,
129        canonical_skill,
130        provider,
131        runtime_path,
132        part_of,
133    }))
134}
135
136fn parse_catalog_kind(value: &JsonObject, label: &str) -> Result<CatalogKind, ValidationError> {
137    match FIELDS
138        .required_string(value.get("kind"), &format!("{label}.kind"))?
139        .as_str()
140    {
141        "skill" => Ok(CatalogKind::Skill),
142        "graph" => Ok(CatalogKind::Graph),
143        _ => Err(FIELDS.validation_error(format!("{label}.kind must be skill or graph."))),
144    }
145}
146
147fn parse_catalog_audience(
148    value: &JsonObject,
149    label: &str,
150) -> Result<CatalogAudience, ValidationError> {
151    match FIELDS
152        .required_string(value.get("audience"), &format!("{label}.audience"))?
153        .as_str()
154    {
155        "public" => Ok(CatalogAudience::Public),
156        "builder" => Ok(CatalogAudience::Builder),
157        "operator" => Ok(CatalogAudience::Operator),
158        "system" => Ok(CatalogAudience::System),
159        _ => Err(FIELDS.validation_error(format!(
160            "{label}.audience must be public, builder, operator, or system."
161        ))),
162    }
163}
164
165fn parse_catalog_visibility(
166    value: &JsonObject,
167    label: &str,
168) -> Result<CatalogVisibility, ValidationError> {
169    match FIELDS
170        .optional_string(value.get("visibility"), &format!("{label}.visibility"))?
171        .as_deref()
172    {
173        Some("public") | None => Ok(CatalogVisibility::Public),
174        Some("internal") => Ok(CatalogVisibility::Internal),
175        Some(_) => {
176            Err(FIELDS.validation_error(format!("{label}.visibility must be public or internal.")))
177        }
178    }
179}
180
181fn parse_catalog_role(value: &JsonObject, label: &str) -> Result<CatalogRole, ValidationError> {
182    match FIELDS
183        .required_string(value.get("role"), &format!("{label}.role"))?
184        .as_str()
185    {
186        "canonical" => Ok(CatalogRole::Canonical),
187        "branded" => Ok(CatalogRole::Branded),
188        "context" => Ok(CatalogRole::Context),
189        "graph-stage" => Ok(CatalogRole::GraphStage),
190        "runtime-path" => Ok(CatalogRole::RuntimePath),
191        "harness-fixture" => Ok(CatalogRole::HarnessFixture),
192        _ => Err(FIELDS.validation_error(format!(
193            "{label}.role must be canonical, branded, context, graph-stage, runtime-path, or harness-fixture."
194        ))),
195    }
196}
197
198fn validate_catalog_role(
199    visibility: CatalogVisibility,
200    role: CatalogRole,
201    label: &str,
202) -> Result<(), ValidationError> {
203    if visibility == CatalogVisibility::Public
204        && matches!(
205            role,
206            CatalogRole::GraphStage | CatalogRole::RuntimePath | CatalogRole::HarnessFixture
207        )
208    {
209        return Err(FIELDS.validation_error(format!(
210            "{label}.role cannot be {} when visibility is public.",
211            role.as_str()
212        )));
213    }
214    Ok(())
215}
216
217fn validate_catalog_bindings(
218    role: CatalogRole,
219    canonical_skill: &Option<String>,
220    provider: &Option<String>,
221    part_of: &[String],
222    label: &str,
223) -> Result<(), ValidationError> {
224    if role == CatalogRole::Branded {
225        if canonical_skill.is_none() {
226            return Err(FIELDS.validation_error(format!(
227                "{label}.canonical_skill is required when catalog.role is branded."
228            )));
229        }
230        if provider.is_none() {
231            return Err(FIELDS.validation_error(format!(
232                "{label}.provider is required when catalog.role is branded."
233            )));
234        }
235    }
236    if matches!(
237        role,
238        CatalogRole::GraphStage | CatalogRole::RuntimePath | CatalogRole::HarnessFixture
239    ) && part_of.is_empty()
240    {
241        return Err(FIELDS.validation_error(format!(
242            "{label}.part_of is required when catalog.role is {}.",
243            role.as_str()
244        )));
245    }
246    Ok(())
247}