Skip to main content

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}