1use serde::{Deserialize, Serialize};
8
9use crate::query::{
10 BindingSource, BoolExpr, EdgeDirection, FieldRef, QueryDecl, ScalarExpr, TypedLiteral,
11};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Registry {
16 pub schema_hash: String,
18 pub kinds: Vec<Kind>,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
23 pub queries: Vec<QueryDecl>,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub roles: Vec<RoleDecl>,
29}
30
31impl Registry {
32 pub fn role(&self, name: &str) -> Option<&RoleDecl> {
33 self.roles.iter().find(|r| r.name == name)
34 }
35
36 pub fn binds(&self, field: &Field) -> Option<Affordance> {
38 field
39 .role
40 .as_deref()
41 .and_then(|n| self.role(n))
42 .map(|r| r.binds)
43 }
44
45 pub fn affordance_field<'a>(&self, kind: &'a Kind, a: Affordance) -> Option<&'a Field> {
47 kind.fields.iter().find(|f| self.binds(f) == Some(a))
48 }
49
50 pub fn coherence_errors(&self) -> Vec<String> {
58 fn base(ty: &FieldType) -> &FieldType {
62 match ty {
63 FieldType::Option(i) => base(i),
64 other => other,
65 }
66 }
67 let mut errors = Vec::new();
68 for (i, r) in self.roles.iter().enumerate() {
69 if self.roles[..i].iter().any(|x| x.name == r.name) {
70 errors.push(format!("role '{}' is declared twice", r.name));
71 }
72 if r.binds == Affordance::Badge && r.variants.is_empty() {
73 errors.push(format!("badge role '{}' declares no variants", r.name));
74 }
75 if r.binds != Affordance::Badge && !r.variants.is_empty() {
76 errors.push(format!(
77 "role '{}' binds {:?} but declares variants — only Badge roles carry them",
78 r.name, r.binds
79 ));
80 }
81 }
82 fn nested_roles(prefix: &str, ty: &FieldType, errors: &mut Vec<String>) {
86 match ty {
87 FieldType::Option(i) | FieldType::List(i) => nested_roles(prefix, i, errors),
88 FieldType::Struct(fs) => {
89 for f in fs {
90 let at = format!("{prefix}.{}", f.name);
91 if let Some(role) = &f.role {
92 errors.push(format!(
93 "{at} adopts role '{role}' but roles bind only to a \
94 kind's top-level fields — nested adoptions are inert"
95 ));
96 }
97 nested_roles(&at, &f.ty, errors);
98 }
99 }
100 FieldType::Enum(vs) => {
101 for v in vs {
102 for f in &v.fields {
103 let at = format!("{prefix}.{}.{}", v.name, f.name);
104 if let Some(role) = &f.role {
105 errors.push(format!(
106 "{at} adopts role '{role}' but roles bind only to a \
107 kind's top-level fields — nested adoptions are inert"
108 ));
109 }
110 nested_roles(&at, &f.ty, errors);
111 }
112 }
113 }
114 _ => {}
115 }
116 }
117 for kind in &self.kinds {
118 for f in &kind.fields {
119 nested_roles(&format!("{}.{}", kind.name, f.name), &f.ty, &mut errors);
120 }
121 }
122 for kind in &self.kinds {
123 if !kind.storage.starts_with("facts/") || kind.storage.contains("..") {
127 errors.push(format!(
128 "kind '{}' storage '{}' must live under facts/ (no .., no absolute paths)",
129 kind.name, kind.storage
130 ));
131 }
132 for f in &kind.fields {
133 let Some(role_name) = f.role.as_deref() else {
134 continue;
135 };
136 let at = format!("{}.{}", kind.name, f.name);
137 let Some(decl) = self.role(role_name) else {
138 errors.push(format!("{at} adopts undeclared role '{role_name}'"));
139 continue;
140 };
141 match (decl.binds, base(&f.ty)) {
142 (Affordance::Title, FieldType::Str) => {}
143 (Affordance::Timeline, FieldType::Date) => {}
144 (Affordance::Badge, FieldType::Enum(vs)) => {
145 let field_vs: std::collections::BTreeSet<&str> =
146 vs.iter().map(|v| v.name.as_str()).collect();
147 let role_vs: std::collections::BTreeSet<&str> =
148 decl.variants.iter().map(|v| v.name.as_str()).collect();
149 if field_vs != role_vs {
150 errors.push(format!(
151 "{at} adopts badge role '{role_name}' but its variants \
152 [{}] differ from the role's [{}] — roles are strict; \
153 use the shared enum or drop the role",
154 vs.iter()
155 .map(|v| v.name.as_str())
156 .collect::<Vec<_>>()
157 .join(", "),
158 decl.variants
159 .iter()
160 .map(|v| v.name.as_str())
161 .collect::<Vec<_>>()
162 .join(", "),
163 ));
164 }
165 }
166 (binds, other) => errors.push(format!(
167 "{at} adopts role '{role_name}' (binds {binds:?}) but is {other:?}"
168 )),
169 }
170 }
171 }
172 errors.extend(self.query_errors());
173 errors
174 }
175
176 pub fn query_errors(&self) -> Vec<String> {
180 use std::collections::{BTreeMap, BTreeSet};
181
182 fn base(ty: &FieldType) -> &FieldType {
183 match ty {
184 FieldType::Option(inner) | FieldType::List(inner) => base(inner),
185 other => other,
186 }
187 }
188
189 fn literal_fits(ty: &FieldType, lit: &TypedLiteral) -> bool {
190 match (base(ty), lit) {
191 (FieldType::Str | FieldType::Markdown, TypedLiteral::String(_))
192 | (FieldType::Int, TypedLiteral::Int(_))
193 | (FieldType::Decimal, TypedLiteral::Decimal(_))
194 | (FieldType::Date, TypedLiteral::Date(_))
195 | (FieldType::Bool, TypedLiteral::Bool(_)) => true,
196 (FieldType::Enum(variants), TypedLiteral::Enum { variant, .. }) => {
197 variants.iter().any(|v| v.name == *variant)
198 }
199 _ => false,
200 }
201 }
202
203 fn field_type<'a>(
204 bindings: &BTreeMap<u32, &'a Kind>,
205 field: &FieldRef,
206 ) -> Option<&'a FieldType> {
207 bindings
208 .get(&field.binding)?
209 .fields
210 .iter()
211 .find(|f| f.name == field.field)
212 .map(|f| &f.ty)
213 }
214
215 fn check_scalar(
216 bindings: &BTreeMap<u32, &Kind>,
217 query: &str,
218 expr: &ScalarExpr,
219 errors: &mut Vec<String>,
220 ) {
221 match expr {
222 ScalarExpr::Field(f) => {
223 if field_type(bindings, f).is_none() {
224 errors.push(format!(
225 "query '{query}' references missing field binding {}.{}",
226 f.binding, f.field
227 ));
228 }
229 }
230 ScalarExpr::Literal(_) => {}
231 ScalarExpr::Aggregate { expr, .. } => {
232 if let Some(expr) = expr {
233 check_scalar(bindings, query, expr, errors);
234 }
235 }
236 }
237 }
238
239 fn check_bool(
240 bindings: &BTreeMap<u32, &Kind>,
241 query: &str,
242 expr: &BoolExpr,
243 errors: &mut Vec<String>,
244 ) {
245 match expr {
246 BoolExpr::And(xs) | BoolExpr::Or(xs) => xs
247 .iter()
248 .for_each(|x| check_bool(bindings, query, x, errors)),
249 BoolExpr::Not(x) => check_bool(bindings, query, x, errors),
250 BoolExpr::Compare { left, right, .. } => {
251 check_scalar(bindings, query, left, errors);
252 check_scalar(bindings, query, right, errors);
253 let pair = match (left, right) {
254 (ScalarExpr::Field(f), ScalarExpr::Literal(l))
255 | (ScalarExpr::Literal(l), ScalarExpr::Field(f)) => Some((f, l)),
256 _ => None,
257 };
258 if let Some((field, literal)) = pair
259 && let Some(ty) = field_type(bindings, field)
260 && !literal_fits(ty, literal)
261 {
262 errors.push(format!(
263 "query '{query}' compares {}.{} ({ty:?}) with incompatible {literal:?}",
264 field.binding, field.field
265 ));
266 }
267 }
268 }
269 }
270
271 let mut errors = Vec::new();
272 let mut names = BTreeSet::new();
273 for query in &self.queries {
274 if !names.insert(query.name.as_str()) {
275 errors.push(format!("query '{}' is declared twice", query.name));
276 }
277 let mut bindings = BTreeMap::new();
278 for binding in &query.bindings {
279 let Some(kind) = self.kinds.iter().find(|k| k.name == binding.kind) else {
280 errors.push(format!(
281 "query '{}' binding {} names missing kind '{}'",
282 query.name, binding.id, binding.kind
283 ));
284 continue;
285 };
286 if bindings.insert(binding.id, kind).is_some() {
287 errors.push(format!(
288 "query '{}' declares binding {} twice",
289 query.name, binding.id
290 ));
291 }
292 }
293 for binding in &query.bindings {
294 let BindingSource::Follow {
295 from,
296 field,
297 direction,
298 ..
299 } = &binding.source
300 else {
301 continue;
302 };
303 let (field_kind, target_kind) = match direction {
304 EdgeDirection::Out => (bindings.get(from), bindings.get(&binding.id)),
305 EdgeDirection::In => (bindings.get(&binding.id), bindings.get(from)),
306 };
307 let (Some(field_kind), Some(target_kind)) = (field_kind, target_kind) else {
308 errors.push(format!(
309 "query '{}' binding {} follows missing binding {}",
310 query.name, binding.id, from
311 ));
312 continue;
313 };
314 let Some(decl) = field_kind.fields.iter().find(|f| f.name == *field) else {
315 errors.push(format!(
316 "query '{}' follows missing edge '{}.{}'",
317 query.name, field_kind.name, field
318 ));
319 continue;
320 };
321 let points_to = match base(&decl.ty) {
322 FieldType::Link { allowed } => allowed
323 .as_ref()
324 .is_none_or(|ks| ks.iter().any(|k| k == &target_kind.name)),
325 FieldType::Str => decl.refers_to.as_deref() == Some(target_kind.name.as_str()),
326 _ => false,
327 };
328 if !points_to {
329 errors.push(format!(
330 "query '{}' edge '{}.{}' does not point to '{}'",
331 query.name, field_kind.name, field, target_kind.name
332 ));
333 }
334 }
335 if query.bindings.is_empty() {
336 errors.push(format!("query '{}' has no root binding", query.name));
337 }
338 if query.columns.is_empty() {
339 errors.push(format!("query '{}' has no output columns", query.name));
340 }
341 if let Some(filter) = &query.filter {
342 check_bool(&bindings, &query.name, filter, &mut errors);
343 }
344 for expr in &query.group_by {
345 check_scalar(&bindings, &query.name, expr, &mut errors);
346 }
347 let mut columns = BTreeSet::new();
348 for column in &query.columns {
349 if let Some(f) = &column.format
350 && (f.thousands || f.decimals.is_some())
351 {
352 let numeric = match &column.expr {
355 ScalarExpr::Aggregate { .. } => true,
356 ScalarExpr::Field(fr) => field_type(&bindings, fr)
357 .map(|ty| matches!(base(ty), FieldType::Int | FieldType::Decimal))
358 .unwrap_or(false),
359 ScalarExpr::Literal(l) => {
360 matches!(l, TypedLiteral::Int(_) | TypedLiteral::Decimal(_))
361 }
362 };
363 if !numeric {
364 errors.push(format!(
365 "query '{}' column '{}' uses numeric formatting \
366 (thousands/decimals) on a non-numeric expression",
367 query.name, column.name
368 ));
369 }
370 }
371 if !columns.insert(column.name.as_str()) {
372 errors.push(format!(
373 "query '{}' has duplicate output column '{}'",
374 query.name, column.name
375 ));
376 }
377 check_scalar(&bindings, &query.name, &column.expr, &mut errors);
378 if !query.group_by.is_empty()
382 && !matches!(column.expr, ScalarExpr::Aggregate { .. })
383 && !query.group_by.contains(&column.expr)
384 {
385 errors.push(format!(
386 "query '{}' output column '{}' is neither aggregated \
387 nor in group_by",
388 query.name, column.name
389 ));
390 }
391 }
392 if query.group_by.is_empty() {
393 let aggs = query
394 .columns
395 .iter()
396 .filter(|c| matches!(c.expr, ScalarExpr::Aggregate { .. }))
397 .count();
398 if aggs > 0 && aggs < query.columns.len() {
399 errors.push(format!(
400 "query '{}' mixes aggregate and plain columns without \
401 group_by",
402 query.name
403 ));
404 }
405 }
406 for expr in &query.group_by {
407 if matches!(expr, ScalarExpr::Aggregate { .. }) {
408 errors.push(format!(
409 "query '{}' groups by an aggregate — group_by takes \
410 field expressions",
411 query.name
412 ));
413 }
414 }
415 for order in &query.order_by {
416 check_scalar(&bindings, &query.name, &order.expr, &mut errors);
417 }
418 }
419 errors
420 }
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct RoleDecl {
429 pub name: String,
430 pub doc: String,
432 pub binds: Affordance,
434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
436 pub variants: Vec<Variant>,
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444pub enum Affordance {
445 Title,
447 #[serde(alias = "Date")]
451 Timeline,
452 Badge,
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
459pub enum Tone {
460 Positive,
461 Neutral,
462 Attention,
463 Negative,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct Kind {
469 pub name: String,
471 pub doc: String,
473 pub storage: String,
476 pub fields: Vec<Field>,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct Field {
481 pub name: String,
482 pub doc: String,
484 pub ty: FieldType,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub role: Option<String>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub refers_to: Option<String>,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
500pub enum FieldType {
501 Str,
503 Int,
505 Decimal,
508 Hyperlink,
512 Markdown,
514 Date,
515 Bool,
516 Enum(Vec<Variant>),
520 Link {
522 allowed: Option<Vec<String>>,
523 },
524 List(Box<FieldType>),
525 Option(Box<FieldType>),
526 Struct(Vec<Field>),
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
533pub struct Variant {
534 pub name: String,
535 #[serde(default, skip_serializing_if = "String::is_empty")]
536 pub doc: String,
537 #[serde(default, skip_serializing_if = "Vec::is_empty")]
539 pub fields: Vec<Field>,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub tone: Option<Tone>,
543}