Skip to main content

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}