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}