1use std::collections::BTreeMap;
14use std::path::Path;
15
16use serde::Serialize;
17
18use crate::bench::baseline::{self, Policy};
19use crate::bench::results::{CacheMode, Provenance as RunProvenance};
20use crate::bench::{BenchmarkProjection, Coverage, ResultDocument, SystemTarget, TargetKind};
21use crate::capability::registry::{ModuleSource, Summary};
22use crate::capability::{Capability, CapabilityKind, Context, Provenance, Stability};
23use crate::cli::CommandDoc;
24use crate::error::{Error, Result};
25use crate::http::openapi;
26use crate::index::Index;
27use crate::metadata::KindSchema;
28use crate::policy::LoadedPolicy;
29use crate::repository::Repository;
30
31pub const SCHEMA: &str = "majordomus-site-registry/v2";
33
34#[derive(Debug, Clone, Serialize)]
36pub struct SiteRegistry {
37 pub schema: &'static str,
39 pub generated: String,
42 pub generator: Generator,
44 pub registry: RegistryView,
46 pub index: IndexView,
48 pub kinds: Vec<KindView>,
50 pub projections: Vec<ProjectionView>,
52 pub cli: CommandDoc,
54 pub mcp: McpView,
56 pub http: HttpView,
58 pub benchmarks: BenchmarksView,
60}
61
62#[derive(Debug, Clone, Serialize)]
64pub struct Generator {
65 pub id: &'static str,
67 pub version: &'static str,
69}
70
71#[derive(Debug, Clone, Serialize)]
73pub struct RegistryView {
74 pub fingerprint: String,
76 pub summary: Summary,
78 pub builtin: Vec<CapabilityView>,
81 pub modules: Vec<ModuleView>,
84}
85
86#[derive(Debug, Clone, Serialize)]
88pub struct CapabilityView {
89 #[serde(flatten)]
90 pub capability: Capability,
92 pub source_path: String,
94}
95
96impl crate::order::Ordered for CapabilityView {
98 fn order_key(&self) -> crate::order::OrderKey<'_> {
99 self.capability.order_key()
100 }
101}
102
103#[derive(Debug, Clone, Serialize)]
105pub struct ModuleView {
106 pub id: String,
108 pub title: String,
110 pub description: String,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub stability: Option<Stability>,
115 pub source: String,
117 pub capabilities: usize,
119 pub capability_ids: Vec<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub source_path: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub rustdoc: Option<ReferencePage>,
131}
132
133#[derive(Debug, Clone, Serialize)]
155pub struct ReferencePage {
156 pub surface: &'static str,
158 pub module: String,
160 pub path: String,
163}
164
165#[derive(Debug, Clone, Serialize)]
167pub struct IndexView {
168 pub fingerprint: String,
170 pub state: String,
172 pub layer_schema: String,
174 pub sections: BTreeMap<String, String>,
176 pub by_kind: BTreeMap<String, usize>,
178 pub diagnostics: BTreeMap<String, usize>,
180 pub objects: Vec<ObjectView>,
182}
183
184#[derive(Debug, Clone, Serialize)]
186pub struct ObjectView {
187 pub uri: String,
189 pub kind: String,
191 pub identity: String,
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub title: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub description: Option<String>,
199 pub path: String,
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub section: Option<String>,
204 pub source_class: String,
206 pub bytes: u64,
208 #[serde(skip_serializing_if = "Vec::is_empty")]
209 pub tags: Vec<String>,
211}
212
213impl crate::order::Ordered for ModuleView {
215 fn order_key(&self) -> crate::order::OrderKey<'_> {
216 crate::order::OrderKey::plain(&self.id, &self.id)
217 }
218}
219
220#[derive(Debug, Clone, Serialize)]
222pub struct KindView {
223 pub name: String,
225 pub format: String,
227 pub front_matter: String,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub schema: Option<String>,
232 pub identity: Vec<String>,
234 pub objects: usize,
236}
237
238impl crate::order::Ordered for KindView {
241 fn order_key(&self) -> crate::order::OrderKey<'_> {
242 crate::order::OrderKey::plain(&self.name, &self.name)
243 }
244}
245
246#[derive(Debug, Clone, Serialize)]
248pub struct ProjectionView {
249 pub provider: String,
251 pub target: String,
253 pub mode: String,
255 pub always_loaded: bool,
257}
258
259#[derive(Debug, Clone, Serialize)]
261pub struct McpView {
262 pub server_name: &'static str,
264 pub protocol_versions: &'static [&'static str],
266 pub tools: Vec<ToolView>,
268 pub resources: Vec<ResourceView>,
272 pub resource_template: &'static str,
274}
275
276#[derive(Debug, Clone, Serialize)]
278pub struct ToolView {
279 pub name: String,
281 pub id: String,
283 pub title: String,
285 pub description: String,
287 pub kind: CapabilityKind,
289 pub read_only: bool,
291 pub effect: crate::capability::Effect,
293 pub hints: crate::capability::Hints,
295 pub input: String,
297 pub output: String,
299}
300
301pub const MCP_RESOURCE_TEMPLATE: &str = "majordomus://<kind>/<identity>";
304
305#[derive(Debug, Clone, Serialize)]
307pub struct ResourceView {
308 pub uri: String,
310 pub name: String,
312 pub id: String,
314 pub title: String,
316 pub kind: CapabilityKind,
318}
319
320#[derive(Debug, Clone, Serialize)]
322pub struct HttpView {
323 pub routes: Vec<RouteView>,
325 pub infrastructure: Vec<crate::web::ProjectionRoute>,
328 pub openapi_path: String,
330}
331
332#[derive(Debug, Clone, Serialize)]
334pub struct RouteView {
335 pub method: String,
337 pub path: String,
339 pub id: String,
341 pub kind: CapabilityKind,
343}
344
345#[derive(Debug, Clone, Serialize)]
347pub struct BenchmarksView {
348 pub targets: Vec<TargetView>,
350 pub coverage: Coverage,
352 pub system_targets: Vec<SystemTargetView>,
354 pub policy: Policy,
357 pub policy_path: String,
359 pub baselines: Vec<BaselineView>,
361}
362
363#[derive(Debug, Clone, Serialize)]
365pub struct TargetView {
366 pub key: String,
368 pub kind: &'static str,
370 pub transport: String,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub id: Option<String>,
375 pub module: String,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub case: Option<String>,
380 pub cached: bool,
382 #[serde(skip_serializing_if = "Option::is_none")]
383 pub tool: Option<String>,
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub route: Option<String>,
388}
389
390#[derive(Debug, Clone, Serialize)]
392pub struct SystemTargetView {
393 pub key: String,
395 pub transport: String,
397 pub description: String,
399}
400
401#[derive(Debug, Clone, Serialize)]
403pub struct BaselineView {
404 pub platform: String,
406 pub path: String,
408 pub profile: String,
410 pub finished_at: String,
412 pub provenance: RunProvenance,
415 pub results: Vec<MeasurementView>,
417}
418
419#[derive(Debug, Clone, Serialize)]
421pub struct MeasurementView {
422 pub key: String,
424 pub cache_mode: String,
426 pub samples: usize,
428 pub p50_us: f64,
430 pub p95_us: f64,
432 pub p99_us: f64,
434 #[serde(skip_serializing_if = "Option::is_none")]
435 pub handler_invocations: Option<u64>,
437}
438
439fn reference_pages(root: &Path) -> Result<BTreeMap<String, ReferencePage>> {
445 let mut pages = BTreeMap::new();
446 let Some(dir) = crate::capability::builtin::quality::crate_dir(root) else {
447 return Ok(pages);
448 };
449 for route in crate::quality::rustdoc::module_routes(&dir)? {
450 pages
453 .entry(route.path.clone())
454 .or_insert_with(|| ReferencePage {
455 surface: crate::web::discover::RUSTDOC,
456 module: route.path,
457 path: route.route,
458 });
459 }
460 Ok(pages)
461}
462
463pub fn dataset(
465 ctx: &Context,
466 schema: &KindSchema,
467 policy: &LoadedPolicy,
468 repo: &Repository,
469) -> Result<SiteRegistry> {
470 let registry = &ctx.registry;
471 let index: &Index = &ctx.index;
472 let mut builtin: Vec<CapabilityView> = registry
473 .iter()
474 .filter(|c| matches!(c.provenance, Provenance::Builtin { .. }))
475 .map(|c| CapabilityView {
476 capability: c.clone(),
477 source_path: c.provenance.source_path(),
478 })
479 .collect();
480 crate::order::canonical(&mut builtin);
481 let reference = reference_pages(repo.root())?;
482 let mut modules: Vec<ModuleView> = registry
483 .modules()
484 .map(|m| {
485 let mine: Vec<&Capability> = registry
486 .iter()
487 .filter(|c| {
488 c.module.as_str() == m.id.as_str()
489 && matches!(c.provenance, Provenance::Builtin { .. })
490 })
491 .collect();
492 let rust_module = mine.first().and_then(|c| match &c.provenance {
493 Provenance::Builtin { module } => Some(module.as_str()),
494 Provenance::Declarative { .. } => None,
495 });
496 ModuleView {
497 id: m.id.to_string(),
498 title: m.title.clone(),
499 description: m.description.clone(),
500 stability: m.stability,
501 source: enum_name(m.source),
502 capabilities: registry
503 .iter()
504 .filter(|c| c.module.as_str() == m.id.as_str())
505 .count(),
506 capability_ids: mine.iter().map(|c| c.id.to_string()).collect(),
507 source_path: (m.source != ModuleSource::Declarative)
508 .then(|| mine.first().map(|c| c.provenance.source_path()))
509 .flatten(),
510 rustdoc: rust_module.and_then(|module| reference.get(module).cloned()),
511 }
512 })
513 .collect();
514 crate::order::canonical(&mut modules);
515
516 let mut objects: Vec<ObjectView> = index
517 .objects
518 .iter()
519 .map(|o| ObjectView {
520 uri: o.uri.clone(),
521 kind: o.kind.clone(),
522 identity: o.identity.clone(),
523 title: o.title.clone(),
524 description: o.description.clone(),
525 path: o.provenance.path.clone(),
526 section: o.provenance.section.clone(),
527 source_class: o.provenance.source_class.clone(),
528 bytes: o.provenance.bytes,
529 tags: o.tags().into_iter().map(str::to_string).collect(),
530 })
531 .collect();
532 objects.sort_by(|a, b| a.uri.cmp(&b.uri));
533 let by_kind: BTreeMap<String, usize> = index
534 .kinds()
535 .into_iter()
536 .map(|(k, n)| (k.to_string(), n))
537 .collect();
538 let mut diagnostics: BTreeMap<String, usize> = BTreeMap::new();
539 for d in &index.diagnostics {
540 *diagnostics
541 .entry(format!("{:?}", d.severity).to_lowercase())
542 .or_default() += 1;
543 }
544
545 let mut kinds: Vec<KindView> = schema
546 .kinds()
547 .map(|(name, spec)| KindView {
548 name: name.clone(),
549 format: format!("{:?}", spec.format).to_lowercase(),
550 front_matter: format!("{:?}", spec.front_matter).to_lowercase(),
551 schema: spec.schema.clone(),
552 identity: spec.identity.clone(),
553 objects: by_kind.get(name).copied().unwrap_or(0),
554 })
555 .collect();
556 crate::order::canonical(&mut kinds);
557
558 let projections = policy
559 .policy
560 .projections
561 .iter()
562 .map(|p| ProjectionView {
563 provider: p.provider.clone(),
564 target: p.target.clone(),
565 mode: format!("{:?}", p.mode).to_lowercase(),
566 always_loaded: p.always_loaded,
567 })
568 .collect();
569
570 let mcp = McpView {
571 server_name: crate::mcp::protocol::SERVER_NAME,
572 protocol_versions: crate::mcp::protocol::PROTOCOL_VERSIONS,
573 tools: registry
574 .iter()
575 .filter_map(|c| {
576 let name = c.exposure.mcp.as_ref()?.tool.clone()?;
577 Some(ToolView {
578 name,
579 id: c.id.to_string(),
580 title: c.title.clone(),
581 description: c.description.clone(),
582 kind: c.kind,
583 read_only: c.execution.hints().read_only,
584 effect: c.execution.effect,
585 hints: c.execution.hints(),
586 input: c.input.name.clone().unwrap_or_else(|| "object".into()),
587 output: c.output.name.clone().unwrap_or_else(|| "object".into()),
588 })
589 })
590 .collect(),
591 resources: registry
592 .iter()
593 .filter(|c| matches!(c.provenance, Provenance::Builtin { .. }))
594 .filter_map(|c| {
595 let r = c.exposure.mcp.as_ref()?.resource.as_ref()?;
596 Some(ResourceView {
597 uri: r.uri.clone(),
598 name: r.name.clone(),
599 id: c.id.to_string(),
600 title: c.title.clone(),
601 kind: c.kind,
602 })
603 })
604 .collect(),
605 resource_template: MCP_RESOURCE_TEMPLATE,
606 };
607
608 let http = HttpView {
609 routes: registry
610 .iter()
611 .filter_map(|c| {
612 let h = c.exposure.http.as_ref()?;
613 Some(RouteView {
614 method: h.method.as_str().to_string(),
615 path: h.path.clone(),
616 id: c.id.to_string(),
617 kind: c.kind,
618 })
619 })
620 .collect(),
621 infrastructure: openapi::infrastructure_routes(),
622 openapi_path: format!("{}/openapi.json", crate::generate::OUT_DIR),
623 };
624
625 let projection = BenchmarkProjection::from_context(ctx);
626 let coverage = Coverage::compute(ctx, &projection);
627 let targets = projection
628 .targets
629 .iter()
630 .map(|t| match &t.kind {
631 TargetKind::Capability {
632 id,
633 module,
634 transport,
635 case,
636 cache,
637 tool,
638 route,
639 ..
640 } => TargetView {
641 key: t.key.clone(),
642 kind: "capability",
643 transport: transport.name().to_string(),
644 id: Some(id.clone()),
645 module: module.clone(),
646 case: Some(case.clone()),
647 cached: !matches!(cache, crate::capability::CachePolicy::Disabled),
648 tool: tool.clone(),
649 route: route.as_ref().map(|(m, p)| format!("{m} {p}")),
650 },
651 TargetKind::System { target } => TargetView {
652 key: t.key.clone(),
653 kind: "system",
654 transport: target.transport().name().to_string(),
655 id: None,
656 module: "system".into(),
657 case: None,
658 cached: false,
659 tool: None,
660 route: None,
661 },
662 TargetKind::Page { route, .. } => TargetView {
663 key: t.key.clone(),
664 kind: "page",
665 transport: crate::bench::Transport::Http.name().to_string(),
666 id: None,
667 module: "system".into(),
668 case: None,
669 cached: true,
670 tool: None,
671 route: Some(format!("GET {route}")),
672 },
673 })
674 .collect();
675 let policy_path = baseline::policy_path(repo);
676 let policy_rel = relative(repo.root(), &policy_path);
677 let bench_policy = Policy::load(repo)?;
678 let baselines = load_baselines(repo, &policy_path)?;
679 let benchmarks = BenchmarksView {
680 targets,
681 coverage,
682 system_targets: SystemTarget::ALL
683 .iter()
684 .map(|s| SystemTargetView {
685 key: s.key().to_string(),
686 transport: s.transport().name().to_string(),
687 description: s.description().to_string(),
688 })
689 .collect(),
690 policy: bench_policy,
691 policy_path: policy_rel,
692 baselines,
693 };
694
695 Ok(SiteRegistry {
696 schema: SCHEMA,
697 generated: crate::generate::json_banner(
698 "the capability registry and the index of this repository's layer",
699 ),
700 generator: Generator {
701 id: "majordomus-cli",
702 version: crate::VERSION,
703 },
704 registry: RegistryView {
705 fingerprint: registry.fingerprint().to_string(),
706 summary: registry.summary(),
707 builtin,
708 modules,
709 },
710 index: IndexView {
711 fingerprint: index.fingerprint.clone(),
712 state: format!("{:?}", index.state).to_lowercase(),
713 layer_schema: index.repository.layer_schema.clone(),
714 sections: index.repository.sections.clone(),
715 by_kind,
716 diagnostics,
717 objects,
718 },
719 kinds,
720 projections,
721 cli: crate::cli::tree(),
722 mcp,
723 http,
724 benchmarks,
725 })
726}
727
728fn load_baselines(repo: &Repository, policy_path: &Path) -> Result<Vec<BaselineView>> {
732 let Some(dir) = policy_path.parent() else {
733 return Ok(Vec::new());
734 };
735 let Ok(entries) = std::fs::read_dir(dir) else {
736 return Ok(Vec::new());
737 };
738 let mut names: Vec<String> = entries
739 .filter_map(|e| e.ok())
740 .filter_map(|e| e.file_name().to_str().map(str::to_string))
741 .filter(|n| n.starts_with("baseline.") && n.ends_with(".json"))
742 .collect();
743 crate::order::canonical(&mut names);
744 let mut out = Vec::new();
745 for name in names {
746 let path = dir.join(&name);
747 let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
748 let doc: ResultDocument =
749 serde_json::from_str(&text).map_err(|e| Error::InvalidManifest {
750 path: path.clone(),
751 reason: format!("not a benchmark result document: {e}"),
752 })?;
753 let platform = name
754 .trim_start_matches("baseline.")
755 .trim_end_matches(".json")
756 .to_string();
757 out.push(BaselineView {
758 platform,
759 path: relative(repo.root(), &path),
760 profile: doc.profile.clone(),
761 finished_at: doc.finished_at.clone(),
762 provenance: doc.provenance.clone(),
763 results: doc
764 .results
765 .iter()
766 .map(|r| MeasurementView {
767 key: r.key.clone(),
768 cache_mode: match r.cache_mode {
769 CacheMode::Uncached => "uncached",
770 CacheMode::Cold => "cold",
771 CacheMode::Warm => "warm",
772 CacheMode::NotApplicable => "n/a",
773 }
774 .to_string(),
775 samples: r.stats.samples,
776 p50_us: r.stats.p50_us,
777 p95_us: r.stats.p95_us,
778 p99_us: r.stats.p99_us,
779 handler_invocations: r.handler_invocations,
780 })
781 .collect(),
782 });
783 }
784 Ok(out)
785}
786
787fn relative(root: &Path, path: &Path) -> String {
788 path.strip_prefix(root)
789 .unwrap_or(path)
790 .to_string_lossy()
791 .replace('\\', "/")
792}
793
794fn enum_name<T: serde::Serialize>(v: T) -> String {
795 serde_json::to_value(v)
796 .ok()
797 .and_then(|v| v.as_str().map(str::to_string))
798 .unwrap_or_default()
799}
800
801pub fn render(dataset: &SiteRegistry) -> String {
803 let mut s = serde_json::to_string_pretty(dataset).unwrap_or_default();
804 s.push('\n');
805 s
806}
807
808pub const WHY_SCHEMA: &str = "majordomus-site-why/v1";
812
813pub const WHY_SOURCE: &str =
815 "the operational moments, audiences and areas of this repository's layer";
816
817pub const GRAPH_SOURCE: &str = "the moments and what answers them, as the derived `why` graph";
819
820fn graph_artifact(
826 ctx: &Context,
827 id: &str,
828 file: &str,
829 artifact: &str,
830 source: &str,
831) -> Result<crate::generate::Artifact> {
832 crate::graph::derive_whole(id, &ctx.registry, &ctx.index)
833 .map_err(|reason| Error::Protocol { reason })
834 .map(|graph| {
835 let mut document: serde_json::Map<String, serde_json::Value> =
837 serde_json::to_value(&graph)
838 .ok()
839 .and_then(|v| v.as_object().cloned())
840 .unwrap_or_default();
841 document.insert(
842 "generated".into(),
843 serde_json::Value::String(crate::generate::json_banner(source)),
844 );
845 document.insert(
846 "generator".into(),
847 serde_json::json!({ "id": "majordomus-cli", "version": crate::VERSION }),
848 );
849 crate::generate::Artifact::verbatim(
850 format!("{}/{file}", crate::generate::SITE_DATA_DIR),
851 artifact,
852 crate::generate::ArtifactFormat::Json,
853 None,
854 source,
855 render_json(&serde_json::Value::Object(document)),
856 )
857 })
858}
859
860pub fn why_artifacts(ctx: &Context) -> Result<Vec<crate::generate::Artifact>> {
877 let by_cli = |path: &[&str]| -> Option<String> {
878 ctx.registry
879 .by_cli(&path.iter().map(|w| w.to_string()).collect::<Vec<_>>())
880 .map(|c| c.id.to_string())
881 };
882 let run = |path: &[&str], input: serde_json::Value| -> Result<serde_json::Value> {
883 let id = by_cli(path).ok_or_else(|| Error::Protocol {
884 reason: format!(
885 "no capability is exposed as `majordomus {}`",
886 path.join(" ")
887 ),
888 })?;
889 ctx.execute(&id, input).map_err(|e| Error::Protocol {
890 reason: e.to_string(),
891 })
892 };
893
894 let catalogue = run(&["why", "list"], serde_json::json!({ "status": "any" }))?;
898 let mut moments = Vec::new();
899 for m in catalogue["moments"].as_array().into_iter().flatten() {
900 let id = m["id"].as_str().unwrap_or_default();
901 let mut detail = run(&["why", "show"], serde_json::json!({ "id": id }))?;
902 if let Some(o) = detail.as_object_mut() {
906 o.remove("body");
907 }
908 moments.push(detail);
909 }
910 let validation = run(&["why", "validate"], serde_json::json!({}))?;
911
912 let strip = |v: &serde_json::Value| -> Vec<serde_json::Value> {
914 v.as_array()
915 .into_iter()
916 .flatten()
917 .map(|e| {
918 let mut e = e.clone();
919 if let Some(o) = e.as_object_mut() {
920 o.remove("body");
921 }
922 e
923 })
924 .collect()
925 };
926 let audiences = strip(&catalogue["audiences"]);
927 let areas = strip(&catalogue["areas"]);
928
929 let mut signals = Vec::new();
932 for m in &moments {
933 if m["status"] != "stable" {
934 continue;
935 }
936 for s in m["signals"].as_array().into_iter().flatten() {
937 signals.push(serde_json::json!({
938 "id": s["id"],
939 "text": s["text"],
940 "moment": m["id"],
941 }));
942 }
943 }
944
945 let document = serde_json::json!({
946 "schema": WHY_SCHEMA,
947 "generated": crate::generate::json_banner(WHY_SOURCE),
948 "generator": { "id": "majordomus-cli", "version": crate::VERSION },
949 "fingerprint": catalogue["fingerprint"],
950 "route": crate::why::ROUTE,
951 "counts": catalogue["counts"],
952 "facets": catalogue["facets"],
953 "audiences": audiences,
954 "areas": areas,
955 "signals": signals,
956 "moments": moments,
957 "valid": validation["valid"],
958 });
959
960 graph_artifact(ctx, "why", "why-graph.json", "site-why-graph", GRAPH_SOURCE).map(|graph| {
961 vec![
962 crate::generate::Artifact::verbatim(
963 format!("{}/why.json", crate::generate::SITE_DATA_DIR),
964 "site-why",
965 crate::generate::ArtifactFormat::Json,
966 Some(WHY_SCHEMA.to_string()),
967 WHY_SOURCE,
968 render_json(&document),
969 ),
970 graph,
971 ]
972 })
973}
974
975fn render_json(v: &serde_json::Value) -> String {
976 let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
977 s.push('\n');
978 s
979}
980
981pub const PRODUCT_SCHEMA: &str = "majordomus-site-product/v1";
985
986pub const PRODUCT_SOURCE: &str =
988 "the product features of this repository's layer, resolved against the registry, the index, the catalogue and the topology";
989
990pub const PUBLIC_FEATURE_FIELDS: &[&str] = &[
994 "id",
995 "title",
996 "short_title",
997 "headline",
998 "summary",
999 "status",
1000 "weight",
1001 "featured",
1002 "domain",
1003 "areas",
1004 "audiences",
1005 "modules",
1006 "commands",
1007 "kinds",
1008 "rules",
1009 "docs",
1010 "adrs",
1011 "claims",
1012 "use_cases",
1013 "cockpit",
1014 "web",
1015 "related",
1016 "tags",
1017 "route",
1018 "source",
1019 "surfaces",
1020 "module_refs",
1021 "command_refs",
1022 "kind_refs",
1023 "rule_refs",
1024 "doc_refs",
1025 "adr_refs",
1026 "claim_refs",
1027 "use_case_refs",
1028 "cockpit_refs",
1029 "web_refs",
1030 "moments",
1031 "backlinks",
1032 "counts",
1033 "evidence",
1034 "domain_ref",
1035];
1036
1037pub const PUBLIC_DOMAIN_FIELDS: &[&str] = &[
1041 "id",
1042 "title",
1043 "headline",
1044 "problem",
1045 "status",
1046 "weight",
1047 "tags",
1048 "route",
1049 "source",
1050 "features",
1051 "surfaces",
1052 "moments",
1053 "claim_refs",
1054 "use_case_refs",
1055 "counts",
1056 "evidence",
1057];
1058
1059pub const EVIDENCE_PRODUCER: &str = "majordomus evidence show";
1061
1062pub fn claim_evidence(report: &serde_json::Value) -> serde_json::Value {
1096 let unsupported: std::collections::BTreeSet<&str> = report["findings"]
1097 .as_array()
1098 .into_iter()
1099 .flatten()
1100 .filter_map(|f| f["claim"].as_str())
1101 .collect();
1102 let complete = report["subject"]["complete"].as_bool() == Some(true);
1103 let present = report["ledger"]["present"].as_bool() == Some(true);
1104 let mut claims = serde_json::Map::new();
1105 let (mut declared, mut supported) = (0usize, 0usize);
1106 for c in report["claims"].as_array().into_iter().flatten() {
1107 let Some(id) = c["id"].as_str() else { continue };
1108 let guaranteed = c["status"].as_str() == Some("guaranteed");
1109 let is_supported = complete && guaranteed && !unsupported.contains(id);
1110 declared += usize::from(guaranteed);
1111 supported += usize::from(is_supported);
1112 let state = match c["state"].as_str().unwrap_or("unknown") {
1113 "proven" => "inputs-unchanged".to_string(),
1114 s => s.replace('_', "-"),
1115 };
1116 claims.insert(
1117 id.to_string(),
1118 serde_json::json!({
1119 "status": c["status"],
1120 "state": state,
1121 "label": state.replace('-', " "),
1122 "supported": is_supported,
1123 }),
1124 );
1125 }
1126 serde_json::json!({
1127 "producer": EVIDENCE_PRODUCER,
1128 "available": complete && present,
1129 "complete": complete,
1130 "ledger": {
1131 "path": report["ledger"]["path"],
1132 "executions": report["ledger"]["executions"],
1133 "newest": report["ledger"]["newest"],
1134 },
1135 "declared": declared,
1136 "supported": supported,
1137 "claims": claims,
1138 })
1139}
1140
1141pub fn allowed(v: &serde_json::Value, fields: &[&str]) -> serde_json::Value {
1152 serde_json::Value::Object(
1153 v.as_object()
1154 .into_iter()
1155 .flatten()
1156 .filter(|(k, _)| fields.contains(&k.as_str()))
1157 .map(|(k, v)| (k.clone(), v.clone()))
1158 .collect(),
1159 )
1160}
1161
1162pub fn product_artifacts(ctx: &Context) -> Result<Vec<crate::generate::Artifact>> {
1177 let by_cli = |path: &[&str]| -> Option<String> {
1178 ctx.registry
1179 .by_cli(&path.iter().map(|w| w.to_string()).collect::<Vec<_>>())
1180 .map(|c| c.id.to_string())
1181 };
1182 let run = |path: &[&str], input: serde_json::Value| -> Result<serde_json::Value> {
1183 let id = by_cli(path).ok_or_else(|| Error::Protocol {
1184 reason: format!(
1185 "no capability is exposed as `majordomus {}`",
1186 path.join(" ")
1187 ),
1188 })?;
1189 ctx.execute(&id, input).map_err(|e| Error::Protocol {
1190 reason: e.to_string(),
1191 })
1192 };
1193
1194 let list = run(&["product", "list"], serde_json::json!({ "status": "any" }))?;
1195 let mut features = Vec::new();
1196 for f in list["features"].as_array().into_iter().flatten() {
1197 let id = f["id"].as_str().unwrap_or_default();
1198 let detail = run(&["product", "show"], serde_json::json!({ "id": id }))?;
1199 let mut public = serde_json::Map::new();
1202 if let Some(o) = detail.as_object() {
1203 for (k, v) in o {
1204 if PUBLIC_FEATURE_FIELDS.contains(&k.as_str()) {
1205 public.insert(k.clone(), v.clone());
1206 }
1207 }
1208 }
1209 features.push(serde_json::Value::Object(public));
1210 }
1211 let domains: Vec<serde_json::Value> = ctx
1214 .product
1215 .domains()
1216 .iter()
1217 .map(|d| {
1218 allowed(
1219 &serde_json::to_value(d).expect("a resolved domain serialises"),
1220 PUBLIC_DOMAIN_FIELDS,
1221 )
1222 })
1223 .collect();
1224 let matrix = run(&["product", "matrix"], serde_json::json!({}))?;
1225 let providers = run(&["product", "providers"], serde_json::json!({}))?;
1226 let validation = run(&["product", "validate"], serde_json::json!({}))?;
1227
1228 let summary = ctx.registry.summary();
1231 let by_kind: BTreeMap<String, usize> = ctx
1232 .index
1233 .kinds()
1234 .into_iter()
1235 .map(|(k, n)| (k.to_string(), n))
1236 .collect();
1237 let modules = ctx
1238 .registry
1239 .modules()
1240 .filter(|m| m.source != ModuleSource::Declarative)
1241 .count();
1242 let cli_commands = crate::cli::tree()
1243 .flatten()
1244 .iter()
1245 .filter(|c| c.executable)
1246 .count();
1247 let telemetry = serde_json::json!({
1248 "capabilities": summary.total,
1249 "builtin": summary.builtin,
1250 "declarative": summary.declarative,
1251 "modules": modules,
1252 "mcp_tools": summary.mcp_tools,
1253 "mcp_resources": summary.mcp_resources,
1254 "http_routes": summary.http_routes,
1255 "cli_commands": cli_commands,
1256 "shell_commands": list["counts"]["commands"],
1257 "kinds": by_kind.len(),
1258 "objects": ctx.index.objects.len(),
1259 "by_kind": by_kind,
1260 "providers": list["counts"]["providers"],
1261 "features": list["counts"]["features"],
1262 "domains": ctx.product.public_domains().iter().filter(|d| !d.features.is_empty()).count(),
1264 "web_surfaces": crate::generate::surfaces_every_checkout_has(&ctx.web).count(),
1267 });
1268
1269 let mut by_rule: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1274 for f in &features {
1275 if f["status"].as_str() == Some("draft") {
1276 continue;
1277 }
1278 for r in f["rule_refs"].as_array().into_iter().flatten() {
1279 if let Some(id) = r["id"].as_str() {
1280 by_rule.entry(id.to_string()).or_insert_with(|| r.clone());
1281 }
1282 }
1283 }
1284 let rules: Vec<serde_json::Value> = by_rule.into_values().collect();
1285
1286 let cockpit_areas: Vec<serde_json::Value> = crate::cockpit::nav::areas()
1287 .iter()
1288 .map(|a| serde_json::json!({ "id": a.id, "title": a.label, "route": a.href }))
1289 .collect();
1290
1291 let evidence = claim_evidence(&run(&["evidence", "show"], serde_json::json!({}))?);
1295
1296 let document = serde_json::json!({
1297 "schema": PRODUCT_SCHEMA,
1298 "generated": crate::generate::json_banner(PRODUCT_SOURCE),
1299 "generator": { "id": "majordomus-cli", "version": crate::VERSION },
1300 "fingerprint": list["fingerprint"],
1301 "route": crate::product::ROUTE,
1302 "domain_route": crate::product::DOMAIN_ROUTE,
1303 "counts": list["counts"],
1304 "surfaces": list["surfaces"],
1305 "domains": domains,
1306 "features": features,
1307 "matrix": { "rows": matrix["rows"], "modules": matrix["modules"], "commands": matrix["commands"], "kinds": matrix["kinds"] },
1308 "providers": providers["providers"],
1309 "rules": rules,
1310 "cockpit_areas": cockpit_areas,
1311 "telemetry": telemetry,
1312 "evidence": evidence,
1313 "valid": validation["valid"],
1314 });
1315
1316 graph_artifact(
1317 ctx,
1318 "product",
1319 "product-graph.json",
1320 "site-product-graph",
1321 "the features, what they are made of, and the interfaces that follow, as the derived `product` graph",
1322 )
1323 .map(|graph| {
1324 vec![
1325 crate::generate::Artifact::verbatim(
1326 format!("{}/product.json", crate::generate::SITE_DATA_DIR),
1327 "site-product",
1328 crate::generate::ArtifactFormat::Json,
1329 Some(PRODUCT_SCHEMA.to_string()),
1330 PRODUCT_SOURCE,
1331 render_json(&document),
1332 ),
1333 graph,
1334 ]
1335 })
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340 use super::*;
1341
1342 #[test]
1343 fn a_committed_graph_is_derived_whole_or_refused() {
1344 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1345 let ctx = repo.context().unwrap();
1346 let why = graph_artifact(&ctx, "why", "w.json", "w", "the why graph").unwrap();
1347 assert_eq!(
1348 why.path,
1349 format!("{}/w.json", crate::generate::SITE_DATA_DIR)
1350 );
1351 let refused = graph_artifact(&ctx, "no-such-graph", "n.json", "n", "nothing")
1352 .map(|_| ())
1353 .unwrap_err()
1354 .to_string();
1355 assert!(
1356 refused.contains("derives no `no-such-graph` graph"),
1357 "{refused}"
1358 );
1359 }
1360
1361 fn repository_with(files: &[(&str, &str)]) -> tempfile::TempDir {
1364 let root = tempfile::tempdir().unwrap();
1365 let dir = root.path().join(crate::capability::model::CRATE_DIR);
1366 std::fs::create_dir_all(dir.join("src")).unwrap();
1367 std::fs::write(
1368 dir.join("Cargo.toml"),
1369 "[package]\nname = \"majordomus-cli\"\nversion = \"0.1.0\"\n",
1370 )
1371 .unwrap();
1372 for (path, text) in files {
1373 let p = dir.join("src").join(path);
1374 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1375 std::fs::write(p, text).unwrap();
1376 }
1377 root
1378 }
1379
1380 #[test]
1381 fn a_module_links_the_reference_page_only_when_the_crate_exports_it() {
1382 let repo = repository_with(&[
1383 ("lib.rs", "//! Root.\npub mod capability;\n"),
1384 ("capability/mod.rs", "//! Capabilities.\npub mod builtin;\n"),
1385 (
1386 "capability/builtin/mod.rs",
1387 "//! Builtin.\npub mod open;\npub(crate) mod crated;\nmod closed;\n",
1388 ),
1389 ("capability/builtin/open.rs", "//! Open.\n"),
1390 ("capability/builtin/crated.rs", "//! Crate-visible.\n"),
1391 ("capability/builtin/closed.rs", "//! Private.\n"),
1392 ]);
1393 let pages = reference_pages(repo.path()).unwrap();
1394 let open = &pages["majordomus_cli::capability::builtin::open"];
1397 assert_eq!(
1398 open.path,
1399 "majordomus_cli/capability/builtin/open/index.html"
1400 );
1401 assert_eq!(open.module, "majordomus_cli::capability::builtin::open");
1402 assert_eq!(open.surface, crate::web::discover::RUSTDOC);
1403 assert!(!pages.contains_key("majordomus_cli::capability::builtin::crated"));
1405 assert!(!pages.contains_key("majordomus_cli::capability::builtin::closed"));
1406
1407 let bare = tempfile::tempdir().unwrap();
1409 assert!(reference_pages(bare.path()).unwrap().is_empty());
1410 }
1411
1412 #[test]
1413 fn a_crate_that_cannot_be_read_is_an_error_and_never_an_empty_mapping() {
1414 let repo = repository_with(&[
1416 ("lib.rs", "//! Root.\npub mod broken;\n"),
1417 ("broken.rs", "pub fn ("),
1418 ]);
1419 assert!(reference_pages(repo.path()).is_err());
1420 }
1421}