1use 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
31pub const GENERATED_ROOT: &str = "target/web";
36
37pub const DECLARATION_SCHEMA: &str = "web-surface/v1";
39
40pub const DECLARATION_FILE: &str = "surface.json";
42
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct Runtime {
49 pub mcp: bool,
51 pub cockpit: bool,
53 pub ui: bool,
55}
56
57impl Runtime {
58 pub fn full() -> Self {
61 Runtime {
62 mcp: true,
63 cockpit: true,
64 ui: true,
65 }
66 }
67
68 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct Declaration {
102 pub schema: String,
104 pub id: String,
106 pub mount: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub title: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub index: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub producer: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub availability: Option<Availability>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub category: Option<Category>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub visibility: Option<Visibility>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub built_from: Option<String>,
131}
132
133pub 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
147pub 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 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
286pub const HOME: &str = "home";
288
289pub const DOCS: &str = "docs";
291
292pub const APPLICATION: &str = "app";
294
295pub const DOCS_MOUNT: &str = "/docs";
298
299pub const DOCS_ARTIFACT: &str = "target/web/docs";
301
302pub const RUSTDOC: &str = "rustdoc";
305
306pub const RUSTDOC_MOUNT: &str = "/rustdoc";
312
313pub const RUSTDOC_ARTIFACT: &str = "target/web/rustdoc";
316
317pub const RUSTDOC_PRODUCER: &str = "scripts/rust-check --doc";
320
321pub const SITE_CONFIG: &str = "site/config.toml";
324
325pub const SITE_PUBLIC: &str = "site/public";
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub struct Reserved {
332 pub role: &'static str,
334 pub path: &'static str,
336 pub owner: &'static str,
338}
339
340pub 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 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
405pub 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
413pub 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
476pub 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
539const DECLARED_BY_DISCOVERY: [&str; 2] = [DOCS_ARTIFACT, RUSTDOC_ARTIFACT];
548
549fn 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
556pub 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 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 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 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 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 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 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 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 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 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 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}