majordomus_cli/web/model.rs
1//! The typed model of a web surface: what it is, where it is mounted, what produced it,
2//! and where every value came from.
3//!
4//! One vocabulary serves every consumer — the router, the static composition, the
5//! validator, the listing and the generated manifest — so a surface is described once and
6//! read many times. The kinds are behavioural rather than nominal: a test report and a
7//! benchmark report are both [`SurfaceKind::StaticDirectory`] and differ only in data, and
8//! nothing in this module knows the name of any surface this repository happens to have.
9
10use std::collections::BTreeMap;
11use std::fmt;
12use std::path::PathBuf;
13
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17/// How a surface answers a request.
18///
19/// Only what this repository serves: a directory of generated files, a path the executable
20/// answers itself, and a redirect. A new *kind* is a new behaviour, never a new name for
21/// the same behaviour with different data.
22#[derive(
23 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
24)]
25#[serde(rename_all = "kebab-case")]
26pub enum SurfaceKind {
27 /// A generated directory, mounted under its prefix and served from disk.
28 StaticDirectory,
29 /// A path the executable answers itself, from its own registry or handlers.
30 NativeRoute,
31 /// A path that answers with a redirect to another one.
32 Redirect,
33}
34
35impl fmt::Display for SurfaceKind {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 f.write_str(match self {
38 SurfaceKind::StaticDirectory => "static",
39 SurfaceKind::NativeRoute => "native",
40 SurfaceKind::Redirect => "redirect",
41 })
42 }
43}
44
45/// What a surface is for, which is how a reader is shown it.
46///
47/// A category is the one piece of intent that a mount cannot carry: `/openapi.json` and
48/// `/swagger` sit beside each other and are a document and a viewer for it. Grouping is
49/// derived from this field and never from a list of paths kept somewhere else.
50#[derive(
51 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
52)]
53#[serde(rename_all = "kebab-case")]
54// the schema component is named for what it categorises: `Category` alone already names
55// the scope's classes, and two components of one name is a document that cannot be built
56#[schemars(rename = "SurfaceCategory")]
57pub enum Category {
58 /// Something a person opens and looks at: the home page, the Cockpit.
59 Interface,
60 /// Prose and reference written for a person: the site, the Swagger UI.
61 Documentation,
62 /// A machine-readable surface of the capability registry.
63 Api,
64 /// A wire protocol another program speaks.
65 Protocol,
66 /// Generated evidence of a run: a test report, a benchmark report.
67 Report,
68}
69
70impl Category {
71 /// Every category, in the order a listing shows them.
72 pub const ALL: [Category; 5] = [
73 Category::Interface,
74 Category::Documentation,
75 Category::Api,
76 Category::Protocol,
77 Category::Report,
78 ];
79
80 /// The heading a person reads.
81 ///
82 /// ```
83 /// use majordomus_cli::web::model::Category;
84 /// assert_eq!(Category::Api.title(), "API");
85 /// ```
86 pub fn title(self) -> &'static str {
87 match self {
88 Category::Interface => "Interfaces",
89 Category::Documentation => "Documentation",
90 Category::Api => "API",
91 Category::Protocol => "Protocols",
92 Category::Report => "Reports",
93 }
94 }
95}
96
97impl fmt::Display for Category {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.write_str(match self {
100 Category::Interface => "interface",
101 Category::Documentation => "documentation",
102 Category::Api => "api",
103 Category::Protocol => "protocol",
104 Category::Report => "report",
105 })
106 }
107}
108
109/// Who a surface is offered to.
110///
111/// Two values and no more: a surface is either offered for a person to discover, or it is
112/// part of the topology without being advertised. Both are always in the machine-readable
113/// answer — hiding a served route from introspection would only hide it from the people
114/// maintaining it.
115#[derive(
116 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
117)]
118#[serde(rename_all = "kebab-case")]
119// A surface's visibility and a capability's are different vocabularies — that one has a
120// third value, `developer`. The schema component namespace is flat, so each says which of
121// the two it is (see `crate::capability::model::Visibility`).
122#[schemars(rename = "SurfaceVisibility")]
123pub enum Visibility {
124 /// Listed for a person: it appears on the home page.
125 Public,
126 /// Served and introspectable, not advertised: a machine speaks to it, or another
127 /// surface links to it.
128 Internal,
129}
130
131impl fmt::Display for Visibility {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.write_str(match self {
134 Visibility::Public => "public",
135 Visibility::Internal => "internal",
136 })
137 }
138}
139
140/// A capability of the running process a surface needs in order to exist.
141///
142/// The registry describes the effective process, not the maximum one: a build or an
143/// invocation that answers no MCP has no MCP surface, and the home page cannot link to
144/// one. Stating the dependency as data is what keeps that automatic.
145#[derive(
146 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
147)]
148#[serde(rename_all = "kebab-case")]
149pub enum Feature {
150 /// This process answers MCP over HTTP.
151 Mcp,
152 /// This process serves the Cockpit.
153 Cockpit,
154 /// This process serves the UI: `majordomus ui`, the primary surface (ADR 0135).
155 Ui,
156}
157
158impl fmt::Display for Feature {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.write_str(match self {
161 Feature::Mcp => "mcp",
162 Feature::Cockpit => "cockpit",
163 Feature::Ui => "ui",
164 })
165 }
166}
167
168/// Where a resolved value came from.
169///
170/// Kept for every field a consumer can be surprised by, so that `web explain` can answer
171/// "why is this mounted here?" without anybody reading the discovery code.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
173#[serde(tag = "source", rename_all = "kebab-case")]
174pub enum Provenance {
175 /// Read from the capability registry: the routes the executable already declares once.
176 Registry,
177 /// Read from a producer's own declaration beside its output (`surface.json`).
178 ProducerDeclaration {
179 /// The declaration file, repository-relative.
180 path: String,
181 },
182 /// Inferred from a file or directory being where the convention says it is.
183 Filesystem {
184 /// What was found, repository-relative.
185 path: String,
186 },
187 /// Inferred from the site generator's own configuration.
188 SiteConfig {
189 /// The configuration file, repository-relative.
190 path: String,
191 },
192 /// The model's documented default for a value nobody stated.
193 Default,
194}
195
196impl fmt::Display for Provenance {
197 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198 match self {
199 Provenance::Registry => f.write_str("capability registry"),
200 Provenance::ProducerDeclaration { path } => write!(f, "producer declaration {path}"),
201 Provenance::Filesystem { path } => write!(f, "filesystem {path}"),
202 Provenance::SiteConfig { path } => write!(f, "site config {path}"),
203 Provenance::Default => f.write_str("default"),
204 }
205 }
206}
207
208/// A normalised mount path: absolute, without a trailing slash, without `.` or `..`.
209///
210/// Normalisation is idempotent and total — an unacceptable path is refused rather than
211/// repaired, because a mount is intent and a guess at intent is how a surface ends up
212/// somewhere nobody meant.
213///
214/// ```
215/// use majordomus_cli::web::model::Mount;
216/// assert_eq!(Mount::parse("/tests/").unwrap().as_str(), "/tests");
217/// assert_eq!(Mount::parse("/").unwrap().as_str(), "/");
218/// assert_eq!(Mount::parse("tests").unwrap().as_str(), "/tests");
219/// assert!(Mount::parse("/a/../b").is_err());
220/// assert!(Mount::parse("//a").is_err());
221/// ```
222#[derive(
223 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
224)]
225#[serde(try_from = "String", into = "String")]
226#[schemars(
227 with = "String",
228 description = "An absolute mount path, without a trailing slash."
229)]
230pub struct Mount(String);
231
232impl Mount {
233 /// The root mount, which owns everything no other surface owns.
234 pub fn root() -> Self {
235 Mount("/".into())
236 }
237
238 /// Parse and normalise a mount path, or say why it is not one.
239 ///
240 /// ```
241 /// use majordomus_cli::web::model::Mount;
242 /// // normalisation is idempotent
243 /// let once = Mount::parse("/benchmarks/").unwrap();
244 /// let twice = Mount::parse(once.as_str()).unwrap();
245 /// assert_eq!(once, twice);
246 /// ```
247 pub fn parse(raw: &str) -> Result<Self, String> {
248 let trimmed = raw.trim();
249 if trimmed.is_empty() {
250 return Err("a mount path is empty".into());
251 }
252 if trimmed.contains(char::is_whitespace) {
253 return Err(format!("mount '{raw}' contains whitespace"));
254 }
255 if trimmed.contains('\\') {
256 return Err(format!(
257 "mount '{raw}' contains a backslash; paths are '/'-separated"
258 ));
259 }
260 let with_slash = if trimmed.starts_with('/') {
261 trimmed.to_string()
262 } else {
263 format!("/{trimmed}")
264 };
265 let body = with_slash.trim_end_matches('/');
266 if body.is_empty() {
267 return Ok(Mount::root());
268 }
269 for segment in body.split('/').skip(1) {
270 if segment.is_empty() {
271 return Err(format!("mount '{raw}' has an empty segment"));
272 }
273 if segment == "." || segment == ".." {
274 return Err(format!("mount '{raw}' walks the path with '{segment}'"));
275 }
276 if segment.contains('?') || segment.contains('#') {
277 return Err(format!("mount '{raw}' carries a query or a fragment"));
278 }
279 }
280 Ok(Mount(body.to_string()))
281 }
282
283 /// The mount as it is written in a route table.
284 pub fn as_str(&self) -> &str {
285 &self.0
286 }
287
288 /// Is this the root mount, the one surface allowed to answer what nothing else claims?
289 pub fn is_root(&self) -> bool {
290 self.0 == "/"
291 }
292
293 /// The prefix a request path must carry to belong to this mount, with its trailing slash.
294 ///
295 /// ```
296 /// use majordomus_cli::web::model::Mount;
297 /// assert_eq!(Mount::parse("/tests").unwrap().prefix(), "/tests/");
298 /// assert_eq!(Mount::root().prefix(), "/");
299 /// ```
300 pub fn prefix(&self) -> String {
301 if self.is_root() {
302 "/".into()
303 } else {
304 format!("{}/", self.0)
305 }
306 }
307
308 /// Does this mount own `path` — the mount itself, or anything below it?
309 ///
310 /// ```
311 /// use majordomus_cli::web::model::Mount;
312 /// let m = Mount::parse("/tests").unwrap();
313 /// assert!(m.owns("/tests"));
314 /// assert!(m.owns("/tests/"));
315 /// assert!(m.owns("/tests/coverage/index.html"));
316 /// assert!(!m.owns("/tests-of-something"));
317 /// assert!(Mount::root().owns("/anything"));
318 /// ```
319 pub fn owns(&self, path: &str) -> bool {
320 if self.is_root() {
321 return path.starts_with('/');
322 }
323 path == self.0 || path.starts_with(&self.prefix())
324 }
325
326 /// Is `other` inside this mount? The root contains everything, itself included.
327 ///
328 /// ```
329 /// use majordomus_cli::web::model::Mount;
330 /// let outer = Mount::parse("/tests").unwrap();
331 /// let inner = Mount::parse("/tests/coverage").unwrap();
332 /// assert!(outer.contains(&inner));
333 /// assert!(!inner.contains(&outer));
334 /// ```
335 pub fn contains(&self, other: &Mount) -> bool {
336 if self.is_root() {
337 return true;
338 }
339 other.0 == self.0 || other.0.starts_with(&self.prefix())
340 }
341
342 /// The path under a destination root this mount composes into, relative and without a
343 /// leading slash: the root mount composes into the destination itself.
344 ///
345 /// ```
346 /// use majordomus_cli::web::model::Mount;
347 /// assert_eq!(Mount::parse("/tests").unwrap().relative(), "tests");
348 /// assert_eq!(Mount::root().relative(), "");
349 /// ```
350 pub fn relative(&self) -> String {
351 self.0.trim_start_matches('/').to_string()
352 }
353}
354
355impl TryFrom<String> for Mount {
356 type Error = String;
357 fn try_from(value: String) -> Result<Self, Self::Error> {
358 Mount::parse(&value)
359 }
360}
361
362impl From<Mount> for String {
363 fn from(value: Mount) -> Self {
364 value.0
365 }
366}
367
368impl fmt::Display for Mount {
369 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370 f.write_str(&self.0)
371 }
372}
373
374/// Whether a surface is part of the static publication, served only while a process runs,
375/// or both.
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
377#[serde(rename_all = "kebab-case")]
378#[schemars(rename = "SurfaceAvailability")]
379pub enum Availability {
380 /// Served by the running executable and published as files.
381 Both,
382 /// Answered by the running executable and never published: a route the process
383 /// computes, or a build made for this server's own mount rather than for deployment.
384 ServedOnly,
385 /// Published as files; the running executable serves it from the same directory.
386 PublishedOnly,
387}
388
389impl Availability {
390 /// Does this surface contribute files to a static publication?
391 pub fn is_published(&self) -> bool {
392 matches!(self, Availability::Both | Availability::PublishedOnly)
393 }
394
395 /// Does the running server answer for this surface?
396 pub fn is_served(&self) -> bool {
397 matches!(self, Availability::Both | Availability::ServedOnly)
398 }
399}
400
401/// A surface is presented by its id: the composition writes its artifact under that name,
402/// the topology reports it under that name, and the title is prose.
403impl crate::order::Ordered for Surface {
404 fn order_key(&self) -> crate::order::OrderKey<'_> {
405 crate::order::OrderKey::plain(&self.id, &self.id)
406 }
407}
408
409/// One resolved surface: everything a consumer needs, with the provenance of what it could
410/// be surprised by.
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
412pub struct Surface {
413 /// Identity, unique across the topology; the selector `--only` and `--exclude` use it.
414 pub id: String,
415 /// One line: what a reader sees in a listing.
416 pub title: String,
417 /// What it is for, which is how a listing groups it.
418 pub category: Category,
419 /// Whether a person is shown it.
420 pub visibility: Visibility,
421 /// How it answers.
422 pub kind: SurfaceKind,
423 /// Where it answers.
424 pub mount: Mount,
425 /// What produced it: a command, a module, or the generator that writes the directory.
426 pub producer: String,
427 /// The runtime capability it needs; absent when the process always has it.
428 #[serde(skip_serializing_if = "Option::is_none")]
429 pub feature: Option<Feature>,
430 /// The generated directory, repository-relative, for a static surface.
431 #[serde(skip_serializing_if = "Option::is_none")]
432 pub artifact: Option<PathBuf>,
433 /// The file served for the mount itself, when the surface has one.
434 #[serde(skip_serializing_if = "Option::is_none")]
435 pub index: Option<String>,
436 /// Where the surface's files go and who answers for it.
437 pub availability: Availability,
438 /// The revision the artifact was built from, when its producer recorded one: what
439 /// makes a stale build a finding rather than a surprise.
440 #[serde(skip_serializing_if = "Option::is_none")]
441 pub built_from: Option<String>,
442 /// Where each interesting value came from, by field name.
443 pub provenance: BTreeMap<String, Provenance>,
444}
445
446impl Surface {
447 /// Does this surface put files into a publication?
448 pub fn publishes(&self) -> bool {
449 self.availability.is_published() && self.artifact.is_some()
450 }
451
452 /// Does a process with these runtime capabilities answer for this surface?
453 ///
454 /// ```
455 /// use majordomus_cli::web::discover::Runtime;
456 /// use majordomus_cli::web::model::Feature;
457 /// # use majordomus_cli::web::model::*;
458 /// # use std::collections::BTreeMap;
459 /// let mut s = Surface { id: "mcp".into(), title: "MCP".into(), category: Category::Protocol,
460 /// visibility: Visibility::Internal, kind: SurfaceKind::NativeRoute,
461 /// mount: Mount::parse("/mcp").unwrap(), producer: "t".into(), feature: Some(Feature::Mcp),
462 /// artifact: None, index: None, availability: Availability::ServedOnly,
463 /// built_from: None, provenance: BTreeMap::new() };
464 /// assert!(s.served_by(Runtime::full()));
465 /// assert!(!s.served_by(Runtime::default()));
466 /// s.feature = None;
467 /// assert!(s.served_by(Runtime::default()));
468 /// ```
469 pub fn served_by(&self, runtime: crate::web::discover::Runtime) -> bool {
470 self.availability.is_served() && self.feature.is_none_or(|f| runtime.has(f))
471 }
472}
473
474/// Every surface this repository exposes, resolved together.
475///
476/// The order is by mount, most specific first, then by id: the order a router must consult
477/// them in, computed rather than left to whoever inserted a route last.
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
479pub struct Topology {
480 /// The resolved surfaces, in route-precedence order.
481 pub surfaces: Vec<Surface>,
482}
483
484impl Topology {
485 /// Sort surfaces into route-precedence order and hold them.
486 ///
487 /// Precedence is depth first — a longer mount wins over a shorter one, so the root
488 /// application is consulted last — and identity breaks ties, so the order never depends
489 /// on discovery order, filesystem order or a hash map.
490 ///
491 /// ```
492 /// use majordomus_cli::web::model::*;
493 /// # use std::collections::BTreeMap;
494 /// # fn s(id: &str, mount: &str) -> Surface {
495 /// # Surface { id: id.into(), title: id.into(), category: Category::Report,
496 /// # visibility: Visibility::Public, kind: SurfaceKind::StaticDirectory,
497 /// # mount: Mount::parse(mount).unwrap(), producer: "t".into(), feature: None,
498 /// # artifact: None, index: None, availability: Availability::Both,
499 /// # built_from: None, provenance: BTreeMap::new() }
500 /// # }
501 /// let t = Topology::new(vec![s("app", "/"), s("tests", "/tests")]);
502 /// assert_eq!(t.surfaces[0].id, "tests"); // the specific one is consulted first
503 /// assert_eq!(t.surfaces[1].id, "app");
504 /// // the root is last whatever it is compared with: `owner()` rests on this, and the
505 /// // root's own slash must never be counted as depth
506 /// let deep = Topology::new(vec![s("api", "/api/v1"), s("root", "/"), s("docs", "/docs")]);
507 /// assert_eq!(deep.ids(), vec!["api", "docs", "root"]);
508 /// ```
509 pub fn new(mut surfaces: Vec<Surface>) -> Self {
510 surfaces.sort_by(|a, b| {
511 let depth = |m: &Mount| m.as_str().matches('/').count() + usize::from(!m.is_root());
512 depth(&b.mount)
513 .cmp(&depth(&a.mount))
514 .then_with(|| a.mount.cmp(&b.mount))
515 .then_with(|| a.id.cmp(&b.id))
516 });
517 Topology { surfaces }
518 }
519
520 /// The surface that owns a request path, or none when nothing does.
521 ///
522 /// ```
523 /// use majordomus_cli::web::model::*;
524 /// # use std::collections::BTreeMap;
525 /// # fn s(id: &str, mount: &str) -> Surface {
526 /// # Surface { id: id.into(), title: id.into(), category: Category::Report,
527 /// # visibility: Visibility::Public, kind: SurfaceKind::StaticDirectory,
528 /// # mount: Mount::parse(mount).unwrap(), producer: "t".into(), feature: None,
529 /// # artifact: None, index: None, availability: Availability::Both,
530 /// # built_from: None, provenance: BTreeMap::new() }
531 /// # }
532 /// let t = Topology::new(vec![s("app", "/"), s("tests", "/tests")]);
533 /// assert_eq!(t.owner("/tests/index.html").unwrap().id, "tests");
534 /// assert_eq!(t.owner("/anything-else").unwrap().id, "app");
535 /// ```
536 pub fn owner(&self, path: &str) -> Option<&Surface> {
537 self.surfaces.iter().find(|s| s.mount.owns(path))
538 }
539
540 /// One surface by id.
541 pub fn get(&self, id: &str) -> Option<&Surface> {
542 self.surfaces.iter().find(|s| s.id == id)
543 }
544
545 /// The topology narrowed to a selection: `only` when it is not empty, minus `exclude`.
546 ///
547 /// Selection is by discovered id and nothing else, so a surface that did not exist when
548 /// this code was written is selectable the day it is discovered.
549 pub fn select(&self, only: &[String], exclude: &[String]) -> Topology {
550 Topology::new(
551 self.surfaces
552 .iter()
553 .filter(|s| only.is_empty() || only.iter().any(|o| o == &s.id))
554 .filter(|s| !exclude.iter().any(|e| e == &s.id))
555 .cloned()
556 .collect(),
557 )
558 }
559
560 /// The ids this topology holds, in precedence order.
561 pub fn ids(&self) -> Vec<&str> {
562 self.surfaces.iter().map(|s| s.id.as_str()).collect()
563 }
564
565 /// The topology as a process with these runtime capabilities serves it.
566 ///
567 /// One resolution, two worlds: a surface that is only published (the site as it is
568 /// deployed) is not served, and a surface whose feature this process does not have is
569 /// not there at all. Both narrowings are filters over the same value, so nothing can
570 /// be served that was not discovered.
571 ///
572 /// ```
573 /// use majordomus_cli::web::discover::{self, Runtime};
574 /// let all = discover::native(Runtime::full());
575 /// let none = majordomus_cli::web::Topology::new(all).served(Runtime::default());
576 /// assert!(!none.ids().contains(&"mcp"));
577 /// ```
578 pub fn served(&self, runtime: crate::web::discover::Runtime) -> Topology {
579 Topology::new(
580 self.surfaces
581 .iter()
582 .filter(|s| s.served_by(runtime))
583 .cloned()
584 .collect(),
585 )
586 }
587
588 /// The topology as a publication holds it: every surface that contributes files.
589 pub fn published(&self) -> Topology {
590 Topology::new(
591 self.surfaces
592 .iter()
593 .filter(|s| s.publishes())
594 .cloned()
595 .collect(),
596 )
597 }
598
599 /// The public surfaces of one category, in precedence order: what a person is shown.
600 pub fn public_in(&self, category: Category) -> Vec<&Surface> {
601 self.surfaces
602 .iter()
603 .filter(|s| s.visibility == Visibility::Public && s.category == category)
604 .collect()
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 fn surface(id: &str, mount: &str, category: Category, visibility: Visibility) -> Surface {
613 Surface {
614 id: id.into(),
615 title: format!("the {id}"),
616 category,
617 visibility,
618 kind: SurfaceKind::NativeRoute,
619 mount: Mount::parse(mount).unwrap(),
620 producer: "test".into(),
621 feature: None,
622 artifact: None,
623 index: None,
624 availability: Availability::ServedOnly,
625 built_from: None,
626 provenance: BTreeMap::new(),
627 }
628 }
629
630 #[test]
631 fn every_vocabulary_word_has_exactly_one_spelling() {
632 // the words reach a listing, a JSON document and a page; a variant whose Display
633 // and whose serialisation disagree is a value that means two things
634 for (value, word) in [
635 (SurfaceKind::StaticDirectory, "static"),
636 (SurfaceKind::NativeRoute, "native"),
637 (SurfaceKind::Redirect, "redirect"),
638 ] {
639 assert_eq!(value.to_string(), word);
640 }
641 for category in Category::ALL {
642 assert_eq!(
643 serde_json::to_value(category).unwrap(),
644 serde_json::Value::String(category.to_string()),
645 "{category}"
646 );
647 assert!(!category.title().is_empty());
648 }
649 assert_eq!(Visibility::Public.to_string(), "public");
650 assert_eq!(Visibility::Internal.to_string(), "internal");
651 assert_eq!(Feature::Mcp.to_string(), "mcp");
652 assert_eq!(Feature::Cockpit.to_string(), "cockpit");
653 assert_eq!(Feature::Ui.to_string(), "ui");
654 }
655
656 #[test]
657 fn provenance_says_where_a_value_came_from_in_words() {
658 assert_eq!(Provenance::Registry.to_string(), "capability registry");
659 assert_eq!(Provenance::Default.to_string(), "default");
660 assert_eq!(
661 Provenance::ProducerDeclaration {
662 path: "target/web/x/surface.json".into()
663 }
664 .to_string(),
665 "producer declaration target/web/x/surface.json"
666 );
667 assert_eq!(
668 Provenance::Filesystem {
669 path: "target/web/x".into()
670 }
671 .to_string(),
672 "filesystem target/web/x"
673 );
674 assert_eq!(
675 Provenance::SiteConfig {
676 path: "site/config.toml".into()
677 }
678 .to_string(),
679 "site config site/config.toml"
680 );
681 }
682
683 #[test]
684 fn a_mount_round_trips_through_its_serialised_form() {
685 let mount = Mount::parse("/docs").unwrap();
686 let text = serde_json::to_string(&mount).unwrap();
687 assert_eq!(text, "\"/docs\"");
688 assert_eq!(serde_json::from_str::<Mount>(&text).unwrap(), mount);
689 assert!(serde_json::from_str::<Mount>("\"/a/../b\"").is_err());
690 assert_eq!(String::from(mount.clone()), "/docs");
691 assert_eq!(mount.to_string(), "/docs");
692 assert_eq!(Mount::try_from("/docs/".to_string()).unwrap(), mount);
693 }
694
695 #[test]
696 fn availability_is_the_two_worlds_and_nothing_else() {
697 assert!(Availability::Both.is_served() && Availability::Both.is_published());
698 assert!(Availability::ServedOnly.is_served() && !Availability::ServedOnly.is_published());
699 assert!(
700 !Availability::PublishedOnly.is_served() && Availability::PublishedOnly.is_published()
701 );
702 }
703
704 #[test]
705 fn a_selection_narrows_by_id_and_leaves_the_order_alone() {
706 let topology = Topology::new(vec![
707 surface("home", "/", Category::Interface, Visibility::Public),
708 surface("api", "/api/v1", Category::Api, Visibility::Public),
709 surface("mcp", "/mcp", Category::Protocol, Visibility::Internal),
710 ]);
711 assert_eq!(topology.ids(), vec!["api", "mcp", "home"]);
712 assert_eq!(topology.get("mcp").map(|s| s.id.as_str()), Some("mcp"));
713 assert!(topology.get("nothing").is_none());
714
715 let only = topology.select(&["api".to_string()], &[]);
716 assert_eq!(only.ids(), vec!["api"]);
717 let without = topology.select(&[], &["api".to_string()]);
718 assert_eq!(without.ids(), vec!["mcp", "home"]);
719 // an empty selector is every surface, not none
720 assert_eq!(topology.select(&[], &[]).ids(), topology.ids());
721 }
722
723 #[test]
724 fn a_person_is_shown_the_public_surfaces_of_a_category_and_no_others() {
725 let topology = Topology::new(vec![
726 surface("home", "/", Category::Interface, Visibility::Public),
727 surface("api", "/api/v1", Category::Api, Visibility::Public),
728 surface("mcp", "/mcp", Category::Protocol, Visibility::Internal),
729 ]);
730 assert_eq!(
731 topology
732 .public_in(Category::Interface)
733 .iter()
734 .map(|s| s.id.as_str())
735 .collect::<Vec<_>>(),
736 vec!["home"]
737 );
738 assert!(
739 topology.public_in(Category::Protocol).is_empty(),
740 "an internal surface is served and not advertised"
741 );
742 assert!(topology.public_in(Category::Report).is_empty());
743 }
744
745 #[test]
746 fn the_published_world_holds_only_what_contributes_files() {
747 let mut app = surface("app", "/", Category::Documentation, Visibility::Public);
748 app.kind = SurfaceKind::StaticDirectory;
749 app.availability = Availability::PublishedOnly;
750 app.artifact = Some(PathBuf::from("site/public"));
751 let topology = Topology::new(vec![
752 app,
753 surface("home", "/", Category::Interface, Visibility::Public),
754 ]);
755 assert_eq!(topology.published().ids(), vec!["app"]);
756 assert_eq!(
757 topology.served(crate::web::discover::Runtime::full()).ids(),
758 vec!["home"]
759 );
760 // a static surface that names no directory publishes nothing, whatever it says
761 let mut nameless = topology.get("app").unwrap().clone();
762 nameless.artifact = None;
763 assert!(!nameless.publishes());
764 }
765}