Skip to main content

majordomus_cli/web/
discover.rs

1//! Discovery: how the repository's web surfaces are found without anybody listing them.
2//!
3//! Four inference sources, none of them a register a person maintains:
4//!
5//! 1. the **executable's own routes**, from the constants that already declare them once —
6//!    the capability prefix, the OpenAPI document, the Swagger UI, MCP and the Cockpit;
7//! 2. the **generated web root**, `target/web/<id>/`, where a producer writes its output
8//!    and a `surface.json` beside it declaring the intent no directory walk can infer;
9//! 3. the **site configuration**, which says the Zola application exists and where it is
10//!    built, so the application is discovered rather than assumed;
11//! 4. the **crate**, whose presence says its rustdoc exists as a surface whether or not
12//!    anybody has run `cargo doc` in this checkout, so the crate's reference is discovered
13//!    from the source it documents rather than from the output of one machine.
14//!
15//! Everything a consumer can be surprised by carries its [`Provenance`], so `web explain`
16//! answers "why is this here?" from data rather than from this module's source.
17
18use std::collections::BTreeMap;
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22
23use super::model::{
24    Availability, Category, Feature, Mount, Provenance, Surface, SurfaceKind, Topology, Visibility,
25};
26use crate::capability::model::{HttpExposure, CRATE_DIR};
27use crate::cockpit;
28use crate::error::{Error, Result};
29use crate::http::{mcp, swagger};
30
31/// The generated root every static producer writes into, repository-relative.
32///
33/// One directory per surface, owned by its producer alone: two generators never write into
34/// one tree, so a rebuild of one cannot delete the other's output.
35pub const GENERATED_ROOT: &str = "target/web";
36
37/// The contract of a producer's declaration.
38pub const DECLARATION_SCHEMA: &str = "web-surface/v1";
39
40/// The file a producer writes beside its output to declare what it is.
41pub const DECLARATION_FILE: &str = "surface.json";
42
43/// What the running executable offers, which changes what it can answer for.
44///
45/// The static surfaces do not depend on it; the native ones do, because a process that
46/// serves no MCP endpoint must not advertise one.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct Runtime {
49    /// This process answers MCP over HTTP.
50    pub mcp: bool,
51    /// This process serves the Cockpit.
52    pub cockpit: bool,
53    /// This process serves the UI (ADR 0135).
54    pub ui: bool,
55}
56
57impl Runtime {
58    /// Everything the executable can offer: what `web list` describes when it is not the
59    /// server itself, so a listing shows the topology rather than one process's slice.
60    pub fn full() -> Self {
61        Runtime {
62            mcp: true,
63            cockpit: true,
64            ui: true,
65        }
66    }
67
68    /// Does this process have the capability a surface needs?
69    ///
70    /// ```
71    /// use majordomus_cli::web::discover::Runtime;
72    /// use majordomus_cli::web::model::Feature;
73    /// assert!(Runtime::full().has(Feature::Cockpit));
74    /// assert!(!Runtime::default().has(Feature::Cockpit));
75    /// ```
76    pub fn has(self, feature: Feature) -> bool {
77        match feature {
78            Feature::Mcp => self.mcp,
79            Feature::Cockpit => self.cockpit,
80            Feature::Ui => self.ui,
81        }
82    }
83}
84
85/// A producer's declaration: the intent that no walk of its output can infer.
86///
87/// `id` and `mount` are the intent. Everything else has a documented default, because a
88/// field a producer must fill in to say what the convention already says is a field that
89/// will disagree with reality one day.
90///
91/// ```
92/// use majordomus_cli::web::discover::Declaration;
93/// let d: Declaration = serde_json::from_str(r#"{
94///     "schema": "web-surface/v1", "id": "tests", "mount": "/tests"
95/// }"#).unwrap();
96/// assert_eq!(d.id, "tests");
97/// assert_eq!(d.index.as_deref(), None);
98/// ```
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct Declaration {
102    /// The contract this declaration follows.
103    pub schema: String,
104    /// The surface's identity, which is also its selector.
105    pub id: String,
106    /// Where the producer intends its output to be mounted.
107    pub mount: String,
108    /// One line for a listing; the id when absent.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub title: Option<String>,
111    /// The file the mount itself answers with; `index.html` when absent.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub index: Option<String>,
114    /// What produced the directory, for a reader who has to rebuild it.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub producer: Option<String>,
117    /// Whether the surface is published, served, or both; both when absent.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub availability: Option<Availability>,
120    /// What the surface is for; a generated directory is a report when it says nothing.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub category: Option<Category>,
123    /// Whether a person is shown it; public when absent, because a producer that went to
124    /// the trouble of declaring a mount meant it to be found.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub visibility: Option<Visibility>,
127    /// The revision the output was built from, when the producer knows it: what makes a
128    /// stale artifact a finding instead of a surprise.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub built_from: Option<String>,
131}
132
133/// The whole topology of a repository: the executable's routes, the application, the
134/// crate's rustdoc and the generated reports.
135///
136/// Discovery never fails on an absent producer — a report that has not been generated is
137/// simply not a surface yet, and [`super::validate`] is where a *required* artifact being
138/// missing becomes a finding.
139pub fn discover(root: &Path, runtime: Runtime) -> Result<Topology> {
140    let mut surfaces = native(runtime);
141    surfaces.extend(application(root));
142    surfaces.extend(rustdoc(root));
143    surfaces.extend(generated(root)?);
144    Ok(Topology::new(surfaces))
145}
146
147/// The routes the executable answers itself, from the constants that declare them.
148///
149/// This function names no path of its own: every mount below is the same constant the
150/// router, the OpenAPI document and the Cockpit already use, so a prefix that moves moves
151/// here too. It is the whole list — a process that does not offer a feature simply does
152/// not serve the surfaces that declare it, which [`Runtime`] decides and nothing here
153/// repeats.
154pub fn native_all() -> Vec<Surface> {
155    vec![
156        Surface {
157            id: HOME.into(),
158            title: "This process, and everything it serves".into(),
159            category: Category::Interface,
160            visibility: Visibility::Public,
161            kind: SurfaceKind::NativeRoute,
162            mount: Mount::root(),
163            producer: "web::home".into(),
164            feature: None,
165            artifact: None,
166            index: None,
167            availability: Availability::ServedOnly,
168            built_from: None,
169            provenance: provenance([("mount", Provenance::Default)]),
170        },
171        Surface {
172            id: "api".into(),
173            title: "The capability registry over HTTP".into(),
174            category: Category::Api,
175            visibility: Visibility::Public,
176            kind: SurfaceKind::NativeRoute,
177            mount: Mount::parse(HttpExposure::PREFIX.trim_end_matches('/'))
178                .expect("the capability prefix is a mount"),
179            producer: "capability registry".into(),
180            feature: None,
181            artifact: None,
182            index: None,
183            availability: Availability::ServedOnly,
184            built_from: None,
185            provenance: provenance([
186                ("mount", Provenance::Registry),
187                ("kind", Provenance::Registry),
188            ]),
189        },
190        Surface {
191            id: "openapi".into(),
192            title: "The OpenAPI document of the capability registry".into(),
193            category: Category::Api,
194            visibility: Visibility::Public,
195            kind: SurfaceKind::NativeRoute,
196            mount: Mount::parse(swagger::SPEC_PATH).expect("the OpenAPI path is a mount"),
197            producer: "capability registry".into(),
198            feature: None,
199            artifact: None,
200            index: None,
201            // the running server renders it per request and `scripts/site-build` copies the
202            // committed document into the publication at the same mount, so a published
203            // page may link it — the one native route of which that is true
204            availability: Availability::Both,
205            built_from: None,
206            provenance: provenance([("mount", Provenance::Registry)]),
207        },
208        Surface {
209            id: "swagger".into(),
210            title: "Swagger UI over the OpenAPI document".into(),
211            category: Category::Documentation,
212            visibility: Visibility::Public,
213            kind: SurfaceKind::NativeRoute,
214            mount: Mount::parse(swagger::SWAGGER_PATH).expect("the Swagger path is a mount"),
215            producer: "http::swagger".into(),
216            feature: None,
217            artifact: None,
218            index: None,
219            availability: Availability::ServedOnly,
220            built_from: None,
221            provenance: provenance([("mount", Provenance::Registry)]),
222        },
223        Surface {
224            id: "mcp".into(),
225            title: "MCP over HTTP for attached clients".into(),
226            category: Category::Protocol,
227            visibility: Visibility::Internal,
228            kind: SurfaceKind::NativeRoute,
229            mount: Mount::parse(mcp::PATH).expect("the MCP path is a mount"),
230            producer: "mcp endpoint".into(),
231            feature: Some(Feature::Mcp),
232            artifact: None,
233            index: None,
234            availability: Availability::ServedOnly,
235            built_from: None,
236            provenance: provenance([("mount", Provenance::Registry)]),
237        },
238        Surface {
239            id: "events".into(),
240            title: "The live channel: what this process's executions are doing".into(),
241            category: Category::Protocol,
242            visibility: Visibility::Internal,
243            kind: SurfaceKind::NativeRoute,
244            mount: Mount::parse(crate::http::events::PATH).expect("the events path is a mount"),
245            producer: "http::events".into(),
246            feature: None,
247            artifact: None,
248            index: None,
249            availability: Availability::ServedOnly,
250            built_from: None,
251            provenance: provenance([("mount", Provenance::Registry)]),
252        },
253        Surface {
254            id: cockpit::SURFACE.into(),
255            title: "The registry, rendered for a person".into(),
256            category: Category::Interface,
257            visibility: Visibility::Public,
258            kind: SurfaceKind::NativeRoute,
259            mount: Mount::parse(cockpit::PREFIX).expect("the Cockpit prefix is a mount"),
260            producer: "cockpit".into(),
261            feature: Some(Feature::Cockpit),
262            artifact: None,
263            index: None,
264            availability: Availability::ServedOnly,
265            built_from: None,
266            provenance: provenance([("mount", Provenance::Registry)]),
267        },
268        Surface {
269            id: crate::ui::SURFACE.into(),
270            title: "The delivery, for a person: majordomus ui".into(),
271            category: Category::Interface,
272            visibility: Visibility::Public,
273            kind: SurfaceKind::NativeRoute,
274            mount: Mount::parse(crate::ui::PREFIX).expect("the UI prefix is a mount"),
275            producer: "ui".into(),
276            feature: Some(Feature::Ui),
277            artifact: None,
278            index: None,
279            availability: Availability::ServedOnly,
280            built_from: None,
281            provenance: provenance([("mount", Provenance::Registry)]),
282        },
283    ]
284}
285
286/// The identity of the surface that answers `/`: the page this process is entered through.
287pub const HOME: &str = "home";
288
289/// The identity of the served documentation build, mounted under [`DOCS_MOUNT`].
290pub const DOCS: &str = "docs";
291
292/// The identity of the site as it is published, which owns `/` of a deployment.
293pub const APPLICATION: &str = "app";
294
295/// Where the documentation is served by a running process, and the base URL its build is
296/// made for. `/` belongs to the process's own home page, so the site is mounted below it.
297pub const DOCS_MOUNT: &str = "/docs";
298
299/// Where the documentation build for [`DOCS_MOUNT`] is written, repository-relative.
300pub const DOCS_ARTIFACT: &str = "target/web/docs";
301
302/// The identity of the crate's rustdoc, mounted under [`RUSTDOC_MOUNT`]: named after its
303/// producer, as `swagger` is, because "reference" already means several other things here.
304pub const RUSTDOC: &str = "rustdoc";
305
306/// Where the crate's rustdoc is served and published.
307///
308/// A top-level mount of its own, and every nearer name was measured and refused: `/docs`
309/// is the documentation's whole subtree and a surface inside another is a nested-mount
310/// finding, `/api` is the prefix of the capability routes, and `/reference` is overloaded.
311pub const RUSTDOC_MOUNT: &str = "/rustdoc";
312
313/// Where the rustdoc for [`RUSTDOC_MOUNT`] is written, repository-relative: one directory
314/// of the generated root, owned by its producer alone.
315pub const RUSTDOC_ARTIFACT: &str = "target/web/rustdoc";
316
317/// The command that writes [`RUSTDOC_ARTIFACT`]: the same `cargo doc` invocation the gate
318/// proves warning-free, so the published reference is the one that was checked.
319pub const RUSTDOC_PRODUCER: &str = "scripts/rust-check --doc";
320
321/// The site's own configuration, repository-relative: what says this repository has a site
322/// at all.
323pub const SITE_CONFIG: &str = "site/config.toml";
324
325/// Where the site generator writes the build made for deployment, repository-relative.
326pub const SITE_PUBLIC: &str = "site/public";
327
328/// A mount whose meaning is fixed: the role it serves, the path it is at, and the id of the
329/// surface that must hold it.
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub struct Reserved {
332    /// What the mount is for, in one word; the key the generated topology publishes it under.
333    pub role: &'static str,
334    /// The path, exactly as the constant that declares it carries it.
335    pub path: &'static str,
336    /// The surface that must own it.
337    pub owner: &'static str,
338}
339
340/// The mounts this repository reserves.
341///
342/// A path is a name, and this repository has already paid once for a name meaning two
343/// things: the Swagger UI held `/docs` because the word was free, and the day documentation
344/// arrived the obvious path was occupied. These pairs are that decision written down where
345/// something reads it — the validator refuses a topology that breaks one, the generated
346/// topology publishes them, and the documentation renders what is published rather than
347/// restating it.
348///
349/// Each path is the same constant its surface is declared with, so a mount that moves moves
350/// here and the reservation follows it.
351///
352/// ```
353/// use majordomus_cli::web::discover::reserved;
354/// let docs = reserved().into_iter().find(|r| r.role == "documentation").unwrap();
355/// assert_eq!(docs.path, "/docs");
356/// assert_eq!(docs.owner, "docs");
357/// let swagger = reserved().into_iter().find(|r| r.role == "swagger").unwrap();
358/// assert_eq!(swagger.path, "/swagger");
359/// let rustdoc = reserved().into_iter().find(|r| r.owner == "rustdoc").unwrap();
360/// assert_eq!(rustdoc.path, "/rustdoc");
361/// ```
362pub fn reserved() -> Vec<Reserved> {
363    vec![
364        Reserved {
365            role: "home",
366            path: "/",
367            owner: HOME,
368        },
369        Reserved {
370            role: "documentation",
371            path: DOCS_MOUNT,
372            owner: DOCS,
373        },
374        // the crate's reference is documentation too, and a second surface answering its
375        // mount — a producer that decided the rustdoc should be copied somewhere "useful" —
376        // is the duplicate canonical route this refuses while the owner is gone as well
377        Reserved {
378            role: "rustdoc",
379            path: RUSTDOC_MOUNT,
380            owner: RUSTDOC,
381        },
382        Reserved {
383            role: "swagger",
384            path: swagger::SWAGGER_PATH,
385            owner: "swagger",
386        },
387        Reserved {
388            role: "openapi",
389            path: swagger::SPEC_PATH,
390            owner: "openapi",
391        },
392        Reserved {
393            role: "capabilities",
394            path: HttpExposure::PREFIX,
395            owner: "api",
396        },
397        Reserved {
398            role: "events",
399            path: crate::http::events::PATH,
400            owner: "events",
401        },
402    ]
403}
404
405/// The routes the executable answers itself, narrowed to what this process offers.
406pub fn native(runtime: Runtime) -> Vec<Surface> {
407    native_all()
408        .into_iter()
409        .filter(|s| s.feature.is_none_or(|f| runtime.has(f)))
410        .collect()
411}
412
413/// The site, when this repository has one: the deployment and the local build of it.
414///
415/// One configuration, two surfaces, because the same source is built twice for two
416/// different mounts and neither build is a copy of the source. The deployment owns `/` of
417/// whatever origin it is published to and is never served by this process; the local build
418/// is made for [`DOCS_MOUNT`] and is never published. Nothing about the site is assumed:
419/// its existence is read from its own configuration, and a repository without one has
420/// neither surface.
421pub fn application(root: &Path) -> Vec<Surface> {
422    let config = root.join(SITE_CONFIG);
423    if !config.is_file() {
424        return Vec::new();
425    }
426    let from_config = || Provenance::SiteConfig {
427        path: SITE_CONFIG.into(),
428    };
429    vec![
430        Surface {
431            id: APPLICATION.into(),
432            title: "The site as it is deployed".into(),
433            category: Category::Documentation,
434            visibility: Visibility::Public,
435            kind: SurfaceKind::StaticDirectory,
436            mount: Mount::root(),
437            producer: "scripts/site-build".into(),
438            feature: None,
439            artifact: Some(PathBuf::from(SITE_PUBLIC)),
440            index: Some("index.html".into()),
441            availability: Availability::PublishedOnly,
442            built_from: None,
443            provenance: provenance([
444                ("kind", from_config()),
445                ("mount", Provenance::Default),
446                ("artifact", from_config()),
447            ]),
448        },
449        Surface {
450            id: DOCS.into(),
451            title: "The documentation, as this process serves it".into(),
452            category: Category::Documentation,
453            visibility: Visibility::Public,
454            kind: SurfaceKind::StaticDirectory,
455            mount: Mount::parse(DOCS_MOUNT).expect("the documentation mount is a mount"),
456            producer: "scripts/site-build --serve".into(),
457            feature: None,
458            artifact: Some(PathBuf::from(DOCS_ARTIFACT)),
459            index: Some("index.html".into()),
460            availability: Availability::ServedOnly,
461            built_from: built_from(&root.join(DOCS_ARTIFACT)),
462            provenance: provenance([
463                ("kind", from_config()),
464                ("mount", Provenance::Default),
465                (
466                    "artifact",
467                    Provenance::Filesystem {
468                        path: DOCS_ARTIFACT.into(),
469                    },
470                ),
471            ]),
472        },
473    ]
474}
475
476/// The crate's rustdoc, when this repository has the crate.
477///
478/// Declared from the source it documents, exactly as the documentation build is declared
479/// from the site's configuration: the crate existing is what says the reference exists,
480/// and whether `cargo doc` has run in this checkout decides only whether it is *built*.
481/// That is what keeps the topology — and the committed projection of it — the same on
482/// every machine; an unbuilt reference is served as a 503 naming [`RUSTDOC_PRODUCER`], and
483/// `web validate --artifacts` is where its absence becomes a finding.
484///
485/// Both worlds hold it. A running process serves it at a top-level mount of its own, and a
486/// publication carries it beside the site under the same path, nested under the
487/// application's root, which is the one nesting the validator allows. The revision is
488/// read from the declaration the producer writes beside its output; nothing else is,
489/// because every other value is decided here and a second statement of it could only
490/// disagree.
491///
492/// ```
493/// use majordomus_cli::web::discover::{rustdoc, RUSTDOC, RUSTDOC_MOUNT};
494/// let dir = tempfile::tempdir().unwrap();
495/// assert!(rustdoc(dir.path()).is_none(), "a repository without the crate documents nothing");
496/// std::fs::create_dir_all(dir.path().join("apps/majordomus-cli/src")).unwrap();
497/// std::fs::write(dir.path().join("apps/majordomus-cli/Cargo.toml"), "").unwrap();
498/// std::fs::write(dir.path().join("apps/majordomus-cli/src/lib.rs"), "//! x\n").unwrap();
499/// let surface = rustdoc(dir.path()).expect("the crate is documented");
500/// assert_eq!(surface.id, RUSTDOC);
501/// assert_eq!(surface.mount.as_str(), RUSTDOC_MOUNT);
502/// // declared before it is built: the producer has not run in this directory
503/// assert!(surface.built_from.is_none());
504/// assert!(surface.availability.is_served() && surface.availability.is_published());
505/// ```
506pub fn rustdoc(root: &Path) -> Option<Surface> {
507    crate::capability::builtin::quality::crate_dir(root)?;
508    Some(Surface {
509        id: RUSTDOC.into(),
510        title: "The Rust crate's documentation, as rustdoc renders it".into(),
511        category: Category::Documentation,
512        visibility: Visibility::Public,
513        kind: SurfaceKind::StaticDirectory,
514        mount: Mount::parse(RUSTDOC_MOUNT).expect("the rustdoc mount is a mount"),
515        producer: RUSTDOC_PRODUCER.into(),
516        feature: None,
517        artifact: Some(PathBuf::from(RUSTDOC_ARTIFACT)),
518        index: Some("index.html".into()),
519        availability: Availability::Both,
520        built_from: built_from(&root.join(RUSTDOC_ARTIFACT)),
521        provenance: provenance([
522            (
523                "kind",
524                Provenance::Filesystem {
525                    path: CRATE_DIR.into(),
526                },
527            ),
528            ("mount", Provenance::Default),
529            (
530                "artifact",
531                Provenance::Filesystem {
532                    path: RUSTDOC_ARTIFACT.into(),
533                },
534            ),
535        ]),
536    })
537}
538
539/// The generated directories whose surface is declared by discovery rather than by the
540/// walk of the generated root: the documentation build, known from the site's
541/// configuration, and the rustdoc, known from the crate.
542///
543/// Each producer still writes a declaration beside its output, and that declaration
544/// carries the revision and nothing else the walk would add. Read as a surface of its own
545/// it would be the same surface twice — a duplicate id — and it would exist only in a
546/// checkout where the producer happened to run.
547const DECLARED_BY_DISCOVERY: [&str; 2] = [DOCS_ARTIFACT, RUSTDOC_ARTIFACT];
548
549/// The revision a producer recorded beside its output, when it recorded one.
550fn built_from(dir: &Path) -> Option<String> {
551    let text = std::fs::read_to_string(dir.join(DECLARATION_FILE)).ok()?;
552    let declaration: Declaration = serde_json::from_str(&text).ok()?;
553    declaration.built_from
554}
555
556/// Every generated static surface: one directory under the generated root, each declaring
557/// itself.
558///
559/// A directory without a declaration is not a surface — it is somebody's scratch space, and
560/// serving it because it happened to be there is how a repository publishes what it did not
561/// mean to.
562pub fn generated(root: &Path) -> Result<Vec<Surface>> {
563    let dir = root.join(GENERATED_ROOT);
564    let mut entries: Vec<PathBuf> = match std::fs::read_dir(&dir) {
565        Ok(read) => read
566            .filter_map(|e| e.ok())
567            .map(|e| e.path())
568            .filter(|p| p.is_dir())
569            .collect(),
570        Err(_) => return Ok(Vec::new()),
571    };
572    entries.sort();
573    let mut out = Vec::new();
574    for path in entries {
575        let declaration = path.join(DECLARATION_FILE);
576        if !declaration.is_file() {
577            continue;
578        }
579        // the documentation build and the rustdoc are discovered from what says they should
580        // exist even when they have not been built; their declarations carry the revision
581        // and nothing else this walk would add
582        let relative = path.file_name().map(|n| Path::new(GENERATED_ROOT).join(n));
583        if relative.is_some_and(|r| DECLARED_BY_DISCOVERY.iter().any(|d| r == Path::new(d))) {
584            continue;
585        }
586        let rel = format!(
587            "{GENERATED_ROOT}/{}/{DECLARATION_FILE}",
588            path.file_name().unwrap_or_default().to_string_lossy()
589        );
590        let text = std::fs::read_to_string(&declaration).map_err(|e| Error::InvalidSurface {
591            surface: rel.clone(),
592            reason: format!("cannot be read: {e}"),
593        })?;
594        let decl: Declaration = serde_json::from_str(&text).map_err(|e| Error::InvalidSurface {
595            surface: rel.clone(),
596            reason: format!("does not parse: {e}; the contract is {DECLARATION_SCHEMA}"),
597        })?;
598        if decl.schema != DECLARATION_SCHEMA {
599            return Err(Error::InvalidSurface {
600                surface: rel.clone(),
601                reason: format!(
602                    "declares schema '{}', and this executable reads {DECLARATION_SCHEMA}",
603                    decl.schema
604                ),
605            });
606        }
607        let mount = Mount::parse(&decl.mount).map_err(|reason| Error::InvalidSurface {
608            surface: decl.id.clone(),
609            reason: format!("{reason} (declared in {rel})"),
610        })?;
611        let artifact = PathBuf::from(GENERATED_ROOT).join(path.file_name().ok_or_else(|| {
612            Error::InvalidSurface {
613                surface: decl.id.clone(),
614                reason: format!("the directory of {rel} has no name"),
615            }
616        })?);
617        out.push(Surface {
618            id: decl.id.clone(),
619            title: decl.title.unwrap_or_else(|| decl.id.clone()),
620            category: decl.category.unwrap_or(Category::Report),
621            visibility: decl.visibility.unwrap_or(Visibility::Public),
622            kind: SurfaceKind::StaticDirectory,
623            mount,
624            producer: decl.producer.unwrap_or_else(|| "unknown".into()),
625            feature: None,
626            artifact: Some(artifact),
627            index: Some(decl.index.unwrap_or_else(|| "index.html".into())),
628            availability: decl.availability.unwrap_or(Availability::Both),
629            built_from: decl.built_from,
630            provenance: provenance([
631                (
632                    "mount",
633                    Provenance::ProducerDeclaration { path: rel.clone() },
634                ),
635                ("kind", Provenance::Filesystem { path: rel.clone() }),
636                ("artifact", Provenance::Filesystem { path: rel }),
637            ]),
638        });
639    }
640    Ok(out)
641}
642
643fn provenance<const N: usize>(pairs: [(&str, Provenance); N]) -> BTreeMap<String, Provenance> {
644    pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    fn write(dir: &Path, name: &str, body: &str) {
652        std::fs::create_dir_all(dir.join(name)).unwrap();
653        std::fs::write(dir.join(name).join(DECLARATION_FILE), body).unwrap();
654    }
655
656    #[test]
657    fn a_generated_directory_declaring_itself_is_a_surface() {
658        let tmp = tempfile::tempdir().unwrap();
659        let generated = tmp.path().join(GENERATED_ROOT);
660        write(
661            &generated,
662            "example-report",
663            r#"{"schema":"web-surface/v1","id":"example-report","mount":"/example-report","title":"Example"}"#,
664        );
665        let found = generated_surfaces(tmp.path());
666        assert_eq!(found.len(), 1);
667        assert_eq!(found[0].id, "example-report");
668        assert_eq!(found[0].mount.as_str(), "/example-report");
669        assert_eq!(found[0].index.as_deref(), Some("index.html"));
670    }
671
672    #[test]
673    fn a_directory_without_a_declaration_is_not_a_surface() {
674        let tmp = tempfile::tempdir().unwrap();
675        std::fs::create_dir_all(tmp.path().join(GENERATED_ROOT).join("scratch")).unwrap();
676        assert!(generated_surfaces(tmp.path()).is_empty());
677    }
678
679    #[test]
680    fn an_unknown_schema_is_refused_rather_than_guessed_at() {
681        let tmp = tempfile::tempdir().unwrap();
682        write(
683            &tmp.path().join(GENERATED_ROOT),
684            "future",
685            r#"{"schema":"web-surface/v2","id":"future","mount":"/future"}"#,
686        );
687        let err = generated(tmp.path()).unwrap_err().to_string();
688        assert!(err.contains("web-surface/v1"), "{err}");
689    }
690
691    #[test]
692    fn an_unknown_field_is_refused_rather_than_ignored() {
693        let tmp = tempfile::tempdir().unwrap();
694        write(
695            &tmp.path().join(GENERATED_ROOT),
696            "typo",
697            r#"{"schema":"web-surface/v1","id":"typo","mount":"/typo","moutn":"/oops"}"#,
698        );
699        assert!(generated(tmp.path()).is_err());
700    }
701
702    #[test]
703    fn the_application_is_discovered_from_its_configuration() {
704        let tmp = tempfile::tempdir().unwrap();
705        assert!(application(tmp.path()).is_empty());
706        std::fs::create_dir_all(tmp.path().join("site")).unwrap();
707        std::fs::write(tmp.path().join("site/config.toml"), "base_url = \"/\"\n").unwrap();
708        let surfaces = application(tmp.path());
709        let app = surfaces
710            .iter()
711            .find(|s| s.id == APPLICATION)
712            .expect("a site is deployed");
713        assert!(app.mount.is_root());
714        assert_eq!(app.artifact.as_deref(), Some(Path::new(SITE_PUBLIC)));
715        assert!(
716            !app.availability.is_served(),
717            "the deployment is not served"
718        );
719        let docs = surfaces
720            .iter()
721            .find(|s| s.id == DOCS)
722            .expect("the same site is served under its own mount");
723        assert_eq!(docs.mount.as_str(), DOCS_MOUNT);
724        assert_eq!(docs.artifact.as_deref(), Some(Path::new(DOCS_ARTIFACT)));
725        assert!(
726            !docs.availability.is_published(),
727            "the local build is never deployed"
728        );
729    }
730
731    #[test]
732    fn the_native_routes_come_from_the_constants_that_declare_them() {
733        let all = native(Runtime::full());
734        let mounts: Vec<&str> = all.iter().map(|s| s.mount.as_str()).collect();
735        assert!(mounts.contains(&swagger::SWAGGER_PATH));
736        assert!(mounts.contains(&mcp::PATH));
737        assert!(mounts.contains(&cockpit::PREFIX));
738        // a process that serves neither advertises neither
739        let bare = native(Runtime::default());
740        assert!(!bare.iter().any(|s| s.id == "mcp" || s.id == "cockpit"));
741    }
742
743    fn generated_surfaces(root: &Path) -> Vec<Surface> {
744        generated(root).unwrap()
745    }
746
747    /// A repository whose only relevant facts are that it has the crate and a site.
748    fn with_the_crate_and_a_site() -> tempfile::TempDir {
749        let tmp = tempfile::tempdir().unwrap();
750        std::fs::create_dir_all(tmp.path().join(CRATE_DIR).join("src")).unwrap();
751        std::fs::write(tmp.path().join(CRATE_DIR).join("Cargo.toml"), "").unwrap();
752        std::fs::write(tmp.path().join(CRATE_DIR).join("src/lib.rs"), "//! x\n").unwrap();
753        std::fs::create_dir_all(tmp.path().join("site")).unwrap();
754        std::fs::write(tmp.path().join(SITE_CONFIG), "base_url = \"/\"\n").unwrap();
755        tmp
756    }
757
758    /// What the producer writes beside its output: the whole declaration, as the contract
759    /// in `web-surface/v1` has it, with the revision it was built from.
760    fn declare_rustdoc(root: &Path, revision: &str) {
761        let dir = root.join(RUSTDOC_ARTIFACT);
762        std::fs::create_dir_all(&dir).unwrap();
763        std::fs::write(
764            dir.join(DECLARATION_FILE),
765            format!(
766                r#"{{"schema":"web-surface/v1","id":"rustdoc","mount":"/rustdoc",
767                    "title":"rustdoc","producer":"scripts/rust-check --doc",
768                    "availability":"both","category":"documentation",
769                    "visibility":"public","built_from":"{revision}"}}"#
770            ),
771        )
772        .unwrap();
773    }
774
775    #[test]
776    fn the_rustdoc_is_declared_by_the_crate_and_not_by_its_output() {
777        let tmp = tempfile::tempdir().unwrap();
778        assert!(rustdoc(tmp.path()).is_none(), "no crate, no reference");
779        // a manifest alone is not a crate the reference documents: the same test the
780        // quality gates use decides it, so the two can never disagree about the crate
781        std::fs::create_dir_all(tmp.path().join(CRATE_DIR)).unwrap();
782        std::fs::write(tmp.path().join(CRATE_DIR).join("Cargo.toml"), "").unwrap();
783        assert!(rustdoc(tmp.path()).is_none());
784
785        let tmp = with_the_crate_and_a_site();
786        let surface = rustdoc(tmp.path()).expect("the crate is there");
787        assert_eq!(surface.id, RUSTDOC);
788        assert_eq!(surface.kind, SurfaceKind::StaticDirectory);
789        assert_eq!(surface.category, Category::Documentation);
790        assert_eq!(surface.visibility, Visibility::Public);
791        assert_eq!(surface.mount.as_str(), RUSTDOC_MOUNT);
792        assert_eq!(surface.producer, RUSTDOC_PRODUCER);
793        assert_eq!(
794            surface.artifact.as_deref(),
795            Some(Path::new(RUSTDOC_ARTIFACT))
796        );
797        assert_eq!(surface.index.as_deref(), Some("index.html"));
798        assert_eq!(surface.availability, Availability::Both);
799        assert_eq!(surface.built_from, None, "declared, and not built");
800        // every value a reader can be surprised by says where it came from
801        assert_eq!(
802            surface.provenance.get("kind"),
803            Some(&Provenance::Filesystem {
804                path: CRATE_DIR.into()
805            })
806        );
807        assert_eq!(surface.provenance.get("mount"), Some(&Provenance::Default));
808        assert_eq!(
809            surface.provenance.get("artifact"),
810            Some(&Provenance::Filesystem {
811                path: RUSTDOC_ARTIFACT.into()
812            })
813        );
814    }
815
816    #[test]
817    fn the_rustdoc_takes_its_revision_from_the_producer_and_nothing_else() {
818        let tmp = with_the_crate_and_a_site();
819        let unbuilt = rustdoc(tmp.path()).unwrap();
820        declare_rustdoc(tmp.path(), "0123456789abcdef");
821        let built = rustdoc(tmp.path()).unwrap();
822        assert_eq!(built.built_from.as_deref(), Some("0123456789abcdef"));
823        // the revision is the one fact the declaration adds: everything else is the same
824        // surface whether or not the producer ran
825        assert_eq!(
826            Surface {
827                built_from: None,
828                ..built
829            },
830            unbuilt
831        );
832    }
833
834    #[test]
835    fn the_walk_of_the_generated_root_leaves_the_declared_directories_alone() {
836        let tmp = with_the_crate_and_a_site();
837        declare_rustdoc(tmp.path(), "abc");
838        write(
839            &tmp.path().join(GENERATED_ROOT),
840            DOCS,
841            r#"{"schema":"web-surface/v1","id":"docs","mount":"/docs","built_from":"abc"}"#,
842        );
843        assert!(
844            generated_surfaces(tmp.path()).is_empty(),
845            "a declared directory read twice would be the same surface twice"
846        );
847        let topology = discover(tmp.path(), Runtime::full()).unwrap();
848        for id in [RUSTDOC, DOCS] {
849            assert_eq!(
850                topology.surfaces.iter().filter(|s| s.id == id).count(),
851                1,
852                "{id} is discovered exactly once"
853            );
854        }
855        assert_eq!(
856            topology.get(RUSTDOC).and_then(|s| s.built_from.as_deref()),
857            Some("abc")
858        );
859    }
860
861    #[test]
862    fn the_rustdoc_is_valid_in_both_worlds_it_lives_in() {
863        use crate::web::validate::{validate, Artifacts};
864        let tmp = with_the_crate_and_a_site();
865        let topology = discover(tmp.path(), Runtime::full()).unwrap();
866        let findings = validate(&topology, tmp.path(), Artifacts::Ignore);
867        assert!(findings.is_empty(), "{findings:?}");
868
869        // served: a top-level mount of its own, beside the documentation and not inside it
870        let served = topology.served(Runtime::full());
871        assert_eq!(
872            served.owner("/rustdoc/").map(|s| s.id.as_str()),
873            Some(RUSTDOC)
874        );
875        assert_eq!(
876            served
877                .owner("/rustdoc/majordomus_cli/index.html")
878                .map(|s| s.id.as_str()),
879            Some(RUSTDOC)
880        );
881        assert_eq!(served.owner("/docs/").map(|s| s.id.as_str()), Some(DOCS));
882        // published: nested under the application's root, which is the one nesting the
883        // validator allows, because answering what nothing else claims is the root's job
884        let published = topology.published();
885        assert_eq!(
886            published.owner("/rustdoc/").map(|s| s.id.as_str()),
887            Some(RUSTDOC)
888        );
889        assert_eq!(
890            published.owner("/anything").map(|s| s.id.as_str()),
891            Some(APPLICATION)
892        );
893        assert!(validate(&served, tmp.path(), Artifacts::Ignore).is_empty());
894        assert!(validate(&published, tmp.path(), Artifacts::Ignore).is_empty());
895
896        // and before it is built, a validation that requires artifacts names its producer
897        let required = validate(
898            &topology.select(&[RUSTDOC.into()], &[]),
899            tmp.path(),
900            Artifacts::Required,
901        );
902        let absent = required
903            .iter()
904            .find(|f| f.rule == "surface.artifact-absent")
905            .expect("an unbuilt reference is a finding when the artifact is required");
906        assert!(
907            absent.remedy.contains(RUSTDOC_PRODUCER),
908            "{}",
909            absent.remedy
910        );
911    }
912}