majordomus_cli/capability/builtin/entity.rs
1//! The `entity` module: every object of the layer as an addressable, cross-linked node.
2//!
3//! `objects.get` answers what a file *is*. This module answers the three further questions
4//! a governance object is actually read for, and answers them for every kind at once
5//! because none of them is asked of a kind by name:
6//!
7//! - **Where does it live?** Its route, derived from its kind and identity by
8//! [`crate::entity::slug`], the same function the Cockpit resolves a request with.
9//! - **What is it joined to?** Its outgoing references and — the half nothing declared —
10//! the references that resolve to it, both read from the one relation table the graph
11//! and the dangling-reference check already share.
12//! - **Where is it published?** Its public documentation page, when the repository's site
13//! publishes its kind: [`crate::entity::Publication`] reads `site/data/publication.toml`
14//! and the site's `base_url`, the same declaration the site generator builds the pages
15//! from, so the address named here is the page the site holds.
16//! - **What can be said about its enforcement?** Which of the executable artefacts it
17//! names are in the tree, stated as a state a page may not round up, and a pointer to the
18//! capability that can prove more where the registry holds one.
19//!
20//! Nothing here enumerates kinds, entities or routes. A file added under `.ai/` becomes an
21//! object, an object has a route, and this capability answers for it — which is the whole
22//! of `project.entities-are-routable`.
23//!
24//! One entity is asked for either way a caller already holds it — the URI the layer indexes
25//! it by, or the address a reader was given — and the two name the same object:
26//!
27//! ```
28//! use majordomus_cli::capability::builtin::entity::{EntityInput, EvidenceState};
29//!
30//! let by_uri: EntityInput =
31//! serde_json::from_value(serde_json::json!({ "uri": "majordomus://rule/project.x@1" }))
32//! .unwrap();
33//! assert_eq!(by_uri.uri.as_deref(), Some("majordomus://rule/project.x@1"));
34//!
35//! let by_route: EntityInput =
36//! serde_json::from_value(serde_json::json!({ "kind": "rule", "slug": "project-x-1" }))
37//! .unwrap();
38//! assert_eq!(by_route.kind.as_deref(), Some("rule"));
39//!
40//! // a key nobody declared is an error, not a silently ignored field
41//! assert!(serde_json::from_value::<EntityInput>(serde_json::json!({ "url": "x" })).is_err());
42//!
43//! // and what the answer may say about enforcement is a state, never a run
44//! let unclaimed = serde_json::to_value(EvidenceState::Unclaimed).unwrap();
45//! assert_eq!(unclaimed, serde_json::json!("unclaimed"));
46//! ```
47
48use schemars::JsonSchema;
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51
52use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
53use crate::capability::handler::{CapabilityError, Context};
54use crate::capability::model::{CliExposure, Exposure, Stability};
55use crate::capability::module::ModuleDescriptor;
56use crate::entity::{self, Collision, Documentation, Edge, Publication, Surface};
57use crate::model::Object;
58use crate::{capability, module};
59
60use super::{get, mcp, ObjectSummary};
61
62// ---------------------------------------------------------------- input
63
64/// Which entity: by URI, or by the address it is served at.
65///
66/// Two spellings of one question, because two kinds of caller ask it: MCP and the API hold
67/// the `majordomus://` URI the index is keyed by, and the Cockpit's router has a `kind` and
68/// a `slug` out of a path. Nothing converts one form into the other at the edge — the
69/// capability resolves either, so neither caller has to know the other's spelling.
70///
71/// ```
72/// use majordomus_cli::capability::builtin::entity::EntityInput;
73///
74/// let by_route: EntityInput =
75/// serde_json::from_value(serde_json::json!({ "kind": "adr", "slug": "adr-0056" }))
76/// .unwrap();
77/// assert_eq!(by_route.kind.as_deref(), Some("adr"));
78/// assert_eq!(by_route.slug.as_deref(), Some("adr-0056"));
79/// assert_eq!(by_route.uri, None);
80///
81/// // absent is a shape, not an error: which fields are required is the handler's question
82/// assert_eq!(EntityInput::default().kind, None);
83/// ```
84#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
85#[serde(deny_unknown_fields)]
86pub struct EntityInput {
87 /// `majordomus://<kind>/<identity>`. Either this, or `kind` and `slug` together.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub uri: Option<String>,
90 /// The kind, when addressing by route.
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub kind: Option<String>,
93 /// The last segment of the entity's route, when addressing by route.
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub slug: Option<String>,
96}
97
98impl BenchmarkCases for EntityInput {
99 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
100 ctx.index
101 .objects
102 .first()
103 .map(|o| {
104 vec![NamedCase::new(
105 "first-object",
106 EntityInput {
107 uri: Some(o.uri.clone()),
108 ..Default::default()
109 },
110 )]
111 })
112 .unwrap_or_default()
113 }
114}
115
116// ---------------------------------------------------------------- evidence
117
118/// What this repository can say about the executable artefacts an object names, and only
119/// that. The word is chosen so that no reader can mistake it for a run.
120///
121/// The three states are ordered by how much they permit a reader to conclude, and the
122/// strongest of them still concludes nothing about a run: `Resolved` means the files an
123/// object points at exist. `proof` on [`Evidence`] is where "did it ever pass" is asked.
124///
125/// ```
126/// use majordomus_cli::capability::builtin::entity::EvidenceState;
127///
128/// // the wire spellings, which the Cockpit badge and the site template both read
129/// for (state, word) in [
130/// (EvidenceState::Unclaimed, "unclaimed"),
131/// (EvidenceState::Dangling, "dangling"),
132/// (EvidenceState::Resolved, "resolved"),
133/// ] {
134/// assert_eq!(serde_json::to_value(state).unwrap(), serde_json::json!(word));
135/// }
136/// ```
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
138#[serde(rename_all = "snake_case")]
139#[schemars(rename = "EntityEvidenceState")]
140pub enum EvidenceState {
141 /// The object names no executable artefact. Most kinds name none, and that is not a
142 /// defect: a prompt is not enforced by anything.
143 Unclaimed,
144 /// It names artefacts and at least one of them is not in the tree. A claim that points
145 /// at nothing is worse than no claim, because it reads as enforcement.
146 Dangling,
147 /// Every artefact it names is in the tree. This says the claim resolves — never that it
148 /// passed. Whether it ever ran is a different question, and `proof` is where it is
149 /// asked.
150 Resolved,
151}
152
153/// One executable artefact an object names, and whether the tree holds it.
154///
155/// `field` is kept beside the path because the same object can name artefacts under several
156/// keys, and a reader fixing a dangling one needs to know which line of the front matter to
157/// look at rather than which file is missing.
158///
159/// ```
160/// use majordomus_cli::capability::builtin::entity::ClaimedArtifact;
161///
162/// let a = ClaimedArtifact {
163/// field: "x-majordomus.tests".into(),
164/// path: "test/cases/277_entities_are_routable.sh".into(),
165/// present: true,
166/// };
167/// let wire = serde_json::to_value(&a).unwrap();
168/// assert_eq!(wire["field"], serde_json::json!("x-majordomus.tests"));
169/// assert_eq!(wire["present"], serde_json::json!(true));
170/// ```
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172pub struct ClaimedArtifact {
173 /// The front matter key it was named under (`x-majordomus.tests`, `test`, ...).
174 pub field: String,
175 /// The repository-relative path as named.
176 pub path: String,
177 /// Whether the tree holds it.
178 pub present: bool,
179}
180
181/// Where a fuller answer about this entity's enforcement lives.
182///
183/// A pointer, never an answer. This module states what it can see in the tree and then
184/// names the capability that can say more, so that an entity page does not grow a second,
185/// weaker implementation of proof beside the one that already exists.
186///
187/// ```
188/// use majordomus_cli::capability::builtin::entity::ProofRef;
189///
190/// let p = ProofRef {
191/// capability: "rule.proves".into(),
192/// title: "What proves a rule".into(),
193/// address: "majordomus rule proves project.entities-are-routable@1".into(),
194/// };
195/// assert_eq!(
196/// serde_json::to_value(&p).unwrap()["capability"],
197/// serde_json::json!("rule.proves")
198/// );
199/// ```
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
201#[schemars(rename = "EntityProofRef")]
202pub struct ProofRef {
203 /// The capability that answers it.
204 pub capability: String,
205 /// Its title.
206 pub title: String,
207 /// How to ask it here.
208 pub address: String,
209}
210
211/// What can be said about an object's enforcement without running anything.
212///
213/// `meaning` travels with `state` so that every surface shows the same sentence about it:
214/// a badge alone invites each renderer to write its own gloss, and the gloss is where a
215/// "resolved" quietly becomes a "passing". A kind that names no artefact is `Unclaimed`
216/// with an empty `artifacts`, which is an answer and not an omission.
217///
218/// ```
219/// use majordomus_cli::capability::builtin::entity::{Evidence, EvidenceState};
220///
221/// let e = Evidence {
222/// state: EvidenceState::Unclaimed,
223/// meaning: "This object names no executable artefact.".into(),
224/// artifacts: Vec::new(),
225/// proof: None,
226/// };
227///
228/// // the empty collections are omitted from the wire, so absence reads as absence
229/// let wire = serde_json::to_value(&e).unwrap();
230/// assert_eq!(wire["state"], serde_json::json!("unclaimed"));
231/// assert!(wire.get("artifacts").is_none());
232/// assert!(wire.get("proof").is_none());
233/// ```
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
235#[schemars(rename = "EntityEvidence")]
236pub struct Evidence {
237 /// The state, which is never rounded up.
238 pub state: EvidenceState,
239 /// That state in one sentence, for a reader.
240 pub meaning: String,
241 /// Every artefact named, with whether the tree holds it.
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub artifacts: Vec<ClaimedArtifact>,
244 /// The capability that can say more, when this kind has one.
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub proof: Option<ProofRef>,
247}
248
249/// The front matter keys that name an executable artefact. Not a list of kinds: a key, on
250/// whatever kind carries it. `x-majordomus` is the extension block the layer's schemas
251/// reserve for exactly this, and the others are the keys the claim and rule schemas already
252/// use for the same purpose.
253const ARTIFACT_FIELDS: &[&str] = &["tests", "test", "implementation", "validator"];
254
255/// Collect the paths an object names as executable artefacts, from its front matter and
256/// from its `x-majordomus` extension block.
257fn claimed(metadata: &Value) -> Vec<(String, String)> {
258 let mut out = Vec::new();
259 let mut take = |prefix: &str, source: &Value| {
260 for field in ARTIFACT_FIELDS {
261 let key = if prefix.is_empty() {
262 (*field).to_string()
263 } else {
264 format!("{prefix}.{field}")
265 };
266 match source.get(field) {
267 Some(Value::Array(a)) => out.extend(
268 a.iter()
269 .filter_map(Value::as_str)
270 .filter(|s| looks_like_a_path(s))
271 .map(|s| (key.clone(), s.to_string())),
272 ),
273 Some(Value::String(s)) if looks_like_a_path(s) => {
274 out.push((key.clone(), s.clone()))
275 }
276 _ => {}
277 }
278 }
279 };
280 take("", metadata);
281 if let Some(x) = metadata.get("x-majordomus") {
282 take("x-majordomus", x);
283 }
284 out
285}
286
287/// A value that names a file rather than a symbol. `-` is the layer's written absence, and
288/// a validator is named by its function rather than its path; neither is a path to look for.
289fn looks_like_a_path(s: &str) -> bool {
290 s.contains('/') && s != "-"
291}
292
293fn evidence(ctx: &Context, o: &Object) -> Evidence {
294 let root = &ctx.index.repository.root;
295 let artifacts: Vec<ClaimedArtifact> = claimed(&o.metadata)
296 .into_iter()
297 .map(|(field, path)| ClaimedArtifact {
298 present: std::path::Path::new(root).join(&path).exists(),
299 field,
300 path,
301 })
302 .collect();
303 // one formula, no table: a kind whose module answers `<kind>s.show` can prove more
304 let proof = ctx
305 .registry
306 .get(&format!("{}s.show", o.kind))
307 .map(|c| ProofRef {
308 capability: c.id.to_string(),
309 title: c.title.clone(),
310 address: match &c.exposure.http {
311 Some(h) => format!("{} {}", h.method.as_str(), h.path),
312 None => c.id.to_string(),
313 },
314 });
315 let missing = artifacts.iter().filter(|a| !a.present).count();
316 let (state, meaning) = if artifacts.is_empty() {
317 (
318 EvidenceState::Unclaimed,
319 "This object names no executable artefact, so nothing here claims it is enforced."
320 .to_string(),
321 )
322 } else if missing > 0 {
323 (
324 EvidenceState::Dangling,
325 format!(
326 "{missing} of the {} artefact(s) this object names are not in the tree: it reads as enforced and is not.",
327 artifacts.len()
328 ),
329 )
330 } else {
331 (
332 EvidenceState::Resolved,
333 format!(
334 "Every one of the {} artefact(s) this object names is in the tree. That they resolve is not that they ran.",
335 artifacts.len()
336 ),
337 )
338 };
339 Evidence {
340 state,
341 meaning,
342 artifacts,
343 proof,
344 }
345}
346
347// ---------------------------------------------------------------- the view
348
349/// One object of the layer as an addressable node: what it is, where it is served, what it
350/// is joined to, and what can be said about its enforcement.
351///
352/// The whole answer in one value, because it is one question: every surface that shows an
353/// entity — the Cockpit page, `GET /api/v1/entity`, `majordomus_entity`, `majordomus entity
354/// show` — renders this and composes nothing of its own. A field a reader sees on one
355/// surface and not another would be a surface holding an opinion, which is what ADR 0004
356/// forecloses.
357///
358/// ```
359/// use majordomus_cli::capability::builtin::entity::EntityView;
360///
361/// // it round-trips through the wire shape every projection reads it as
362/// let view: EntityView = serde_json::from_value(serde_json::json!({
363/// "uri": "majordomus://adr/adr-0056",
364/// "id": "adr.adr-0056",
365/// "kind": "adr",
366/// "identity": "adr-0056",
367/// "slug": "adr-0056",
368/// "route": "/cockpit/objects/adr/adr-0056",
369/// "kind_route": "/cockpit/objects/adr",
370/// "title": "Every object of the layer has a derived address",
371/// "provenance": { "path": "x.md", "directory": "x", "source_class": "adr", "bytes": 0 },
372/// "metadata": {},
373/// "media_type": "text/markdown",
374/// "content": "",
375/// "relations": [],
376/// "surfaces": [],
377/// "evidence": { "state": "unclaimed", "meaning": "nothing named" },
378/// "documentation": {
379/// "projection": "entity",
380/// "route": "/adrs/adr-0056/",
381/// "url": "https://majordomus.dev/adrs/adr-0056/",
382/// "declared_in": "site/data/publication.toml",
383/// },
384/// }))
385/// .unwrap();
386///
387/// assert_eq!(view.route, "/cockpit/objects/adr/adr-0056");
388/// // the Cockpit route is the object's; the public page is the site's, and both are named
389/// let public = view.documentation.as_ref().and_then(|d| d.url.as_deref());
390/// assert_eq!(public, Some("https://majordomus.dev/adrs/adr-0056/"));
391/// // the kind's index is the entity's route with the last segment removed
392/// assert!(view.route.starts_with(&view.kind_route));
393/// ```
394#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
395pub struct EntityView {
396 /// `majordomus://<kind>/<identity>`.
397 pub uri: String,
398 /// The capability id, `<kind>.<identity>`.
399 pub id: String,
400 /// The kind.
401 pub kind: String,
402 /// The identity within the kind.
403 pub identity: String,
404 /// The last segment of its route, derived from the identity.
405 pub slug: String,
406 /// Its route in the Cockpit.
407 pub route: String,
408 /// The route of its kind's index.
409 pub kind_route: String,
410 #[serde(skip_serializing_if = "Option::is_none")]
411 /// The title, when the kind's title rule found one.
412 pub title: Option<String>,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 /// The one-line description, when the kind holds one.
415 pub description: Option<String>,
416 /// Where it came from.
417 pub provenance: crate::model::Provenance,
418 /// The parsed front matter or YAML, keys in the file's order.
419 pub metadata: Value,
420 /// IANA media type of `content`.
421 pub media_type: String,
422 /// The file as read.
423 pub content: String,
424 /// The references it declares, and the references that resolve to it.
425 pub relations: Vec<Edge>,
426 /// Every surface that answers for it.
427 pub surfaces: Vec<Surface>,
428 /// What can be said about its enforcement without running anything.
429 pub evidence: Evidence,
430 /// Its public documentation page, derived from the repository's publication declaration
431 /// (`site/data/publication.toml`) and the site's `base_url` by the same function the site
432 /// generator writes the page with. Null when the repository declares no publication or
433 /// does not declare this kind; a kind declared as not published carries the reason.
434 #[serde(default)]
435 pub documentation: Option<Documentation>,
436}
437
438fn subject<'a>(ctx: &'a Context, input: &EntityInput) -> Result<&'a Object, CapabilityError> {
439 match (&input.uri, &input.kind, &input.slug) {
440 (Some(uri), None, None) => ctx.index.get(uri).ok_or_else(|| {
441 CapabilityError::NotFound(format!("no object of this layer has the URI {uri}"))
442 }),
443 (None, Some(kind), Some(slug)) => {
444 entity::find(&ctx.index.objects, kind, slug).ok_or_else(|| {
445 CapabilityError::NotFound(format!(
446 "no object of kind '{kind}' is served at {}",
447 entity::route(kind, slug)
448 ))
449 })
450 }
451 _ => Err(CapabilityError::InvalidInput(
452 "name the entity either by 'uri' or by 'kind' and 'slug' together".into(),
453 )),
454 }
455}
456
457fn objects_entity(ctx: &Context, input: EntityInput) -> Result<EntityView, CapabilityError> {
458 let o = subject(ctx, &input)?;
459 Ok(EntityView {
460 uri: o.uri.clone(),
461 id: format!("{}.{}", o.kind, o.identity),
462 kind: o.kind.clone(),
463 identity: o.identity.clone(),
464 slug: entity::slug(&o.identity),
465 route: entity::object_route(o),
466 kind_route: entity::kind_route(&o.kind),
467 title: o.title.clone(),
468 description: o.description.clone(),
469 provenance: o.provenance.clone(),
470 metadata: o.metadata.clone(),
471 media_type: o.media_type.to_string(),
472 content: o.content.clone(),
473 relations: entity::edges(&ctx.registry, &ctx.index.objects, o),
474 surfaces: entity::surfaces(&ctx.registry, o),
475 evidence: evidence(ctx, o),
476 // read at request time: the declaration is the repository's, and a repository
477 // without a site has none
478 documentation: Publication::read(std::path::Path::new(&ctx.index.repository.root))
479 .and_then(|p| p.documentation(&o.kind, &entity::slug(&o.identity))),
480 })
481}
482
483// ---------------------------------------------------------------- kinds
484
485/// One kind of the layer, with its address and how many objects it holds.
486///
487/// `example` carries one of the kind's objects so that a catalogue of kinds is somewhere a
488/// reader can go from rather than a list of words: a kind with a count and no way in is a
489/// dead row. It is `None` only for a kind the index holds no object of.
490///
491/// ```
492/// use majordomus_cli::capability::builtin::entity::KindEntry;
493///
494/// let e: KindEntry = serde_json::from_value(serde_json::json!({
495/// "kind": "adr",
496/// "count": 52,
497/// "route": "/cockpit/objects/adr",
498/// }))
499/// .unwrap();
500///
501/// assert_eq!(e.kind, "adr");
502/// // the route is the kind's, derived — never a per-kind entry in a table of routes
503/// assert_eq!(e.route, majordomus_cli::entity::kind_route(&e.kind));
504/// assert!(e.example.is_none());
505/// ```
506#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
507pub struct KindEntry {
508 /// The kind.
509 pub kind: String,
510 /// How many objects of it the index holds.
511 pub count: usize,
512 /// Where its index is served.
513 pub route: String,
514 /// One of its objects, so a reader has somewhere to go.
515 #[serde(skip_serializing_if = "Option::is_none")]
516 pub example: Option<ObjectSummary>,
517}
518
519/// Every kind of the layer, each with its route, and every route collision there is.
520///
521/// The two numbers are what `scripts/ci/entity-check` compares: the objects the index holds
522/// against the objects that have an address. They are reported together, in one answer, so
523/// that the gate asks one question instead of joining two lists and deciding for itself
524/// what the difference means.
525///
526/// ```
527/// use majordomus_cli::capability::builtin::entity::KindList;
528///
529/// let healthy: KindList = serde_json::from_value(serde_json::json!({
530/// "count": 1,
531/// "kinds": [{ "kind": "adr", "count": 52, "route": "/cockpit/objects/adr" }],
532/// "routable": 52,
533/// }))
534/// .unwrap();
535///
536/// let held: usize = healthy.kinds.iter().map(|k| k.count).sum();
537/// // every object addressable, and no collision: the only state the gate accepts
538/// assert_eq!(held, healthy.routable);
539/// assert!(healthy.collisions.is_empty());
540/// ```
541#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
542pub struct KindList {
543 /// How many kinds.
544 pub count: usize,
545 /// The kinds, in the index's order.
546 pub kinds: Vec<KindEntry>,
547 /// How many objects have a route. Every object does, unless it is in `collisions`.
548 pub routable: usize,
549 /// Two objects of one kind whose identities reduce to one route, which is the only way
550 /// an object can fail to be addressable. Empty is the healthy answer and the only one
551 /// `scripts/ci/entity-check` accepts.
552 #[serde(default, skip_serializing_if = "Vec::is_empty")]
553 pub collisions: Vec<Collision>,
554}
555
556fn objects_kinds(ctx: &Context, _: super::Empty) -> Result<KindList, CapabilityError> {
557 let kinds: Vec<KindEntry> = ctx
558 .index
559 .kinds()
560 .into_iter()
561 .map(|(kind, count)| KindEntry {
562 route: entity::kind_route(kind),
563 example: ctx
564 .index
565 .objects
566 .iter()
567 .find(|o| o.kind == kind)
568 .map(ObjectSummary::of),
569 kind: kind.to_string(),
570 count,
571 })
572 .collect();
573 let collisions = entity::collisions(&ctx.index.objects);
574 let colliding: usize = collisions.iter().map(|c| c.identities.len()).sum();
575 Ok(KindList {
576 count: kinds.len(),
577 kinds,
578 routable: ctx.index.objects.len().saturating_sub(colliding),
579 collisions,
580 })
581}
582
583/// The canonical declaration of the `entity` module: the two capabilities that answer for
584/// an object and for the catalogue of kinds, with every projection each is exposed through.
585///
586/// This is the only place either capability is written down. The MCP tool, the HTTP route,
587/// the command-line path, the generated OpenAPI and the Cockpit's page are all read from
588/// here, which is why adding a surface to `entity.show` is an edit to this function and
589/// never to a transport registry or a documentation table (ADR 0004).
590///
591/// ```
592/// use majordomus_cli::capability::builtin::entity;
593///
594/// let m = entity::module();
595/// assert_eq!(m.id.as_str(), "entity");
596///
597/// // both capabilities are namespaced by the module id, with no second spelling of it
598/// let ids: Vec<String> = m
599/// .capabilities
600/// .iter()
601/// .map(|c| c.capability.id.to_string())
602/// .collect();
603/// assert!(ids.iter().any(|id| id == "entity.show"));
604/// assert!(ids.iter().any(|id| id == "entity.kinds"));
605///
606/// // and each declares where it is answered, rather than being registered elsewhere
607/// let show = m
608/// .capabilities
609/// .iter()
610/// .find(|c| c.capability.id.to_string() == "entity.show")
611/// .expect("entity.show is declared here");
612/// assert_eq!(
613/// show.capability.exposure.http.as_ref().map(|h| h.path.as_str()),
614/// Some("/api/v1/entity")
615/// );
616/// ```
617pub fn module() -> ModuleDescriptor {
618 module! {
619 id: "entity",
620 title: "Entities",
621 description: "Every object of the layer as an addressable, cross-linked node: its route, its outgoing references, the references that resolve to it, the surfaces that answer for it, and what can be said about its enforcement without running anything. Nothing here enumerates kinds, entities or routes — an object of the index has a route because it is an object.",
622 stability: Stability::Implemented,
623 capabilities: [
624 capability! {
625 id: "entity.show",
626 title: "Read one entity",
627 description: "One object of the layer as an addressable node, by URI or by the address it is served at: identity and route, provenance, front matter and content, every reference it declares and every reference that resolves to it, the surfaces that answer for it, the state of the executable artefacts it names, and its public documentation page as the repository's site/data/publication.toml derives it (null when the repository declares none).",
628 input: EntityInput,
629 output: EntityView,
630 stability: Stability::Implemented,
631 exposure: Exposure {
632 mcp: mcp("majordomus_entity"),
633 http: get("/api/v1/entity"),
634 cli: Some(CliExposure { path: vec!["entity".into(), "show".into()] }),
635 },
636 tags: ["objects", "governance"],
637 handler: objects_entity,
638 },
639 capability! {
640 id: "entity.kinds",
641 title: "Every kind, and whether every object of it is addressable",
642 description: "The kinds the layer holds, each with the route of its index and how many objects it carries, and every route collision there is. A collision is the only way an object of the index can fail to have an address of its own; the healthy answer is none.",
643 input: super::Empty,
644 output: KindList,
645 stability: Stability::Implemented,
646 exposure: Exposure {
647 mcp: mcp("majordomus_kinds"),
648 http: get("/api/v1/entity/kinds"),
649 cli: Some(CliExposure { path: vec!["entity".into(), "kinds".into()] }),
650 },
651 tags: ["objects", "governance"],
652 handler: objects_kinds,
653 },
654 ],
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use serde_json::json;
662
663 #[test]
664 fn only_a_path_counts_as_a_named_artefact() {
665 assert!(looks_like_a_path("scripts/ci/x"));
666 assert!(!looks_like_a_path("-"));
667 assert!(
668 !looks_like_a_path("scope_is_declared"),
669 "a validator named by its function is not a file to look for"
670 );
671 }
672
673 #[test]
674 fn artefacts_are_collected_from_the_front_matter_and_the_extension_block() {
675 let m = json!({
676 "test": "test/cases/1_a.sh",
677 "implementation": "-",
678 "x-majordomus": { "tests": ["scripts/ci/a", "scripts/ci/b"] },
679 });
680 assert_eq!(
681 claimed(&m),
682 vec![
683 ("test".to_string(), "test/cases/1_a.sh".to_string()),
684 ("x-majordomus.tests".to_string(), "scripts/ci/a".to_string()),
685 ("x-majordomus.tests".to_string(), "scripts/ci/b".to_string()),
686 ],
687 "the written absence is not an artefact, and the extension block is read too"
688 );
689 }
690
691 #[test]
692 fn an_object_that_names_nothing_is_unclaimed_rather_than_enforced() {
693 assert!(claimed(&json!({"title": "x"})).is_empty());
694 assert!(claimed(&Value::Null).is_empty());
695 }
696}