majordomus_cli/entity.rs
1//! Entities: the one projection that makes every object of the layer addressable.
2//!
3//! An [`crate::model::Object`] already has a kind, an identity and a `majordomus://` URI,
4//! and every surface of this executable already reads it. What it did not have was an
5//! *address a reader can be given*: the Cockpit showed one object at
6//! `/cockpit/object?uri=majordomus%3A%2F%2Frule%2Fproject.x%401`, no page of the public
7//! site showed a decision or a project rule at all, and nothing answered the two questions
8//! a governance object is read for — what does it point at, and what points at it.
9//!
10//! This module is that projection and nothing else. It holds no list of kinds, no list of
11//! entities and no list of routes:
12//!
13//! ```text
14//! index (objects) ─┬─> slug ──> route one deterministic function, per object
15//! graph::RELATIONS ─┘ relations the same table the graph and the
16//! backlinks dangling-reference check already read
17//! capability registry ─> surfaces the exposures the registry already holds
18//! ```
19//!
20//! Adding an object to the layer therefore adds its route, its page, its relations and its
21//! backlinks, with no edit here and none in any consumer. That is the invariant
22//! `project.entities-are-routable` states and `scripts/ci/entity-check` refuses to let
23//! drift.
24//!
25//! # The slug
26//!
27//! An identity is not a path segment: `project.a-rule@1` carries an `@`, and a `document`'s
28//! identity is a repository-relative path with slashes in it. [`slug`] is the deterministic
29//! reduction to one segment — lowercase, every run of anything else a single `-`, trimmed:
30//!
31//! ```
32//! use majordomus_cli::entity::slug;
33//! assert_eq!(slug("adr-0004"), "adr-0004");
34//! assert_eq!(slug("project.a-diagram-is-drawn-not-typed@1"),
35//! "project-a-diagram-is-drawn-not-typed-1");
36//! assert_eq!(slug(".ai/repo/README.md"), "ai-repo-readme-md");
37//! assert_eq!(slug("Continue"), "continue");
38//! ```
39//!
40//! Two identities of one kind can in principle reduce to one slug. That is a collision, not
41//! a tie-break: [`collisions`] finds every one of them and the gate refuses the tree while
42//! any exists, because a route that answers with one of two objects is worse than no route.
43
44use std::collections::BTreeMap;
45
46use schemars::JsonSchema;
47use serde::{Deserialize, Serialize};
48
49use crate::capability::model::Capability;
50use crate::capability::registry::CapabilityRegistry;
51use crate::graph::{self, Outcome};
52use crate::model::Object;
53
54/// Where every entity route of the Cockpit starts. A kind index is this plus the kind; an
55/// entity is that plus its slug. Nothing else is under it, which is what makes the two
56/// route shapes unambiguous without a list of kinds to check against.
57pub const COCKPIT_PREFIX: &str = "/cockpit/objects";
58
59/// The deterministic, URL-safe reduction of an identity to one path segment.
60///
61/// Lowercase; every maximal run of characters outside `[a-z0-9]` becomes a single `-`;
62/// leading and trailing `-` are dropped. An identity that reduces to nothing (there is no
63/// such identity in a valid layer, because an identity is non-empty and a path always
64/// carries a letter) yields the empty string, and [`collisions`] reports it.
65///
66/// ```
67/// use majordomus_cli::entity::slug;
68/// assert_eq!(slug("majordomus.scope-integrity@1"), "majordomus-scope-integrity-1");
69/// assert_eq!(slug("---"), "");
70/// assert_eq!(slug("A B"), "a-b");
71/// ```
72pub fn slug(identity: &str) -> String {
73 let mut out = String::with_capacity(identity.len());
74 let mut pending = false;
75 for ch in identity.chars() {
76 if ch.is_ascii_alphanumeric() {
77 if pending && !out.is_empty() {
78 out.push('-');
79 }
80 pending = false;
81 out.push(ch.to_ascii_lowercase());
82 } else {
83 pending = true;
84 }
85 }
86 out
87}
88
89/// The Cockpit route of one kind's index.
90///
91/// ```
92/// assert_eq!(majordomus_cli::entity::kind_route("adr"), "/cockpit/objects/adr");
93/// ```
94pub fn kind_route(kind: &str) -> String {
95 format!("{COCKPIT_PREFIX}/{}", slug(kind))
96}
97
98/// The Cockpit route of one entity, from its kind and identity.
99///
100/// ```
101/// use majordomus_cli::entity::route;
102/// assert_eq!(route("adr", "adr-0004"), "/cockpit/objects/adr/adr-0004");
103/// assert_eq!(route("rule", "project.x@1"), "/cockpit/objects/rule/project-x-1");
104/// ```
105pub fn route(kind: &str, identity: &str) -> String {
106 format!("{}/{}", kind_route(kind), slug(identity))
107}
108
109/// The route of an object, from the object itself.
110///
111/// [`route`] asks for the kind and the identity separately, because the Cockpit's router
112/// has those two strings out of a path and no object yet. This is the same function for
113/// every caller that is already holding the object, and it exists so that no consumer
114/// reaches for `o.kind` and `o.identity` and spells the address a second time.
115///
116/// ```
117/// # use majordomus_cli::{Object, Provenance};
118/// # fn object(kind: &str, identity: &str) -> Object {
119/// # Object { kind: kind.into(), identity: identity.into(),
120/// # uri: format!("majordomus://{kind}/{identity}"), title: None, description: None,
121/// # metadata: serde_json::json!({}), body: String::new(), content: String::new(),
122/// # media_type: "text/markdown",
123/// # provenance: Provenance { path: "x.md".into(), directory: "x".into(),
124/// # source_class: kind.into(), section: None, bytes: 0, member: None } }
125/// # }
126/// use majordomus_cli::entity::{object_route, route};
127///
128/// let rule = object("rule", "project.entities-are-routable@1");
129/// assert_eq!(
130/// object_route(&rule),
131/// "/cockpit/objects/rule/project-entities-are-routable-1"
132/// );
133/// // and it is the same address the router resolves with
134/// assert_eq!(object_route(&rule), route(&rule.kind, &rule.identity));
135/// ```
136pub fn object_route(o: &Object) -> String {
137 route(&o.kind, &o.identity)
138}
139
140/// Find the object of a kind whose identity has this slug.
141///
142/// Linear over the kind's objects, which is what the Cockpit needs (a lookup per request,
143/// over at most a few hundred objects of one kind) and what keeps this function free of a
144/// cache nobody invalidates.
145///
146/// ```
147/// # use majordomus_cli::{Object, Provenance};
148/// # fn object(kind: &str, identity: &str) -> Object {
149/// # Object { kind: kind.into(), identity: identity.into(),
150/// # uri: format!("majordomus://{kind}/{identity}"), title: None, description: None,
151/// # metadata: serde_json::json!({}), body: String::new(), content: String::new(),
152/// # media_type: "text/markdown",
153/// # provenance: Provenance { path: "x.md".into(), directory: "x".into(),
154/// # source_class: kind.into(), section: None, bytes: 0, member: None } }
155/// # }
156/// use majordomus_cli::entity::find;
157///
158/// let objects = vec![object("rule", "project.x@1"), object("adr", "adr-0056")];
159///
160/// let found = find(&objects, "rule", "project-x-1").expect("the slug resolves");
161/// assert_eq!(found.identity, "project.x@1");
162/// // the kind is part of the address, so the same slug under another kind is not it
163/// assert!(find(&objects, "adr", "project-x-1").is_none());
164/// assert!(find(&objects, "rule", "no-such-rule").is_none());
165/// ```
166pub fn find<'a>(objects: &'a [Object], kind: &str, slug_wanted: &str) -> Option<&'a Object> {
167 objects
168 .iter()
169 .find(|o| o.kind == kind && slug(&o.identity) == slug_wanted)
170}
171
172/// Two objects of one kind whose identities reduce to the same route.
173///
174/// Carries both identities and both files, because the reader who has to fix it needs to
175/// know which two things collided and where they are written — a count would tell them
176/// only that the tree is refused. `correction` is the sentence [`collisions`] wrote about
177/// this particular pair.
178///
179/// ```
180/// # use majordomus_cli::{Object, Provenance};
181/// # fn object(kind: &str, identity: &str) -> Object {
182/// # Object { kind: kind.into(), identity: identity.into(),
183/// # uri: format!("majordomus://{kind}/{identity}"), title: None, description: None,
184/// # metadata: serde_json::json!({}), body: String::new(), content: String::new(),
185/// # media_type: "text/markdown",
186/// # provenance: Provenance { path: format!("{identity}.md"), directory: "x".into(),
187/// # source_class: kind.into(), section: None, bytes: 0, member: None } }
188/// # }
189/// use majordomus_cli::entity::{collisions, Collision};
190///
191/// // `a.b` and `a-b` are two identities and one slug
192/// let objects = vec![object("rule", "a.b"), object("rule", "a-b")];
193/// let found: Vec<Collision> = collisions(&objects);
194///
195/// assert_eq!(found.len(), 1);
196/// assert_eq!(found[0].kind, "rule");
197/// assert_eq!(found[0].slug, "a-b");
198/// assert_eq!(found[0].identities, ["a.b", "a-b"]);
199/// assert!(!found[0].correction.is_empty());
200/// ```
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
202#[schemars(rename = "RouteCollision")]
203pub struct Collision {
204 /// The kind they share.
205 pub kind: String,
206 /// The slug they both reduce to; empty when an identity reduces to nothing.
207 pub slug: String,
208 /// The identities that collide, in index order.
209 pub identities: Vec<String>,
210 /// The files that declared them.
211 pub paths: Vec<String>,
212 /// What to do about it.
213 pub correction: String,
214}
215
216/// Every route collision in an index. Empty is the healthy answer, and the only one the
217/// gate accepts: a route that resolves to one of two objects is not a route.
218///
219/// ```
220/// # use majordomus_cli::entity::collisions;
221/// assert!(collisions(&[]).is_empty());
222/// ```
223pub fn collisions(objects: &[Object]) -> Vec<Collision> {
224 let mut by_route: BTreeMap<(String, String), Vec<&Object>> = BTreeMap::new();
225 for o in objects {
226 by_route
227 .entry((o.kind.clone(), slug(&o.identity)))
228 .or_default()
229 .push(o);
230 }
231 by_route
232 .into_iter()
233 .filter(|((_, s), group)| group.len() > 1 || s.is_empty())
234 .map(|((kind, s), group)| Collision {
235 correction: if s.is_empty() {
236 format!(
237 "the identity of an object of kind `{kind}` carries no letter or digit and so has no route; give it an identity that does"
238 )
239 } else {
240 format!(
241 "{} objects of kind `{kind}` share the route {}; rename one of them so that each has an address of its own",
242 group.len(),
243 route(&kind, &s)
244 )
245 },
246 kind,
247 slug: s,
248 identities: group.iter().map(|o| o.identity.clone()).collect(),
249 paths: group.iter().map(|o| o.provenance.path.clone()).collect(),
250 })
251 .collect()
252}
253
254// ---------------------------------------------------------------- relations
255
256/// Which way a relation runs from the entity being read.
257///
258/// The direction is a property of the *reading*, not of the relation: one edge in
259/// `graph::RELATIONS` is outgoing on the page of the object that declared it and incoming
260/// on the page of the object it names. Nothing in the layer writes `Incoming` down.
261///
262/// ```
263/// use majordomus_cli::entity::Direction;
264///
265/// // the wire spelling, which every surface shows and the site template reads
266/// assert_eq!(
267/// serde_json::to_string(&Direction::Outgoing).unwrap(),
268/// "\"outgoing\""
269/// );
270/// assert_eq!(
271/// serde_json::to_string(&Direction::Incoming).unwrap(),
272/// "\"incoming\""
273/// );
274/// // declared before derived, which is the order an entity page renders the two sections
275/// assert!((Direction::Outgoing as u8) < (Direction::Incoming as u8));
276/// ```
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
278#[serde(rename_all = "lowercase")]
279#[schemars(rename = "RelationDirection")]
280pub enum Direction {
281 /// This entity declared the reference.
282 Outgoing,
283 /// Another entity named this one. Never declared: derived by reading every other
284 /// object's references and keeping the ones that resolve here.
285 Incoming,
286}
287
288/// One edge, as an entity page reads it.
289///
290/// Everything a reader needs about the other end is here — what to show, what kind of thing
291/// it is, and the address to send them to — so the page renders a relation without going
292/// back to the index for it. `route` is `None` exactly when `external` is true: a `file`, a
293/// `test` or a `command` is named by the layer and is not an object of it, so there is no
294/// entity page to link to and the template shows plain text instead of a dead link.
295///
296/// ```
297/// use majordomus_cli::entity::{Direction, Edge};
298/// use majordomus_cli::order::Ordered;
299///
300/// let edge = Edge {
301/// direction: Direction::Incoming,
302/// edge: "depends_on".into(),
303/// field: "depends_on".into(),
304/// uri: Some("majordomus://rule/project.x@1".into()),
305/// kind: "rule".into(),
306/// label: "project.x@1".into(),
307/// title: Some("A rule".into()),
308/// route: Some("/cockpit/objects/rule/project-x-1".into()),
309/// external: false,
310/// };
311///
312/// // the relation is the group and the direction ranks inside it; see the `Ordered` impl
313/// let key = edge.order_key();
314/// assert_eq!(key.group, Some("depends_on"));
315/// assert_eq!(key.rank, Direction::Incoming as i64);
316/// assert_eq!(key.identity, "majordomus://rule/project.x@1");
317/// ```
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
319#[schemars(rename = "EntityEdge")]
320pub struct Edge {
321 /// Which way it runs from the entity that was asked for.
322 pub direction: Direction,
323 /// The relationship, in the graph's vocabulary (`depends_on`, `put_in_force`, ...).
324 pub edge: String,
325 /// The front matter key the reference was declared under.
326 pub field: String,
327 /// The object at the other end: its URI when the layer holds it.
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub uri: Option<String>,
330 /// Its kind — of the layer, or the external vocabulary (`file`, `test`, `command`).
331 pub kind: String,
332 /// What to show: the identity of an object, or the name of something outside.
333 pub label: String,
334 /// Its title, when it is an object that has one.
335 #[serde(skip_serializing_if = "Option::is_none")]
336 pub title: Option<String>,
337 /// Its entity route, when it has one; absent for something outside the layer.
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub route: Option<String>,
340 /// True when the other end is not an object of this index.
341 pub external: bool,
342}
343
344/// An edge is ordered by the relation it is, then by which way it runs, then by what a
345/// reader sees.
346///
347/// The relation is the group, because that is the bucket the reader is actually scanning —
348/// every `depends_on` together, every `put_in_force` together — and the direction is a rank
349/// inside it rather than the most significant part, because every consumer of this
350/// collection filters to one direction before it renders (the entity page draws the
351/// declared references and the backlinks as two sections). The URI is the tie-breaker, and
352/// an external end that has none falls back to its label; `edges` dedups, so two edges that
353/// compare equal here were the same edge twice.
354///
355/// This is the whole order of the collection. There was a `sort_by` here beside the
356/// building of it, which is the second opinion [`crate::order`] exists to remove — the
357/// natural comparison it brings is also the one every other surface of this repository
358/// shows, so `case-2` precedes `case-10` on an entity page as it does everywhere else.
359impl crate::order::Ordered for Edge {
360 fn order_key(&self) -> crate::order::OrderKey<'_> {
361 crate::order::OrderKey::grouped(
362 &self.edge,
363 &self.label,
364 self.uri.as_deref().unwrap_or(&self.label),
365 )
366 .ranked(self.direction as i64)
367 }
368}
369
370/// Every edge of one object: the references it declares, and the references that resolve
371/// to it.
372///
373/// Both directions come from one table — `graph::RELATIONS` — so a relation added there
374/// appears as a forward edge on one page and as a backlink on the other, with nothing
375/// declared twice and no bidirectional bookkeeping in the layer. A reference that resolves
376/// to nothing is not an edge; it is a finding, and
377/// [`graph::unresolved_relations`] is what reports it.
378///
379/// A rule that depends on another produces one edge on each of the two pages, from the one
380/// `depends_on` the first of them declared:
381///
382/// ```
383/// # use majordomus_cli::{Object, Provenance};
384/// # fn rule(identity: &str, metadata: serde_json::Value) -> Object {
385/// # Object { kind: "rule".into(), identity: identity.into(),
386/// # uri: format!("majordomus://rule/{identity}"), title: None, description: None,
387/// # metadata, body: String::new(), content: String::new(),
388/// # media_type: "text/markdown",
389/// # provenance: Provenance { path: format!("{identity}.md"), directory: "x".into(),
390/// # source_class: "rule".into(), section: None, bytes: 0, member: None } }
391/// # }
392/// use majordomus_cli::capability::builtin;
393/// use majordomus_cli::capability::registry::CapabilityRegistry;
394/// use majordomus_cli::entity::{edges, Direction};
395///
396/// let registry = CapabilityRegistry::builder()
397/// .with_builtin(builtin::all())
398/// .build()
399/// .unwrap();
400///
401/// let dependant = rule("project.a@1", serde_json::json!({ "depends_on": ["project.b@1"] }));
402/// let dependency = rule("project.b@1", serde_json::json!({}));
403/// let objects = vec![dependant.clone(), dependency.clone()];
404///
405/// // the declaring end sees it run outwards
406/// let out = edges(®istry, &objects, &dependant);
407/// assert!(out.iter().any(|e| e.direction == Direction::Outgoing
408/// && e.edge == "depends_on"
409/// && e.label == "project.b@1"));
410///
411/// // the named end sees the same relation as a backlink, declared nowhere
412/// let back = edges(®istry, &objects, &dependency);
413/// assert!(back.iter().any(|e| e.direction == Direction::Incoming
414/// && e.edge == "depends_on"
415/// && e.label == "project.a@1"));
416/// ```
417pub fn edges(registry: &CapabilityRegistry, objects: &[Object], subject: &Object) -> Vec<Edge> {
418 let resolver = graph::Resolver::new(registry, objects);
419 let by_uri: BTreeMap<&str, &Object> = objects.iter().map(|o| (o.uri.as_str(), o)).collect();
420 let mut out: Vec<Edge> = Vec::new();
421
422 for declarer in objects {
423 for rel in graph::RELATIONS {
424 if !rel.kinds.is_empty() && !rel.kinds.contains(&declarer.kind.as_str()) {
425 continue;
426 }
427 for reference in graph::metadata_strings(&declarer.metadata, rel.field) {
428 if graph::is_absence(&reference) {
429 continue;
430 }
431 let (from, to, target) = match resolver.resolve(rel, &reference) {
432 Outcome::Node(target) => {
433 if rel.inverted {
434 (Some(target.clone()), Some(declarer.uri.clone()), target)
435 } else {
436 (Some(declarer.uri.clone()), Some(target.clone()), target)
437 }
438 }
439 Outcome::External(node) => {
440 // an external end has no URI, so it can only ever be the far end of
441 // an edge this object declared
442 if declarer.uri != subject.uri {
443 continue;
444 }
445 out.push(Edge {
446 direction: Direction::Outgoing,
447 edge: rel.edge.to_string(),
448 field: rel.field.to_string(),
449 uri: None,
450 kind: node.kind.clone(),
451 label: reference.clone(),
452 title: None,
453 route: None,
454 external: true,
455 });
456 continue;
457 }
458 Outcome::Missing(_) => continue,
459 };
460 let (Some(from), Some(to)) = (from, to) else {
461 continue;
462 };
463 let direction = if from == subject.uri {
464 Direction::Outgoing
465 } else if to == subject.uri {
466 Direction::Incoming
467 } else {
468 continue;
469 };
470 // the far end is whichever of the two is not the subject
471 let other = if direction == Direction::Outgoing {
472 to
473 } else {
474 from
475 };
476 let _ = ⌖
477 out.push(match by_uri.get(other.as_str()) {
478 Some(o) => Edge {
479 direction,
480 edge: rel.edge.to_string(),
481 field: rel.field.to_string(),
482 uri: Some(o.uri.clone()),
483 kind: o.kind.clone(),
484 label: o.identity.clone(),
485 title: o.title.clone(),
486 route: Some(object_route(o)),
487 external: false,
488 },
489 // a capability node: the graph names it `capability:<id>`, and it is
490 // not an object of the index
491 None => Edge {
492 direction,
493 edge: rel.edge.to_string(),
494 field: rel.field.to_string(),
495 uri: None,
496 kind: "capability".into(),
497 label: other.trim_start_matches("capability:").to_string(),
498 title: None,
499 route: Some(format!(
500 "/cockpit/capabilities/{}",
501 crate::http::router::percent_encode(
502 other.trim_start_matches("capability:")
503 )
504 )),
505 external: false,
506 },
507 });
508 }
509 }
510 }
511 crate::order::canonical(&mut out);
512 out.dedup();
513 out
514}
515
516// ---------------------------------------------------------------- surfaces
517
518/// One place this entity can be reached, and how.
519///
520/// A row of the "where else this is answered" table on an entity page. `surface` is the
521/// interface's own name, so a reader who works on the command line and a reader who works
522/// over MCP each find their own line rather than a route they have to translate.
523///
524/// ```
525/// use majordomus_cli::entity::Surface;
526///
527/// let s = Surface {
528/// surface: "mcp".into(),
529/// address: "majordomus://rule/project.x@1".into(),
530/// detail: "the file as read".into(),
531/// };
532/// assert_eq!(
533/// serde_json::to_value(&s).unwrap()["surface"],
534/// serde_json::json!("mcp")
535/// );
536/// ```
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
538#[schemars(rename = "EntitySurface")]
539pub struct Surface {
540 /// Which surface: `mcp`, `http`, `cli`, `cockpit`, `site`.
541 pub surface: String,
542 /// The address on it: a URI, a route, a command line.
543 pub address: String,
544 /// What a reader does with it.
545 pub detail: String,
546}
547
548/// Every surface that answers for one object, derived from the registry's exposures rather
549/// than listed here.
550///
551/// The MCP line is the object's own resource exposure; the HTTP and command-line lines are
552/// the exposures of the capability that reads an object by URI, whichever capability that
553/// is and whatever route it is mounted at, so a route that moves moves here too.
554///
555/// ```
556/// # use majordomus_cli::{Object, Provenance};
557/// # fn object(kind: &str, identity: &str) -> Object {
558/// # Object { kind: kind.into(), identity: identity.into(),
559/// # uri: format!("majordomus://{kind}/{identity}"), title: None, description: None,
560/// # metadata: serde_json::json!({}), body: String::new(), content: String::new(),
561/// # media_type: "text/markdown",
562/// # provenance: Provenance { path: "x.md".into(), directory: "x".into(),
563/// # source_class: kind.into(), section: None, bytes: 0, member: None } }
564/// # }
565/// use majordomus_cli::capability::builtin;
566/// use majordomus_cli::capability::registry::CapabilityRegistry;
567/// use majordomus_cli::entity::surfaces;
568///
569/// let registry = CapabilityRegistry::builder()
570/// .with_builtin(builtin::all())
571/// .build()
572/// .unwrap();
573///
574/// let found = surfaces(®istry, &object("rule", "project.x@1"));
575/// // the Cockpit always answers, because an address is derived and never registered
576/// assert!(found.iter().any(|s| s.surface == "cockpit"
577/// && s.address == "/cockpit/objects/rule/project-x-1"));
578/// // and nothing here is written down: every line came out of the registry's exposures
579/// assert!(found.iter().all(|s| !s.address.is_empty()));
580/// ```
581pub fn surfaces(registry: &CapabilityRegistry, o: &Object) -> Vec<Surface> {
582 let mut out = Vec::new();
583 let id = format!("{}.{}", o.kind, o.identity);
584 if let Some(c) = registry.get(&id) {
585 if let Some(m) = &c.exposure.mcp {
586 if let Some(r) = &m.resource {
587 out.push(Surface {
588 surface: "mcp".into(),
589 address: r.uri.clone(),
590 detail: format!(
591 "resources/read, or the tool majordomus_get with uri={}",
592 o.uri
593 ),
594 });
595 }
596 }
597 }
598 let reader: Option<&Capability> = registry.get("entity.show").or(registry.get("objects.get"));
599 if let Some(c) = reader {
600 if let Some(http) = &c.exposure.http {
601 out.push(Surface {
602 surface: "http".into(),
603 address: format!(
604 "{} {}?uri={}",
605 http.method.as_str(),
606 http.path,
607 crate::http::router::percent_encode(&o.uri)
608 ),
609 detail: format!("the typed entity as {} answers it", c.id),
610 });
611 }
612 if let Some(cli) = &c.exposure.cli {
613 // the address a person types is the route's two segments, which is shorter than
614 // the URI and is the same thing the Cockpit's address bar holds
615 out.push(Surface {
616 surface: "cli".into(),
617 address: format!(
618 "majordomus {} {}/{}",
619 cli.path.join(" "),
620 o.kind,
621 slug(&o.identity)
622 ),
623 detail: "the same answer on the command line; --format json for the typed form"
624 .into(),
625 });
626 }
627 }
628 out.push(Surface {
629 surface: "cockpit".into(),
630 address: object_route(o),
631 detail: "this page".into(),
632 });
633 out
634}
635
636// ---------------------------------------------------------------- the public page
637
638/// Where a repository declares, for every kind of its index, whether and where its public
639/// site publishes that kind. Repository-relative.
640///
641/// The file is the site's, not this executable's: `scripts/generate-site-data` builds the
642/// entity pages from it and `scripts/ci/entity-check` holds it to the index. [`Publication`]
643/// reads the same file so that the address an entity names for its public page is the
644/// address the site generator wrote the page at — one declaration, two readers, no second
645/// list of kinds or routes.
646pub const PUBLICATION: &str = "site/data/publication.toml";
647
648/// How one kind reaches the public site, as the declaration states it.
649///
650/// ```
651/// use majordomus_cli::entity::{Publication, PublishedKind};
652///
653/// let p = Publication::parse(
654/// "[[kinds]]\nkind = \"adr\"\nprojection = \"entity\"\nroute = \"/adrs/\"\n",
655/// None,
656/// );
657/// let adr: &PublishedKind = p.kind("adr").unwrap();
658/// assert_eq!(adr.projection, "entity");
659/// assert_eq!(adr.route.as_deref(), Some("/adrs/"));
660/// assert_eq!(adr.reason, None);
661/// assert!(p.kind("rule").is_none());
662/// ```
663#[derive(Debug, Clone, Default, PartialEq, Eq)]
664pub struct PublishedKind {
665 /// The kind, as the index reports it.
666 pub kind: String,
667 /// `entity` (a page per object under `route`), `section` (an editorial section at
668 /// `route` publishes the kind) or `none` (not published, for `reason`).
669 pub projection: String,
670 /// The public index of the kind, for `entity` and `section`.
671 pub route: Option<String>,
672 /// Why the kind is not published, for `none`.
673 pub reason: Option<String>,
674}
675
676/// A repository's publication declaration, with the base URL its site is built for.
677///
678/// Read from [`PUBLICATION`] and the site's `base_url` at request time, never compiled in:
679/// this executable supervises repositories that have no site at all, and for those
680/// [`Publication::read`] answers `None` and every entity's `documentation` is null.
681///
682/// The declaration is the small TOML subset the site generator reads with `awk`: `#`
683/// comment lines, one `[[kinds]]` table per kind, and `key = value` lines whose string
684/// values are double-quoted. Anything else is ignored rather than guessed at, exactly as
685/// the generator ignores it.
686///
687/// ```
688/// use majordomus_cli::entity::Publication;
689///
690/// let p = Publication::parse(
691/// "[[kinds]]\nkind = \"adr\"\nprojection = \"entity\"\nroute = \"/adrs/\"\n",
692/// Some("base_url = \"https://example.invalid\"\n"),
693/// );
694/// assert_eq!(p.kinds.len(), 1);
695/// assert_eq!(p.base_url.as_deref(), Some("https://example.invalid"));
696/// // with no site configuration, the routes stand and no URL is invented
697/// assert_eq!(Publication::parse("", None), Publication::default());
698/// ```
699#[derive(Debug, Clone, Default, PartialEq, Eq)]
700pub struct Publication {
701 /// Every declared kind, in the file's order.
702 pub kinds: Vec<PublishedKind>,
703 /// The site's `base_url` without a trailing slash, when the site configuration has one.
704 pub base_url: Option<String>,
705}
706
707/// The public documentation page of one entity, as its kind's publication decision derives
708/// it. Every surface that shows an entity carries this value and renders it; none of them
709/// derives a public address of its own.
710///
711/// ```
712/// use majordomus_cli::entity::Documentation;
713///
714/// let d = Documentation {
715/// projection: "entity".into(),
716/// route: Some("/adrs/adr-0056/".into()),
717/// url: Some("https://majordomus.dev/adrs/adr-0056/".into()),
718/// reason: None,
719/// declared_in: "site/data/publication.toml".into(),
720/// };
721/// let wire = serde_json::to_value(&d).unwrap();
722/// assert_eq!(wire["url"], serde_json::json!("https://majordomus.dev/adrs/adr-0056/"));
723/// // a field that does not apply is absent, not an empty string
724/// assert!(wire.get("reason").is_none());
725/// ```
726#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
727#[schemars(rename = "EntityDocumentation")]
728pub struct Documentation {
729 /// The kind's projection: `entity`, `section` or `none`.
730 pub projection: String,
731 /// The public route of the page, site-relative: the entity's own page for an `entity`
732 /// kind, the kind's section for a `section` kind, absent for `none`.
733 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub route: Option<String>,
735 /// The route under the site's `base_url`, when the site configuration names one.
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub url: Option<String>,
738 /// Why the kind is not published, for `none`.
739 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub reason: Option<String>,
741 /// The file the decision is read from.
742 pub declared_in: String,
743}
744
745/// The value of a `key = value` line: a double-quoted string unescaped, anything else as
746/// written up to a trailing comment.
747fn toml_value(raw: &str) -> String {
748 let raw = raw.trim();
749 let Some(body) = raw.strip_prefix('"') else {
750 return raw.split(" #").next().unwrap_or("").trim().to_string();
751 };
752 let mut out = String::with_capacity(body.len());
753 let mut chars = body.chars();
754 while let Some(c) = chars.next() {
755 match c {
756 '"' => break,
757 '\\' => match chars.next() {
758 Some('n') => out.push('\n'),
759 Some('t') => out.push('\t'),
760 Some(other) => out.push(other),
761 None => break,
762 },
763 c => out.push(c),
764 }
765 }
766 out
767}
768
769/// One `key = value` line, or `None` for anything that is not one.
770fn toml_pair(line: &str) -> Option<(&str, String)> {
771 let (key, value) = line.split_once('=')?;
772 let key = key.trim();
773 if key.is_empty()
774 || !key
775 .chars()
776 .all(|c| c.is_ascii_lowercase() || c == '_' || c == '-')
777 {
778 return None;
779 }
780 Some((key, toml_value(value)))
781}
782
783impl Publication {
784 /// Parse a publication declaration and, when there is one, the site configuration its
785 /// `base_url` is read from (a top-level key, before the first table).
786 ///
787 /// ```
788 /// use majordomus_cli::entity::Publication;
789 ///
790 /// let p = Publication::parse(
791 /// "# comment\n[[kinds]]\nkind = \"prompt\"\nprojection = \"none\"\nreason = \"a \\\"why\\\"\"\n",
792 /// Some("base_url = \"https://example.invalid/\"\n[markdown]\nbase_url = \"no\"\n"),
793 /// );
794 /// assert_eq!(p.base_url.as_deref(), Some("https://example.invalid"));
795 /// assert_eq!(p.kind("prompt").unwrap().reason.as_deref(), Some("a \"why\""));
796 /// ```
797 pub fn parse(declaration: &str, site_config: Option<&str>) -> Publication {
798 let mut kinds: Vec<PublishedKind> = Vec::new();
799 let mut current: Option<PublishedKind> = None;
800 for line in declaration.lines() {
801 let line = line.trim();
802 if line.is_empty() || line.starts_with('#') {
803 continue;
804 }
805 if line.starts_with('[') {
806 kinds.extend(current.take());
807 if line == "[[kinds]]" {
808 current = Some(PublishedKind::default());
809 }
810 continue;
811 }
812 let (Some(k), Some((key, value))) = (current.as_mut(), toml_pair(line)) else {
813 continue;
814 };
815 match key {
816 "kind" => k.kind = value,
817 "projection" => k.projection = value,
818 "route" => k.route = Some(value),
819 "reason" => k.reason = Some(value),
820 _ => {}
821 }
822 }
823 kinds.extend(current);
824 let base_url = site_config.and_then(|c| {
825 c.lines()
826 .map(str::trim)
827 .take_while(|l| !l.starts_with('['))
828 .filter_map(toml_pair)
829 .find(|(k, _)| *k == "base_url")
830 .map(|(_, v)| v.trim_end_matches('/').to_string())
831 });
832 Publication { kinds, base_url }
833 }
834
835 /// Read the declaration of the repository at `root`, or `None` when it has none.
836 ///
837 /// ```
838 /// use majordomus_cli::entity::{Publication, PUBLICATION};
839 ///
840 /// let root = std::env::temp_dir().join(format!("mj-publication-{}", std::process::id()));
841 /// // a repository with no site declares nothing, and nothing is invented for it
842 /// assert!(Publication::read(&root.join("absent")).is_none());
843 ///
844 /// std::fs::create_dir_all(root.join("site/data")).unwrap();
845 /// std::fs::write(
846 /// root.join(PUBLICATION),
847 /// "[[kinds]]\nkind = \"adr\"\nprojection = \"entity\"\nroute = \"/adrs/\"\n",
848 /// )
849 /// .unwrap();
850 /// let p = Publication::read(&root).unwrap();
851 /// assert_eq!(p.kinds.len(), 1);
852 /// assert_eq!(p.base_url, None, "no site/config.toml, so no base URL");
853 /// std::fs::remove_dir_all(&root).unwrap();
854 /// ```
855 pub fn read(root: &std::path::Path) -> Option<Publication> {
856 let declaration = std::fs::read_to_string(root.join(PUBLICATION)).ok()?;
857 let config = std::fs::read_to_string(root.join(crate::web::discover::SITE_CONFIG)).ok();
858 Some(Publication::parse(&declaration, config.as_deref()))
859 }
860
861 /// The declaration of one kind, if the file declares it.
862 ///
863 /// ```
864 /// use majordomus_cli::entity::Publication;
865 ///
866 /// let p = Publication::parse(
867 /// "[[kinds]]\nkind = \"prompt\"\nprojection = \"none\"\nreason = \"internal\"\n",
868 /// None,
869 /// );
870 /// assert_eq!(p.kind("prompt").and_then(|k| k.reason.as_deref()), Some("internal"));
871 /// assert!(p.kind("adr").is_none());
872 /// ```
873 pub fn kind(&self, kind: &str) -> Option<&PublishedKind> {
874 self.kinds.iter().find(|k| k.kind == kind)
875 }
876
877 /// The public page of the entity `slug` of `kind`: the same function
878 /// `scripts/generate-site-data` writes the site's pages with — `route` + slug + `/` for
879 /// an `entity` kind, the kind's `route` for a `section` kind, nothing for `none`. A kind
880 /// the declaration does not name has no answer, because nothing decided it.
881 ///
882 /// ```
883 /// use majordomus_cli::entity::Publication;
884 ///
885 /// let p = Publication::parse(
886 /// "[[kinds]]\nkind = \"adr\"\nprojection = \"entity\"\nroute = \"/adrs/\"\n\
887 /// [[kinds]]\nkind = \"skill\"\nprojection = \"section\"\nroute = \"/skills/\"\n",
888 /// Some("base_url = \"https://example.invalid\"\n"),
889 /// );
890 /// let adr = p.documentation("adr", "adr-0056").unwrap();
891 /// assert_eq!(adr.url.as_deref(), Some("https://example.invalid/adrs/adr-0056/"));
892 /// let skill = p.documentation("skill", "commit").unwrap();
893 /// assert_eq!(skill.route.as_deref(), Some("/skills/"));
894 /// assert!(p.documentation("prompt", "x").is_none());
895 /// ```
896 pub fn documentation(&self, kind: &str, slug: &str) -> Option<Documentation> {
897 let k = self.kind(kind)?;
898 let route = match (k.projection.as_str(), &k.route) {
899 ("entity", Some(r)) => Some(format!("{r}{slug}/")),
900 ("section", Some(r)) => Some(r.clone()),
901 _ => None,
902 };
903 let reason = match (&route, &k.reason) {
904 (Some(_), _) => None,
905 (None, Some(r)) => Some(r.clone()),
906 (None, None) => Some(format!(
907 "{PUBLICATION} gives kind '{kind}' the projection '{}' and no route",
908 k.projection
909 )),
910 };
911 Some(Documentation {
912 projection: k.projection.clone(),
913 url: route
914 .as_ref()
915 .zip(self.base_url.as_ref())
916 .map(|(r, b)| format!("{b}{r}")),
917 route,
918 reason,
919 declared_in: PUBLICATION.to_string(),
920 })
921 }
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927
928 fn object(kind: &str, identity: &str) -> Object {
929 Object {
930 kind: kind.into(),
931 identity: identity.into(),
932 uri: crate::model::uri_for(kind, identity),
933 title: None,
934 description: None,
935 metadata: serde_json::Value::Null,
936 body: String::new(),
937 content: String::new(),
938 media_type: "text/markdown",
939 provenance: crate::model::Provenance {
940 path: format!(".ai/{kind}/{identity}.md"),
941 directory: format!(".ai/{kind}"),
942 source_class: kind.into(),
943 section: None,
944 bytes: 0,
945 member: None,
946 },
947 }
948 }
949
950 #[test]
951 fn a_slug_is_one_segment_lowercase_and_stable() {
952 assert_eq!(slug("adr-0004"), "adr-0004");
953 assert_eq!(slug("project.x@1"), "project-x-1");
954 assert_eq!(slug("a/b/c.md"), "a-b-c-md");
955 assert_eq!(
956 slug("ÜBER"),
957 "ber",
958 "non-ascii is a separator, not a letter"
959 );
960 assert!(!slug("a/b").contains('/'), "a slug is one path segment");
961 // deterministic
962 assert_eq!(slug("project.x@1"), slug("project.x@1"));
963 }
964
965 #[test]
966 fn a_route_is_derived_from_kind_and_identity() {
967 let o = object("rule", "project.x@1");
968 assert_eq!(object_route(&o), "/cockpit/objects/rule/project-x-1");
969 assert_eq!(kind_route("rule"), "/cockpit/objects/rule");
970 assert!(object_route(&o).starts_with(&kind_route("rule")));
971 }
972
973 #[test]
974 fn an_entity_is_found_by_its_own_route() {
975 let objects = vec![object("rule", "project.x@1"), object("adr", "adr-0004")];
976 let found = find(&objects, "rule", "project-x-1").expect("the rule");
977 assert_eq!(found.identity, "project.x@1");
978 assert!(
979 find(&objects, "rule", "adr-0004").is_none(),
980 "kind is part of the address"
981 );
982 assert!(find(&objects, "rule", "nothing").is_none());
983 }
984
985 #[test]
986 fn two_identities_that_reduce_to_one_route_are_a_collision() {
987 let objects = vec![object("rule", "project.x@1"), object("rule", "project-x-1")];
988 let found = collisions(&objects);
989 assert_eq!(found.len(), 1, "{found:?}");
990 assert_eq!(found[0].slug, "project-x-1");
991 assert_eq!(found[0].identities.len(), 2);
992 assert!(found[0]
993 .correction
994 .contains("/cockpit/objects/rule/project-x-1"));
995 // and a healthy index has none
996 assert!(collisions(&[object("rule", "a@1"), object("rule", "b@1")]).is_empty());
997 }
998
999 #[test]
1000 fn an_identity_with_no_route_is_reported_rather_than_routed() {
1001 let found = collisions(&[object("rule", "---")]);
1002 assert_eq!(found.len(), 1);
1003 assert_eq!(found[0].slug, "");
1004 assert!(found[0].correction.contains("no route"));
1005 }
1006
1007 const DECLARATION: &str = "\
1008# where each kind is published
1009[[kinds]]
1010kind = \"adr\"
1011projection = \"entity\"
1012route = \"/adrs/\"
1013title = \"Decisions\"
1014weight = 45
1015
1016[[kinds]]
1017kind = \"skill\"
1018projection = \"section\"
1019route = \"/skills/\"
1020
1021[[kinds]]
1022kind = \"prompt\"
1023projection = \"none\"
1024reason = \"A prompt is an input to a provider.\"
1025";
1026
1027 #[test]
1028 fn an_entity_kind_publishes_one_page_per_object_under_its_route() {
1029 let p = Publication::parse(DECLARATION, Some("base_url = \"https://majordomus.dev\"\n"));
1030 let d = p.documentation("adr", "adr-0056").expect("adr is declared");
1031 assert_eq!(d.projection, "entity");
1032 assert_eq!(d.route.as_deref(), Some("/adrs/adr-0056/"));
1033 assert_eq!(
1034 d.url.as_deref(),
1035 Some("https://majordomus.dev/adrs/adr-0056/")
1036 );
1037 assert_eq!(d.reason, None);
1038 assert_eq!(d.declared_in, PUBLICATION);
1039 }
1040
1041 #[test]
1042 fn a_section_kind_is_published_at_its_section_whatever_the_object() {
1043 let p = Publication::parse(
1044 DECLARATION,
1045 Some("base_url = \"https://majordomus.dev/\"\n"),
1046 );
1047 let d = p
1048 .documentation("skill", "commit")
1049 .expect("skill is declared");
1050 assert_eq!(d.projection, "section");
1051 assert_eq!(d.route.as_deref(), Some("/skills/"));
1052 assert_eq!(
1053 d.url.as_deref(),
1054 Some("https://majordomus.dev/skills/"),
1055 "a trailing slash on base_url does not double"
1056 );
1057 }
1058
1059 #[test]
1060 fn a_kind_that_is_not_published_says_why_and_has_no_address() {
1061 let p = Publication::parse(DECLARATION, Some("base_url = \"https://majordomus.dev\"\n"));
1062 let d = p
1063 .documentation("prompt", "continue")
1064 .expect("prompt is declared");
1065 assert_eq!(d.projection, "none");
1066 assert_eq!(d.route, None);
1067 assert_eq!(d.url, None);
1068 assert_eq!(
1069 d.reason.as_deref(),
1070 Some("A prompt is an input to a provider.")
1071 );
1072 }
1073
1074 #[test]
1075 fn an_undeclared_kind_or_a_missing_declaration_has_no_answer() {
1076 let p = Publication::parse(DECLARATION, None);
1077 assert!(p.documentation("rule", "x").is_none(), "nothing decided it");
1078 // no site configuration: the route stands and the URL is not invented
1079 let d = p.documentation("adr", "adr-0001").unwrap();
1080 assert_eq!(d.route.as_deref(), Some("/adrs/adr-0001/"));
1081 assert_eq!(d.url, None);
1082
1083 let empty = tempfile::tempdir().unwrap();
1084 assert!(
1085 Publication::read(empty.path()).is_none(),
1086 "a repository with no site"
1087 );
1088 }
1089
1090 #[test]
1091 fn the_declaration_is_read_from_the_repository_with_its_base_url() {
1092 let root = tempfile::tempdir().unwrap();
1093 std::fs::create_dir_all(root.path().join("site/data")).unwrap();
1094 std::fs::write(root.path().join(PUBLICATION), DECLARATION).unwrap();
1095 std::fs::write(
1096 root.path().join(crate::web::discover::SITE_CONFIG),
1097 "base_url = \"https://example.invalid\"\ntitle = \"x\"\n\n[markdown]\nbase_url = \"no\"\n",
1098 )
1099 .unwrap();
1100 let p = Publication::read(root.path()).expect("declared");
1101 assert_eq!(p.kinds.len(), 3);
1102 assert_eq!(p.base_url.as_deref(), Some("https://example.invalid"));
1103 assert_eq!(
1104 p.documentation("adr", "adr-0002")
1105 .and_then(|d| d.url)
1106 .as_deref(),
1107 Some("https://example.invalid/adrs/adr-0002/")
1108 );
1109 }
1110}