1pub mod frontmatter;
7pub mod yaml;
8
9use std::collections::BTreeMap;
10
11use serde::Deserialize;
12use serde_json::Value;
13
14use crate::error::{Error, Result};
15
16pub const KINDS_SCHEMA: &str = "majordomus-kinds/v1";
18
19pub const REPO_KINDS_FILE: &str = "kinds.yaml";
21
22pub const REPO_SCHEMAS_DIR: &str = "schemas";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Format {
30 Markdown,
32 Yaml,
34 Text,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
40#[serde(rename_all = "lowercase")]
41pub enum FrontMatterRule {
42 Required,
44 Optional,
46 #[default]
47 None,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct SchemaVersion {
56 pub field: String,
58 pub supported: Vec<serde_json::Value>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct KindSpec {
66 pub format: Format,
68 #[serde(default)]
69 pub front_matter: FrontMatterRule,
71 #[serde(default)]
74 pub without_front_matter: Option<String>,
75 #[serde(default)]
78 pub schema: Option<String>,
79 #[serde(default)]
80 pub identity: Vec<String>,
82 #[serde(default)]
83 pub title: Option<String>,
85 #[serde(default)]
86 pub description: Option<String>,
88 #[serde(default)]
89 pub schema_version: Option<SchemaVersion>,
91 #[serde(default)]
95 pub members: Option<String>,
96}
97
98#[derive(Debug, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct KindsFile {
101 schema: String,
102 #[serde(default)]
105 declared: Vec<String>,
106 kinds: BTreeMap<String, KindSpec>,
107}
108
109#[derive(Debug)]
111pub struct KindSchema {
112 kinds: BTreeMap<String, KindSpec>,
113 declared: Vec<String>,
114 schemas: SchemaSet,
115 sources: Vec<String>,
116}
117
118struct Source {
120 path: String,
121 text: String,
122}
123
124fn derived_schemas(dir: &std::path::Path, source: &str) -> Result<Vec<(String, Value)>> {
128 crate::proto::read_dir(dir, source)?
129 .into_values()
130 .map(|file| {
131 let schema = crate::proto::project::to_json_schema(&file)?;
132 Ok((file.schema_id, schema))
133 })
134 .collect()
135}
136
137fn rel_or_abs(path: &std::path::Path, root: &std::path::Path) -> String {
138 crate::generate::relative_to(path, root)
139}
140
141pub struct Schema {
143 pub name: String,
145 pub source: String,
147 pub json: Value,
150 validator: jsonschema::Validator,
152 header: Option<jsonschema::Validator>,
157}
158
159impl std::fmt::Debug for Schema {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.debug_struct("Schema")
162 .field("name", &self.name)
163 .field("source", &self.source)
164 .finish()
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Violation {
171 pub path: String,
173 pub message: String,
175 pub unknown_keys: Vec<String>,
177}
178
179impl Schema {
180 fn compile(name: &str, source: &str, json: Value) -> Result<Self> {
181 let compile = |value: &Value, what: &str| {
182 jsonschema::validator_for(value).map_err(|e| Error::KindSchema {
183 reason: format!("{source}: {name}: {what} is not a valid JSON Schema: {e}"),
184 })
185 };
186 let validator = compile(&json, "the document schema")?;
187 let header = match (
191 json.get("properties").and_then(|p| p.get("header")),
192 json.get("$defs"),
193 ) {
194 (Some(_), Some(defs)) => Some(compile(
195 &serde_json::json!({
196 "$schema": "https://json-schema.org/draft/2020-12/schema",
197 "$ref": "#/$defs/Header",
198 "$defs": defs,
199 }),
200 "its header",
201 )?),
202 _ => None,
203 };
204 Ok(Schema {
205 name: name.to_string(),
206 source: source.to_string(),
207 json,
208 validator,
209 header,
210 })
211 }
212
213 pub fn validate(&self, instance: &Value) -> Vec<Violation> {
217 self.violations(self.header.as_ref().unwrap_or(&self.validator), instance)
218 }
219
220 pub fn validate_document(&self, instance: &Value) -> Vec<Violation> {
223 self.violations(&self.validator, instance)
224 }
225
226 fn violations(&self, validator: &jsonschema::Validator, instance: &Value) -> Vec<Violation> {
227 validator
228 .iter_errors(instance)
229 .map(|e| {
230 let path = e
231 .instance_path()
232 .as_str()
233 .trim_start_matches('/')
234 .replace('/', ".");
235 let unknown_keys = match e.kind() {
236 jsonschema::error::ValidationErrorKind::AdditionalProperties { unexpected } => {
237 unexpected
238 .iter()
239 .map(|k| {
240 if path.is_empty() {
241 k.clone()
242 } else {
243 format!("{path}.{k}")
244 }
245 })
246 .collect()
247 }
248 _ => Vec::new(),
249 };
250 Violation {
251 path,
252 message: e.to_string(),
253 unknown_keys,
254 }
255 })
256 .collect()
257 }
258}
259
260#[derive(Debug, Default)]
262pub struct SchemaSet {
263 schemas: BTreeMap<String, Schema>,
264}
265
266impl SchemaSet {
267 pub fn extend(&mut self, schemas: Vec<(String, Value)>, from: &str) -> Result<()> {
270 for (name, json) in schemas {
271 if json.get(crate::proto::project::DERIVED_EXTENSION).is_some()
274 && self.schemas.contains_key(&name)
275 {
276 continue;
277 }
278 if let Some(first) = self.schemas.get(&name) {
279 return Err(Error::KindSchema {
280 reason: format!("schema '{name}' exists in both {} and {from}; a repository adds schemas, it does not redefine them", first.source),
281 });
282 }
283 self.schemas
284 .insert(name.clone(), Schema::compile(&name, from, json)?);
285 }
286 Ok(())
287 }
288
289 pub fn get(&self, name: &str) -> Option<&Schema> {
291 self.schemas.get(name)
292 }
293
294 pub fn is_empty(&self) -> bool {
296 self.schemas.is_empty()
297 }
298}
299
300impl KindSchema {
301 pub fn load(share: &crate::share::Share, repo: &crate::repository::Repository) -> Result<Self> {
304 let dist_path = share.kinds_path();
305 let dist_text =
306 std::fs::read_to_string(&dist_path).map_err(|e| Error::io(&dist_path, e))?;
307 let mut schemas = SchemaSet::default();
308 let dist_schemas = share.schemas_dir();
309 let dist_source = rel_or_abs(&dist_schemas, repo.root());
310 schemas.extend(derived_schemas(&dist_schemas, &dist_source)?, &dist_source)?;
316 schemas.extend(crate::share::read_schema_dir(&dist_schemas)?, &dist_source)?;
317 let mut sources = vec![Source {
318 path: rel_or_abs(&dist_path, repo.root()),
319 text: dist_text,
320 }];
321 if let Some(knowledge) = repo.section_path("knowledge") {
322 let dir = repo.root().join(&knowledge);
323 let repo_kinds = dir.join(REPO_KINDS_FILE);
324 if repo_kinds.is_file() {
325 let text =
326 std::fs::read_to_string(&repo_kinds).map_err(|e| Error::io(&repo_kinds, e))?;
327 sources.push(Source {
328 path: format!("{knowledge}/{REPO_KINDS_FILE}"),
329 text,
330 });
331 }
332 let repo_schemas = dir.join(REPO_SCHEMAS_DIR);
333 let repo_source = format!("{knowledge}/{REPO_SCHEMAS_DIR}");
334 schemas.extend(derived_schemas(&repo_schemas, &repo_source)?, &repo_source)?;
335 schemas.extend(crate::share::read_schema_dir(&repo_schemas)?, &repo_source)?;
336 }
337 Self::parse_all(&sources, schemas)
338 }
339
340 pub fn parse(text: &str, schemas: SchemaSet) -> Result<Self> {
342 Self::parse_all(
343 &[Source {
344 path: "kinds.yaml".into(),
345 text: text.to_string(),
346 }],
347 schemas,
348 )
349 }
350
351 fn parse_all(sources: &[Source], schemas: SchemaSet) -> Result<Self> {
352 let mut kinds: BTreeMap<String, KindSpec> = BTreeMap::new();
353 let mut owner: BTreeMap<String, String> = BTreeMap::new();
354 let mut declared: Vec<String> = Vec::new();
355 let mut loaded = Vec::new();
356 for source in sources {
357 let file: KindsFile =
358 yaml::parse_into(&source.text).map_err(|reason| Error::KindSchema {
359 reason: format!("{}: {reason}", source.path),
360 })?;
361 if file.schema != KINDS_SCHEMA {
362 return Err(Error::KindSchema {
363 reason: format!(
364 "{}: schema '{}' is not {KINDS_SCHEMA}",
365 source.path, file.schema
366 ),
367 });
368 }
369 for (name, spec) in file.kinds {
370 if let Some(first) = owner.get(&name) {
371 return Err(Error::KindSchema {
372 reason: format!("kind '{name}' is declared by both {first} and {}; a repository adds kinds, it does not redefine them", source.path),
373 });
374 }
375 owner.insert(name.clone(), source.path.clone());
376 kinds.insert(name, spec);
377 }
378 for d in file.declared {
379 if !declared.contains(&d) {
380 declared.push(d);
381 }
382 }
383 loaded.push(source.path.clone());
384 }
385 for d in &declared {
386 match kinds.get(d) {
387 Some(k)
388 if k.format == Format::Markdown
389 && k.front_matter == FrontMatterRule::Required => {}
390 _ => {
391 return Err(Error::KindSchema {
392 reason: format!(
393 "declared kind '{d}' is not a markdown kind that requires front matter"
394 ),
395 })
396 }
397 }
398 }
399 for (name, spec) in &kinds {
400 if spec.format != Format::Markdown && spec.front_matter != FrontMatterRule::None {
401 return Err(Error::KindSchema {
402 reason: format!("kind '{name}': only a markdown kind has a front matter rule"),
403 });
404 }
405 if spec.members.is_some() && spec.format != Format::Yaml {
406 return Err(Error::KindSchema {
407 reason: format!("kind '{name}': only a yaml kind has members"),
408 });
409 }
410 if spec.format == Format::Text
411 && (spec.schema.is_some() || !spec.identity.is_empty() || spec.members.is_some())
412 {
413 return Err(Error::KindSchema {
414 reason: format!("kind '{name}': a text kind has no metadata, so no allow-list, identity fields or members"),
415 });
416 }
417 if let Some(fallback) = &spec.without_front_matter {
418 match kinds.get(fallback) {
419 Some(f) if f.format == Format::Markdown && f.front_matter != FrontMatterRule::Required => {}
420 _ => {
421 return Err(Error::KindSchema {
422 reason: format!("kind '{name}': without_front_matter names '{fallback}', which is not a markdown kind that accepts a file without front matter"),
423 })
424 }
425 }
426 }
427 if let Some(schema) = &spec.schema {
428 if !schemas.schemas.contains_key(schema) {
429 return Err(Error::KindSchema {
430 reason: format!("kind '{name}' names schema '{schema}', and neither the distribution nor the repository holds it; a schema's identity fixes its path, so this is schemas/{} or the .proto beside it", crate::proto::project::schema_path(schema).unwrap_or_else(|_| format!("<{schema} is not <vendor>.<name>/v<n>>"))),
431 });
432 }
433 }
434 }
435 Ok(KindSchema {
436 kinds,
437 declared,
438 schemas,
439 sources: loaded,
440 })
441 }
442
443 pub fn schemas(&self) -> impl Iterator<Item = (&String, &Schema)> {
445 self.schemas.schemas.iter()
446 }
447
448 pub fn sources(&self) -> &[String] {
451 &self.sources
452 }
453
454 pub fn kind(&self, name: &str) -> Option<&KindSpec> {
456 self.kinds.get(name)
457 }
458
459 pub fn is_declared_kind(&self, name: &str) -> bool {
461 self.declared.iter().any(|d| d == name)
462 }
463
464 pub fn kinds(&self) -> impl Iterator<Item = (&String, &KindSpec)> {
466 self.kinds.iter()
467 }
468
469 pub fn schema_for(&self, spec: &KindSpec) -> Option<&Schema> {
471 spec.schema.as_deref().and_then(|n| self.schemas.get(n))
472 }
473
474 pub fn schema(&self, name: &str) -> Option<&Schema> {
476 self.schemas.get(name)
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn dist() -> (String, SchemaSet) {
486 let share = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share");
487 let text = std::fs::read_to_string(share.join("kinds.yaml")).unwrap();
488 let mut schemas = SchemaSet::default();
489 schemas
490 .extend(
491 derived_schemas(&share.join("schemas"), "share/schemas").unwrap(),
492 "share/schemas",
493 )
494 .unwrap();
495 schemas
496 .extend(
497 crate::share::read_schema_dir(&share.join("schemas")).unwrap(),
498 "share/schemas",
499 )
500 .unwrap();
501 (text, schemas)
502 }
503
504 #[test]
505 fn the_distribution_schema_parses_and_names_known_schemas() {
506 let (text, schemas) = dist();
507 let schema = KindSchema::parse(&text, schemas).expect("distribution schema");
508 let rule = schema.kind("rule").expect("rule kind");
509 assert_eq!(rule.format, Format::Markdown);
510 assert_eq!(rule.identity, vec!["id", "version"]);
511 assert_eq!(
512 schema.schema_for(rule).map(|s| s.name.as_str()),
513 Some("majordomus.rule/v1")
514 );
515 assert!(schema.kind("document").is_some());
516 assert!(schema.is_declared_kind("context") && !schema.is_declared_kind("rule"));
517 assert_eq!(
518 schema.kind("claim").unwrap().members.as_deref(),
519 Some("claims")
520 );
521 assert_eq!(schema.kind("test").unwrap().format, Format::Text);
522 assert_eq!(
523 schema.kind("rule").unwrap().without_front_matter.as_deref(),
524 Some("document")
525 );
526 assert!(
527 schema.schema("majordomus.manifest/v1").is_some(),
528 "schemas that no kind names are still loaded"
529 );
530 }
531
532 #[test]
533 fn a_schema_validates_and_names_unknown_keys_and_wrong_types() {
534 let (_, schemas) = dist();
535 let rule = schemas.get("majordomus.rule/v1").unwrap();
536 let ok: Value = serde_json::json!({ "id": "p.x", "version": 1, "kind": "rule", "title": "T", "description": "D", "statement": "S", "status": "active", "class": "advisory", "depends_on": [], "tags": ["a"] });
537 assert!(rule.validate(&ok).is_empty());
538 let mut bad = ok.clone();
539 bad["owner"] = serde_json::json!("me");
540 bad["x-majordomus"] = serde_json::json!({ "colour": "red" });
541 let v = rule.validate(&bad);
542 let unknown: Vec<String> = v.iter().flat_map(|v| v.unknown_keys.clone()).collect();
543 assert!(
544 unknown.contains(&"owner".to_string())
545 && unknown.contains(&"x-majordomus.colour".to_string()),
546 "{v:?}"
547 );
548 let mut typed = ok.clone();
549 typed["version"] = serde_json::json!("one");
550 typed["class"] = serde_json::json!("fatal");
551 let v = typed_violations(rule, &typed);
552 assert!(
553 v.iter().any(|x| x.path == "version") && v.iter().any(|x| x.path == "class"),
554 "{v:?}"
555 );
556 let mut redefine = SchemaSet::default();
557 redefine
558 .extend(
559 vec![(
560 "majordomus.rule/v1".into(),
561 serde_json::json!({ "type": "object" }),
562 )],
563 "a",
564 )
565 .unwrap();
566 let err = redefine
567 .extend(
568 vec![(
569 "majordomus.rule/v1".into(),
570 serde_json::json!({ "type": "object" }),
571 )],
572 "b",
573 )
574 .unwrap_err();
575 assert!(
576 err.to_string()
577 .contains("schema 'majordomus.rule/v1' exists in both a and b"),
578 "{err}"
579 );
580 }
581
582 fn typed_violations(schema: &Schema, v: &Value) -> Vec<Violation> {
583 schema
584 .validate(v)
585 .into_iter()
586 .filter(|x| x.unknown_keys.is_empty())
587 .collect()
588 }
589
590 #[test]
591 fn schema_refusals_name_the_reason() {
592 let (text, _) = dist();
593 let bad = |t: &str| {
594 KindSchema::parse(t, dist().1)
595 .err()
596 .map(|e| e.to_string())
597 .unwrap_or_default()
598 };
599 assert!(
600 bad(&text.replace("majordomus-kinds/v1", "majordomus-kinds/v9"))
601 .contains("is not majordomus-kinds/v1")
602 );
603 assert!(bad(&text.replace(
604 " identity: [id, version]",
605 " identity: [id, version]\n colour: red"
606 ))
607 .contains("colour"));
608 assert!(bad(&text.replace(
609 "without_front_matter: document",
610 "without_front_matter: prompt"
611 ))
612 .contains("without_front_matter"));
613 assert!(
614 bad(&text.replace("declared: [context]", "declared: [profile]"))
615 .contains("declared kind 'profile'")
616 );
617 assert!(bad(&text.replace(
618 " format: text\n identity: []\n",
619 " format: text\n identity: [id]\n"
620 ))
621 .contains("text kind"));
622 assert!(
623 bad(&text.replace(" schema: majordomus.rule/v1\n", " schema: nothing\n"))
624 .contains("schema 'nothing'")
625 );
626 let mut invalid = SchemaSet::default();
627 let err = invalid
628 .extend(
629 vec![("x".into(), serde_json::json!({ "type": "nonsense" }))],
630 "d",
631 )
632 .unwrap_err();
633 assert!(err.to_string().contains("not a valid JSON Schema"), "{err}");
634 }
635
636 #[test]
637 fn a_repository_adds_kinds_and_may_not_redefine_one() {
638 let (text, schemas) = dist();
639 let extra = "schema: majordomus-kinds/v1\nkinds:\n note:\n format: markdown\n front_matter: required\n identity: [id]\n title: title\n";
640 let sources = [
641 Source {
642 path: "share/kinds.yaml".into(),
643 text: text.clone(),
644 },
645 Source {
646 path: ".ai/repo/knowledge/kinds.yaml".into(),
647 text: extra.into(),
648 },
649 ];
650 let schema = KindSchema::parse_all(&sources, schemas).unwrap();
651 assert!(schema.kind("note").is_some());
652 assert_eq!(
653 schema.sources(),
654 ["share/kinds.yaml", ".ai/repo/knowledge/kinds.yaml"]
655 );
656 let redefine = "schema: majordomus-kinds/v1\nkinds:\n rule:\n format: yaml\n";
657 let sources = [
658 Source {
659 path: "share/kinds.yaml".into(),
660 text,
661 },
662 Source {
663 path: ".ai/repo/knowledge/kinds.yaml".into(),
664 text: redefine.into(),
665 },
666 ];
667 let err = KindSchema::parse_all(&sources, dist().1)
668 .unwrap_err()
669 .to_string();
670 assert!(err.contains("kind 'rule' is declared by both share/kinds.yaml and .ai/repo/knowledge/kinds.yaml"), "{err}");
671 }
672}