1use std::collections::BTreeMap;
15use std::path::Path;
16use std::sync::Arc;
17
18use crate::error::{Error, Result};
19use crate::web::discover::{self, Runtime};
20use crate::web::files::Files;
21use crate::web::model::{Surface, SurfaceKind, Topology};
22use crate::web::validate::{self, Artifacts};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Native {
31 Home,
33 Api,
35 OpenApi,
37 Swagger,
39 Mcp,
41 Cockpit,
43 Ui,
45 Events,
47}
48
49impl Native {
50 pub fn of(id: &str) -> Option<Native> {
52 Some(match id {
53 discover::HOME => Native::Home,
54 "api" => Native::Api,
55 "openapi" => Native::OpenApi,
56 "swagger" => Native::Swagger,
57 "mcp" => Native::Mcp,
58 "cockpit" => Native::Cockpit,
59 crate::ui::SURFACE => Native::Ui,
60 "events" => Native::Events,
61 _ => return None,
62 })
63 }
64}
65
66#[derive(Debug)]
68pub enum Bound {
69 Route(Native),
71 Directory(Box<Files>),
74}
75
76#[derive(Debug)]
78pub struct Served {
79 topology: Topology,
80 bound: BTreeMap<String, Bound>,
81}
82
83impl Served {
84 pub fn resolve(topology: &Topology, root: &Path, runtime: Runtime) -> Result<Self> {
91 let topology = topology.served(runtime);
92 let findings = validate::validate(&topology, root, Artifacts::Ignore);
93 if validate::blocking(&findings) {
94 let reasons: Vec<String> = findings
95 .iter()
96 .filter(|f| f.severity == validate::Severity::Error)
97 .map(|f| format!("{} ({}): {}", f.rule, f.surface, f.message))
98 .collect();
99 return Err(Error::InvalidSurface {
100 surface: "topology".into(),
101 reason: format!(
102 "this process cannot serve the surfaces it discovered: {}; `majordomus web validate` reports the same findings with their remedies",
103 reasons.join("; ")
104 ),
105 });
106 }
107 let mut bound = BTreeMap::new();
108 for surface in &topology.surfaces {
109 let handler = match surface.kind {
110 SurfaceKind::StaticDirectory => {
111 Bound::Directory(Box::new(Files::new(surface, root)))
112 }
113 SurfaceKind::NativeRoute => {
114 let Some(native) = Native::of(&surface.id) else {
115 return Err(Error::InvalidSurface {
116 surface: surface.id.clone(),
117 reason: format!(
118 "is declared as a route this executable answers and no handler is bound to it; add an arm to http::surfaces::Native::of, or declare {} as a generated directory",
119 surface.id
120 ),
121 });
122 };
123 Bound::Route(native)
124 }
125 SurfaceKind::Redirect => {
126 return Err(Error::InvalidSurface {
127 surface: surface.id.clone(),
128 reason: "a redirect surface is declared and this executable serves none"
129 .into(),
130 })
131 }
132 };
133 bound.insert(surface.id.clone(), handler);
134 }
135 Ok(Served { topology, bound })
136 }
137
138 pub fn topology(&self) -> &Topology {
140 &self.topology
141 }
142
143 pub fn shared(&self) -> Arc<Topology> {
145 Arc::new(self.topology.clone())
146 }
147
148 pub fn owner(&self, path: &str) -> Option<(&Surface, &Bound)> {
150 let surface = self.topology.owner(path)?;
151 let bound = self.bound.get(&surface.id)?;
152 Some((surface, bound))
153 }
154
155 pub fn ready(&self, id: &str) -> bool {
157 match self.bound.get(id) {
158 Some(Bound::Directory(files)) => files.available(),
159 Some(Bound::Route(_)) => true,
160 None => false,
161 }
162 }
163
164 pub fn summary(&self, base: &str) -> String {
186 let mut parts: Vec<String> = self
187 .topology
188 .surfaces
189 .iter()
190 .map(|s| {
191 let mount = s.mount.as_str();
192 let path = if s.mount.is_root() { "/" } else { mount };
193 format!("{} {base}{path}", s.id)
194 })
195 .collect();
196 crate::order::canonical(&mut parts);
197 format!(
198 "{} surface(s): {}",
199 self.topology.surfaces.len(),
200 parts.join(", ")
201 )
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn every_native_surface_has_a_handler() {
211 for surface in discover::native_all() {
212 assert!(
213 Native::of(&surface.id).is_some(),
214 "the native surface '{}' is declared and nothing answers it",
215 surface.id
216 );
217 }
218 }
219
220 #[test]
221 fn a_native_surface_with_no_handler_refuses_the_router() {
222 let mut surfaces = discover::native_all();
223 let cockpit = surfaces
226 .iter_mut()
227 .find(|s| s.id == "cockpit")
228 .expect("the cockpit is declared");
229 cockpit.id = "invented".into();
230 let topology = Topology::new(surfaces);
231 let err = Served::resolve(&topology, Path::new("/nonexistent"), Runtime::full())
232 .expect_err("a surface nothing answers is refused")
233 .to_string();
234 assert!(err.contains("invented"), "{err}");
235 assert!(err.contains("Native::of"), "{err}");
236 }
237
238 #[test]
239 fn a_collision_refuses_the_router_and_names_both_surfaces() {
240 let mut surfaces = discover::native_all();
241 let mut clone = surfaces
242 .iter()
243 .find(|s| s.id == "cockpit")
244 .expect("the cockpit is declared")
245 .clone();
246 clone.id = "second".into();
247 surfaces.push(clone);
248 let topology = Topology::new(surfaces);
249 let err = Served::resolve(&topology, Path::new("/nonexistent"), Runtime::full())
250 .expect_err("two surfaces on one mount are refused")
251 .to_string();
252 assert!(err.contains("mount-collision"), "{err}");
253 assert!(err.contains("second"), "{err}");
254 }
255
256 #[test]
257 fn the_reserved_mounts_belong_to_the_surfaces_that_own_them() {
258 let topology = Topology::new(discover::native_all());
259 let served = Served::resolve(&topology, Path::new("/nonexistent"), Runtime::full())
260 .expect("the declared topology is servable");
261 assert_eq!(served.owner("/swagger").unwrap().0.id, "swagger");
262 assert_eq!(served.owner("/openapi.json").unwrap().0.id, "openapi");
263 assert_eq!(served.owner("/api/v1/health").unwrap().0.id, "api");
264 assert_eq!(served.owner("/mcp").unwrap().0.id, "mcp");
265 assert_eq!(served.owner("/cockpit/graphs").unwrap().0.id, "cockpit");
266 assert_eq!(served.owner("/").unwrap().0.id, discover::HOME);
268 assert_eq!(served.owner("/whatever").unwrap().0.id, discover::HOME);
269 }
270
271 fn with_a_site() -> tempfile::TempDir {
273 let tmp = tempfile::tempdir().expect("tempdir");
274 std::fs::create_dir_all(tmp.path().join("site")).expect("mkdir");
275 std::fs::write(tmp.path().join(discover::SITE_CONFIG), "base_url = \"/\"\n")
276 .expect("write");
277 tmp
278 }
279
280 #[test]
281 fn a_documentation_surface_does_not_swallow_the_routes_beside_it() {
282 let tmp = with_a_site();
283 let mut surfaces = discover::native_all();
284 surfaces.extend(discover::application(tmp.path()));
285 let topology = Topology::new(surfaces);
286 let served = Served::resolve(&topology, tmp.path(), Runtime::full())
287 .expect("the topology is servable");
288 assert_eq!(served.owner("/docs/").unwrap().0.id, discover::DOCS);
289 assert_eq!(
290 served.owner("/docs/cli/index.html").unwrap().0.id,
291 discover::DOCS
292 );
293 for (path, id) in [
294 ("/swagger", "swagger"),
295 ("/openapi.json", "openapi"),
296 ("/api/v1/objects", "api"),
297 ("/mcp", "mcp"),
298 ("/cockpit", "cockpit"),
299 ] {
300 assert_eq!(served.owner(path).unwrap().0.id, id, "{path}");
301 }
302 assert!(!served.topology().ids().contains(&discover::APPLICATION));
304 }
305
306 #[test]
310 fn the_crates_rustdoc_is_bound_to_its_directory_beside_the_documentation() {
311 let tmp = with_a_site();
312 let krate = tmp.path().join(crate::capability::model::CRATE_DIR);
313 std::fs::create_dir_all(krate.join("src")).expect("mkdir");
314 std::fs::write(krate.join("Cargo.toml"), "").expect("write");
315 std::fs::write(krate.join("src/lib.rs"), "//! x\n").expect("write");
316
317 let unbuilt = discover::discover(tmp.path(), Runtime::full()).expect("discovers");
319 let served = Served::resolve(&unbuilt, tmp.path(), Runtime::full())
320 .expect("an unbuilt reference is servable");
321 assert!(!served.ready(discover::RUSTDOC));
322 let Some((surface, Bound::Directory(files))) = served.owner("/rustdoc/") else {
323 panic!("the rustdoc mount is bound to a directory");
324 };
325 assert_eq!(surface.id, discover::RUSTDOC);
326 let response = files.respond("/rustdoc/");
327 assert_eq!(response.status, 503);
328 assert!(
329 response.body.text().contains(discover::RUSTDOC_PRODUCER),
330 "{}",
331 response.body
332 );
333
334 let dir = tmp.path().join(discover::RUSTDOC_ARTIFACT);
335 for (file, body) in [
336 ("index.html", "<h1>landing</h1>"),
337 ("static.files/a.css", "body{}"),
338 ("static.files/b.js", "var b;"),
339 ("static.files/c.woff2", "wOF2"),
340 ("majordomus_cli/index.html", "<h1>crate</h1>"),
341 ("majordomus_cli/macro.m!.html", "<p>redirect</p>"),
342 ] {
343 let path = dir.join(file);
344 std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
345 std::fs::write(path, body).expect("write");
346 }
347 let built = discover::discover(tmp.path(), Runtime::full()).expect("discovers");
348 let served =
349 Served::resolve(&built, tmp.path(), Runtime::full()).expect("the reference serves");
350 assert!(served.ready(discover::RUSTDOC));
351 for (path, media) in [
352 ("/rustdoc/", "text/html; charset=utf-8"),
353 ("/rustdoc/static.files/a.css", "text/css; charset=utf-8"),
354 (
355 "/rustdoc/static.files/b.js",
356 "text/javascript; charset=utf-8",
357 ),
358 ("/rustdoc/static.files/c.woff2", "font/woff2"),
359 (
360 "/rustdoc/majordomus_cli/index.html",
361 "text/html; charset=utf-8",
362 ),
363 (
364 "/rustdoc/majordomus_cli/macro.m!.html",
365 "text/html; charset=utf-8",
366 ),
367 ] {
368 let Some((surface, Bound::Directory(files))) = served.owner(path) else {
369 panic!("{path} is not owned by a directory");
370 };
371 assert_eq!(surface.id, discover::RUSTDOC, "{path}");
372 let response = files.respond(path);
373 assert_eq!(response.status, 200, "{path}: {}", response.body);
374 assert_eq!(response.content_type, media, "{path}");
375 }
376 assert_eq!(served.owner("/docs/").unwrap().0.id, discover::DOCS);
378 assert!(served
379 .summary("http://x")
380 .contains("rustdoc http://x/rustdoc"));
381 }
382
383 #[test]
384 fn a_process_without_a_feature_serves_none_of_its_surfaces() {
385 let topology = Topology::new(discover::native_all());
386 let served = Served::resolve(&topology, Path::new("/nonexistent"), Runtime::default())
387 .expect("the topology is servable");
388 assert!(served.owner("/mcp").is_none_or(|(s, _)| s.id != "mcp"));
389 assert!(!served.topology().ids().contains(&"cockpit"));
390 assert!(served
391 .summary("http://x")
392 .contains("swagger http://x/swagger"));
393 }
394}