majordomus_cli/web/mod.rs
1//! The repository's web surfaces: one model, discovered once, read by everything that
2//! serves, publishes, validates or describes them.
3//!
4//! A *surface* is anything this repository exposes over HTTP — the application's site, the
5//! capability routes, the Swagger UI, the Cockpit, a generated test or benchmark report.
6//! Before this module each of those was known separately by the router, the publication
7//! script, the gate model and the documentation, and a new one cost a registration in each.
8//! Here it is discovered from its producer ([`discover`]), checked against the invariants a
9//! topology must satisfy ([`validate`]), composed into a publishable tree (`compose`) and
10//! written out for diagnostics (`manifest`) — all from the same [`model::Topology`].
11//!
12//! The decision and what it rejected are in `.ai/repo/adrs/0013-*.md`.
13//!
14//! ```
15//! use majordomus_cli::web::{discover, model::Mount};
16//! // the executable's own routes come from the constants that already declare them
17//! let native = discover::native(discover::Runtime::full());
18//! assert!(native.iter().any(|s| s.mount == Mount::parse("/swagger").unwrap()));
19//! ```
20
21pub(crate) mod compose;
22pub mod discover;
23pub mod files;
24pub(crate) mod home;
25pub mod html;
26pub(crate) mod manifest;
27pub mod model;
28pub mod report;
29pub mod serve;
30pub mod validate;
31
32pub use model::{
33 Availability, Category, Feature, Mount, Provenance, Surface, SurfaceKind, Topology, Visibility,
34};
35
36#[cfg(test)]
37mod tests {
38 use super::*;
39
40 #[test]
41 fn one_discovery_answers_the_router_the_home_page_and_a_publication() {
42 // the claim this module exists to make: serving, publishing and describing are three
43 // readings of one topology, so none of them can know a surface the others do not
44 let topology = Topology::new(discover::native(discover::Runtime::full()));
45
46 let served = topology.served(discover::Runtime::full());
47 let published = topology.published();
48 for id in served.ids().into_iter().chain(published.ids()) {
49 assert!(
50 topology.get(id).is_some(),
51 "{id} is in a reading and not in the topology it was read from"
52 );
53 }
54 // and the reserved namespaces stay apart: /swagger is the API viewer, /docs is the
55 // repository's documentation, and one owner answers each path
56 let swagger = topology
57 .owner("/swagger")
58 .expect("something owns the Swagger UI path");
59 assert_eq!(swagger.id, "swagger");
60 assert!(
61 topology.owner("/docs").is_none_or(|s| s.id != "swagger"),
62 "the API viewer does not own the documentation namespace"
63 );
64 }
65
66 #[test]
67 fn narrowing_a_topology_can_only_take_surfaces_away() {
68 // a projection serving a subset filters the value the process already resolved; a
69 // narrowing that could add one would be a second discovery
70 let full = Topology::new(discover::native(discover::Runtime::full()));
71 let bare = Topology::new(discover::native(discover::Runtime::default()));
72 for id in bare.ids() {
73 assert!(full.get(id).is_some(), "{id} appeared out of a narrowing");
74 }
75 assert!(bare.ids().len() <= full.ids().len());
76 }
77
78 #[test]
79 fn a_resolved_topology_has_one_owner_for_every_path_it_claims() {
80 let topology = Topology::new(discover::native(discover::Runtime::full()));
81 let findings = validate::validate(
82 &topology,
83 std::path::Path::new("/nonexistent"),
84 validate::Artifacts::Ignore,
85 );
86 assert!(findings.is_empty(), "{findings:?}");
87 }
88}
89
90/// One route the projection answers for itself, as the OpenAPI document, the published
91/// site and the Cockpit all show it: where it answers, what it is, and whether a
92/// publication can carry it.
93///
94/// This is not a fourth list. Every entry is a surface [`discover::native_all`] resolved
95/// from the constants that already declare it, so a prefix that moves moves here, and the
96/// description is the producer's own title rather than a sentence written beside it.
97#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
98pub struct ProjectionRoute {
99 /// The surface's identity in the topology.
100 pub id: String,
101 /// Where it answers.
102 pub path: String,
103 /// One line: what it is, in the words of the surface that declares it.
104 pub what: String,
105 /// Whether the running executable, a publication, or both answer for it.
106 pub availability: Availability,
107}
108
109impl ProjectionRoute {
110 /// May a published page offer this as a link a reader can follow?
111 ///
112 /// A page that a publication carries can link it. A page that only a running process
113 /// answers cannot: on a published site that link is a promise nothing keeps, and the
114 /// usual repair — a test on the page's own address — hides the rule in a template.
115 ///
116 /// ```
117 /// use majordomus_cli::web::projection_routes;
118 /// let swagger = projection_routes().into_iter().find(|r| r.id == "swagger").unwrap();
119 /// assert!(!swagger.linkable(), "no server, no console");
120 /// ```
121 pub fn linkable(&self) -> bool {
122 self.availability.is_published()
123 }
124}
125
126/// The projection's own routes, in the order their producers declare them: the home page
127/// first, then every native surface.
128///
129/// The home page is `ServedOnly` and says so: a publication mounts its application at `/`
130/// and carries no index of a process, so a published page that linked `/` expecting the
131/// index would be pointing at the landing page instead. Nothing here is narrowed to what
132/// one process offers — a document describes the projection, and [`discover::Runtime`]
133/// decides what a given server serves of it.
134pub fn projection_routes() -> Vec<ProjectionRoute> {
135 discover::native_all()
136 .into_iter()
137 .map(|s| ProjectionRoute {
138 id: s.id,
139 path: s.mount.to_string(),
140 what: s.title,
141 availability: s.availability,
142 })
143 .collect()
144}
145
146#[cfg(test)]
147mod projection_route_tests {
148 use super::*;
149
150 fn route(id: &str) -> ProjectionRoute {
151 projection_routes()
152 .into_iter()
153 .find(|r| r.id == id)
154 .unwrap_or_else(|| panic!("{id} is a surface of this executable"))
155 }
156
157 #[test]
158 fn the_routes_are_the_surfaces_and_not_a_list_written_beside_them() {
159 let resolved: Vec<String> = discover::native_all()
160 .into_iter()
161 .map(|s| s.mount.to_string())
162 .collect();
163 let shown: Vec<String> = projection_routes().into_iter().map(|r| r.path).collect();
164 assert_eq!(shown, resolved);
165 }
166
167 #[test]
168 fn a_surface_only_a_process_answers_is_not_offered_by_a_publication() {
169 for id in ["swagger", "cockpit", "mcp", "api", discover::HOME] {
170 let r = route(id);
171 assert_eq!(r.availability, Availability::ServedOnly, "{id}");
172 assert!(
173 !r.linkable(),
174 "{id} would be linked from a page with no server"
175 );
176 }
177 }
178
179 #[test]
180 fn every_route_says_what_it_is_in_the_words_of_whatever_produced_it() {
181 for r in projection_routes() {
182 assert!(!r.what.trim().is_empty(), "{} has no description", r.path);
183 assert!(r.path.starts_with('/'), "{} is not a mount", r.path);
184 }
185 }
186
187 #[test]
188 fn the_order_is_the_same_on_every_run() {
189 assert_eq!(projection_routes(), projection_routes());
190 let paths: Vec<String> = projection_routes().into_iter().map(|r| r.path).collect();
191 assert_eq!(paths.first().map(String::as_str), Some("/"));
192 }
193
194 #[test]
195 fn the_one_native_route_a_publication_carries_may_be_linked() {
196 // scripts/site-build copies the committed document to site/static/openapi.json and
197 // site-check refuses a publication without it, so this mount is answered by both
198 let openapi = route("openapi");
199 assert_eq!(openapi.availability, Availability::Both);
200 assert!(openapi.linkable());
201 assert!(
202 projection_routes()
203 .iter()
204 .filter(|r| r.linkable())
205 .all(|r| r.id == "openapi"),
206 "the document is the only native route a publication carries"
207 );
208 }
209}