Skip to main content

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(&registry, &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(&registry, &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 _ = &target;
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(&registry, &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}