Skip to main content

majordomus_cli/
site.rs

1//! The site's registry dataset: what GitHub Pages renders about the executable, derived
2//! from the registry, the index, the command line, the benchmark projection and the
3//! accepted baselines, and nothing else. The site has no content model of its own for
4//! any of it; its templates read `site/data/registry/registry.json` and lay it out, and
5//! the shell site generator reads `docs/generated/registry.json` for the ids it turns into
6//! routes.
7//!
8//! Deterministic: same repository tree and same executable, same bytes. No timestamps of
9//! its own (a baseline's `finished_at` is observed evidence, copied as committed), no
10//! absolute paths, no git state (the site's `source.json` carries the commit; it is
11//! written at build time by the site generator and is not compared by `generate --check`).
12
13use 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
31/// The dataset's own format version, distinct from the crate's.
32pub const SCHEMA: &str = "majordomus-site-registry/v2";
33
34/// The whole dataset.
35#[derive(Debug, Clone, Serialize)]
36pub struct SiteRegistry {
37    /// [`SCHEMA`].
38    pub schema: &'static str,
39    /// That it is generated, in the words every other generated document uses: this file
40    /// is a cache of the registry and the index, and editing it is editing a cache.
41    pub generated: String,
42    /// Who wrote it.
43    pub generator: Generator,
44    /// The capability registry, fingerprinted and counted, with the builtin entries in full.
45    pub registry: RegistryView,
46    /// The index of the layer: fingerprint, counts and every object.
47    pub index: IndexView,
48    /// The kinds the executable reads, with the schema each is validated against.
49    pub kinds: Vec<KindView>,
50    /// The provider projections the policy declares.
51    pub projections: Vec<ProjectionView>,
52    /// The command line, as clap declares it.
53    pub cli: CommandDoc,
54    /// The MCP surface: the tools and the resources the builtin capabilities project.
55    pub mcp: McpView,
56    /// The HTTP surface: the routes the capabilities project and the projection's own.
57    pub http: HttpView,
58    /// The benchmark system: targets, coverage, the regression policy, the accepted baselines.
59    pub benchmarks: BenchmarksView,
60}
61
62/// The generator's identity.
63#[derive(Debug, Clone, Serialize)]
64pub struct Generator {
65    /// `majordomus-cli`.
66    pub id: &'static str,
67    /// The crate version.
68    pub version: &'static str,
69}
70
71/// The capability registry as the site shows it.
72#[derive(Debug, Clone, Serialize)]
73pub struct RegistryView {
74    /// The registry's fingerprint: the index's plus every descriptor.
75    pub fingerprint: String,
76    /// The counts.
77    pub summary: Summary,
78    /// The builtin capabilities in full, by id. The declarative ones are the index's
79    /// objects, one resource each; listing them twice would say nothing new.
80    pub builtin: Vec<CapabilityView>,
81    /// The modules, by id: the builtin ones with their capabilities, the declarative ones
82    /// (one per kind) with their count.
83    pub modules: Vec<ModuleView>,
84}
85
86/// One builtin capability: the canonical descriptor and where its source is.
87#[derive(Debug, Clone, Serialize)]
88pub struct CapabilityView {
89    #[serde(flatten)]
90    /// The descriptor, every field.
91    pub capability: Capability,
92    /// Repository-relative path of the Rust file the descriptor was composed in.
93    pub source_path: String,
94}
95
96/// The view orders as the capability it carries: the source path it adds is provenance.
97impl crate::order::Ordered for CapabilityView {
98    fn order_key(&self) -> crate::order::OrderKey<'_> {
99        self.capability.order_key()
100    }
101}
102
103/// One module.
104#[derive(Debug, Clone, Serialize)]
105pub struct ModuleView {
106    /// The module id.
107    pub id: String,
108    /// The short name.
109    pub title: String,
110    /// One paragraph.
111    pub description: String,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    /// Where the module stands, when it declared it.
114    pub stability: Option<Stability>,
115    /// `builtin` or `declarative`.
116    pub source: String,
117    /// Capabilities it composes.
118    pub capabilities: usize,
119    /// The ids of its builtin capabilities, in registry order; empty for a declarative module.
120    pub capability_ids: Vec<String>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    /// Repository-relative path of the Rust file the module's descriptors were composed
123    /// in, for a builtin module.
124    pub source_path: Option<String>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    /// The page the crate's published reference documents the module on, for a builtin
127    /// module whose Rust module the crate exports. Absent for a module rustdoc gives no
128    /// page (`pub(crate)` or private), for a declarative module, and for every module of a
129    /// repository without the crate.
130    pub rustdoc: Option<ReferencePage>,
131}
132
133/// A page of the crate's reference: the web surface that publishes it and the page within
134/// it, as the rustdoc check derives it from the crate's own inventory.
135///
136/// The mount is deliberately not here. Whether the reference is composed into a build, and
137/// at which mount, is the build's fact — `scripts/site-build` records every surface it
138/// composed in `data/build.json` — so a page links this one only when the build that
139/// renders it carries the surface, and at the mount it was composed at.
140///
141/// ```
142/// use majordomus_cli::site::ReferencePage;
143/// use majordomus_cli::web::discover::RUSTDOC;
144/// let page = ReferencePage {
145///     surface: RUSTDOC,
146///     module: "majordomus_cli::capability::builtin::quality".into(),
147///     path: "majordomus_cli/capability/builtin/quality/index.html".into(),
148/// };
149/// let v = serde_json::to_value(&page).unwrap();
150/// assert_eq!(v["surface"], "rustdoc");
151/// // relative to the surface: the mount is the build's, joined by the page that links it
152/// assert!(!v["path"].as_str().unwrap().starts_with('/'));
153/// ```
154#[derive(Debug, Clone, Serialize)]
155pub struct ReferencePage {
156    /// The web surface the page is published under: [`crate::web::discover::RUSTDOC`].
157    pub surface: &'static str,
158    /// The Rust module's crate path: `majordomus_cli::capability::builtin::quality`.
159    pub module: String,
160    /// The page, relative to the surface's root:
161    /// `majordomus_cli/capability/builtin/quality/index.html`.
162    pub path: String,
163}
164
165/// The index as the site shows it.
166#[derive(Debug, Clone, Serialize)]
167pub struct IndexView {
168    /// Hash of every object's path and content.
169    pub fingerprint: String,
170    /// `ok`, `degraded`, ... as the index reports itself.
171    pub state: String,
172    /// Layer schema, e.g. `ai-repository/v1`.
173    pub layer_schema: String,
174    /// Manifest sections, name to repository-relative path.
175    pub sections: BTreeMap<String, String>,
176    /// How many objects of each kind.
177    pub by_kind: BTreeMap<String, usize>,
178    /// Diagnostics counted by severity.
179    pub diagnostics: BTreeMap<String, usize>,
180    /// Every object, by URI.
181    pub objects: Vec<ObjectView>,
182}
183
184/// One object of the index, without its content.
185#[derive(Debug, Clone, Serialize)]
186pub struct ObjectView {
187    /// `majordomus://<kind>/<identity>`.
188    pub uri: String,
189    /// The kind.
190    pub kind: String,
191    /// The identity within the kind.
192    pub identity: String,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    /// The title, when the kind has one.
195    pub title: Option<String>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    /// The one-line description, when the kind has one.
198    pub description: Option<String>,
199    /// Repository-relative source path.
200    pub path: String,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    /// The manifest section, when the path falls under one.
203    pub section: Option<String>,
204    /// The `sources.yaml` class that discovered it.
205    pub source_class: String,
206    /// Size in bytes.
207    pub bytes: u64,
208    #[serde(skip_serializing_if = "Vec::is_empty")]
209    /// Declared tags.
210    pub tags: Vec<String>,
211}
212
213/// A module is presented by its id, the same string its capabilities are prefixed with.
214impl 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/// One kind.
221#[derive(Debug, Clone, Serialize)]
222pub struct KindView {
223    /// The kind's name.
224    pub name: String,
225    /// `markdown`, `yaml` or `text`.
226    pub format: String,
227    /// `required`, `optional` or `none`.
228    pub front_matter: String,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    /// The JSON Schema it is validated against, when it declares one.
231    pub schema: Option<String>,
232    /// The identity fields, joined with `@`; empty means the path.
233    pub identity: Vec<String>,
234    /// Objects of this kind in the index.
235    pub objects: usize,
236}
237
238/// A kind is presented by its name, which is the identity the whole index files objects
239/// under; there is nothing else about a kind that a reader would look it up by.
240impl 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/// One declared provider projection.
247#[derive(Debug, Clone, Serialize)]
248pub struct ProjectionView {
249    /// The provider.
250    pub provider: String,
251    /// The target, repository-relative.
252    pub target: String,
253    /// `file` or `region`.
254    pub mode: String,
255    /// Loaded by every worker without asking.
256    pub always_loaded: bool,
257}
258
259/// The MCP surface as the registry projects it.
260#[derive(Debug, Clone, Serialize)]
261pub struct McpView {
262    /// The server name `initialize` answers with.
263    pub server_name: &'static str,
264    /// The protocol versions accepted, newest first.
265    pub protocol_versions: &'static [&'static str],
266    /// The tools, in registry order.
267    pub tools: Vec<ToolView>,
268    /// The resources builtin capabilities answer (`majordomus://repository`, ...), in
269    /// registry order. Every object of the layer is one more resource; those are the
270    /// index's objects.
271    pub resources: Vec<ResourceView>,
272    /// The URI shape of a declarative object's resource.
273    pub resource_template: &'static str,
274}
275
276/// One MCP tool.
277#[derive(Debug, Clone, Serialize)]
278pub struct ToolView {
279    /// The tool name a client calls.
280    pub name: String,
281    /// The canonical id the tool projects.
282    pub id: String,
283    /// The title.
284    pub title: String,
285    /// The description.
286    pub description: String,
287    /// `query`, `command` or `resource`.
288    pub kind: CapabilityKind,
289    /// Leaves the process as it found it.
290    pub read_only: bool,
291    /// What a call changes: nothing, this process's memory, or the repository.
292    pub effect: crate::capability::Effect,
293    /// What a caller may assume before calling, classified from the effect.
294    pub hints: crate::capability::Hints,
295    /// The input type's name.
296    pub input: String,
297    /// The output type's name.
298    pub output: String,
299}
300
301/// The URI shape of a declarative object's resource, as every surface that describes the
302/// MCP projection states it.
303pub const MCP_RESOURCE_TEMPLATE: &str = "majordomus://<kind>/<identity>";
304
305/// One MCP resource a builtin capability answers.
306#[derive(Debug, Clone, Serialize)]
307pub struct ResourceView {
308    /// The URI a client reads.
309    pub uri: String,
310    /// The short name.
311    pub name: String,
312    /// The canonical id.
313    pub id: String,
314    /// The title.
315    pub title: String,
316    /// `query`, `command` or `resource`.
317    pub kind: CapabilityKind,
318}
319
320/// The HTTP surface as the registry projects it.
321#[derive(Debug, Clone, Serialize)]
322pub struct HttpView {
323    /// The capability routes, in registry order.
324    pub routes: Vec<RouteView>,
325    /// The projection's own routes, not capabilities: each with what it is and whether a
326    /// publication can carry it, so a page can tell a link from a promise.
327    pub infrastructure: Vec<crate::web::ProjectionRoute>,
328    /// Where the OpenAPI document is committed, repository-relative.
329    pub openapi_path: String,
330}
331
332/// One HTTP route.
333#[derive(Debug, Clone, Serialize)]
334pub struct RouteView {
335    /// `GET` or `POST`.
336    pub method: String,
337    /// The path.
338    pub path: String,
339    /// The canonical id, the document's `operationId`.
340    pub id: String,
341    /// `query`, `command` or `resource`.
342    pub kind: CapabilityKind,
343}
344
345/// The benchmark system as the site shows it.
346#[derive(Debug, Clone, Serialize)]
347pub struct BenchmarksView {
348    /// Every target of this repository, by key: what `majordomus bench` times.
349    pub targets: Vec<TargetView>,
350    /// The coverage: every requirement and where it stands, with the tallies.
351    pub coverage: Coverage,
352    /// The system targets: the transports' own operations.
353    pub system_targets: Vec<SystemTargetView>,
354    /// The regression policy `bench --check` applies, from
355    /// `.ai/repo/benchmarks/rust/policy.yaml` or the default.
356    pub policy: Policy,
357    /// Where the policy lives, repository-relative.
358    pub policy_path: String,
359    /// The accepted baselines, one per platform, as committed.
360    pub baselines: Vec<BaselineView>,
361}
362
363/// One benchmark target.
364#[derive(Debug, Clone, Serialize)]
365pub struct TargetView {
366    /// `<id>|<transport>|<case>` or `system.<transport>.<name>`.
367    pub key: String,
368    /// `capability`, `system` or `page`.
369    pub kind: &'static str,
370    /// The transport.
371    pub transport: String,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    /// The capability id, for a capability target.
374    pub id: Option<String>,
375    /// The module, or `system`.
376    pub module: String,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    /// The case name, for a capability target.
379    pub case: Option<String>,
380    /// Measured cold and warm as well as uncached.
381    pub cached: bool,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    /// The MCP tool name, on the MCP transport.
384    pub tool: Option<String>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    /// `METHOD /path`, on the HTTP transport.
387    pub route: Option<String>,
388}
389
390/// One system target.
391#[derive(Debug, Clone, Serialize)]
392pub struct SystemTargetView {
393    /// The key.
394    pub key: String,
395    /// The transport.
396    pub transport: String,
397    /// What it measures.
398    pub description: String,
399}
400
401/// One accepted baseline.
402#[derive(Debug, Clone, Serialize)]
403pub struct BaselineView {
404    /// `<os>-<arch>-<build>`, from the file name.
405    pub platform: String,
406    /// Repository-relative path of the file.
407    pub path: String,
408    /// The profile the run used.
409    pub profile: String,
410    /// When the run finished, RFC 3339 UTC; observed evidence, copied as committed.
411    pub finished_at: String,
412    /// What measured: commit, build profile, OS, architecture, host, executable version,
413    /// registry fingerprint.
414    pub provenance: RunProvenance,
415    /// Every measurement.
416    pub results: Vec<MeasurementView>,
417}
418
419/// One measurement of a baseline.
420#[derive(Debug, Clone, Serialize)]
421pub struct MeasurementView {
422    /// The target's key.
423    pub key: String,
424    /// `uncached`, `cold`, `warm`, or `n/a` for a system target.
425    pub cache_mode: String,
426    /// How many samples.
427    pub samples: usize,
428    /// The median, microseconds.
429    pub p50_us: f64,
430    /// The 95th percentile, microseconds.
431    pub p95_us: f64,
432    /// The 99th percentile, microseconds.
433    pub p99_us: f64,
434    #[serde(skip_serializing_if = "Option::is_none")]
435    /// Handler invocations during the samples, when known.
436    pub handler_invocations: Option<u64>,
437}
438
439/// The crate's exported modules, by crate path, each with the page its reference documents
440/// it on: `quality::rustdoc::module_routes`, the derivation `majordomus quality rustdoc`
441/// holds the published tree to, read here rather than restated. It is read from the crate's
442/// source, so the dataset is the same on every machine whether or not the tree was built;
443/// a repository without the crate has no reference, and no module links one.
444fn 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        // a module rustdoc documents twice (under two re-exports) is linked at the first
451        // page in canonical order, which is the order the routes are answered in
452        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
463/// Build the dataset.
464pub 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
728/// Every `baseline.<platform>.json` beside the policy, by platform, as committed. A
729/// baseline that does not parse is an error: a stale evidence file is not silently
730/// dropped from the page that presents it.
731fn 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
801/// The dataset as the committed file: pretty JSON, trailing newline.
802pub 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
808// ---------------------------------------------------------------- the Why catalogue
809
810/// The dataset's own format version.
811pub const WHY_SCHEMA: &str = "majordomus-site-why/v1";
812
813/// Where `why.json` says it came from.
814pub const WHY_SOURCE: &str =
815    "the operational moments, audiences and areas of this repository's layer";
816
817/// Where `why-graph.json` says it came from.
818pub const GRAPH_SOURCE: &str = "the moments and what answers them, as the derived `why` graph";
819
820/// A graph the site commits as data, as the artifact at `file` under the site's data
821/// directory: derived whole or refused ([`crate::graph::derive_whole`]), never a prefix of
822/// the repository, and carrying the provenance members every generated document carries (the
823/// graph is a value of the domain and has none of its own). The one place both site graphs
824/// are taken.
825fn 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            // a graph serializes to an object; its members and the provenance are one map
836            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
860/// The Why catalogue and its graph, as the site's templates read them:
861/// `site/data/registry/why.json` and `site/data/registry/why-graph.json`.
862///
863/// Both are projections of [`crate::why::Catalogue`], which the context already built.
864/// Nothing here re-reads a file, and nothing here is a second opinion about what a moment
865/// says: the site generator writes one page per entry from this document, and the
866/// templates read it for every listing, filter, count, backlink and questionnaire.
867///
868/// The bodies are not in it: a page's prose is read from the file the record names in
869/// `source`, so the dataset stays the metadata every listing, filter, count and backlink
870/// needs and never becomes a second copy of the writing.
871///
872/// Deterministic and index-independent: the payload depends on the catalogue's own
873/// sources and on nothing else, so `scripts/derive` — which runs `generate`, then the
874/// site generator, then `generate` again over the tree that left — produces the same
875/// bytes in both passes.
876pub 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    // the catalogue, then one detail per moment: the site needs the derived relations and
895    // the body, and asking the capability for them is what keeps this from being a second
896    // reading of the same files
897    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        // The prose stays where it was authored. Every page the site generator writes
903        // takes the body from the file this record names in `source`, so the dataset is
904        // the metadata a template reads and not a second copy of the writing.
905        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    // the same for the two taxonomies: their pages take the prose from their own files
913    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    // the questionnaire's index: every signal with the moment that owns it, flat, so a
930    // template renders it without joining two lists and a script never learns a mapping
931    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
981// ---------------------------------------------------------------- the product model
982
983/// The dataset's own format version.
984pub const PRODUCT_SCHEMA: &str = "majordomus-site-product/v1";
985
986/// Where `product.json` says it came from.
987pub 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
990/// The fields of one feature the public dataset carries. An allow-list, not an exclusion:
991/// a field added to the resolved view reaches the published site only when it is named
992/// here, so a value that names a machine or an internal state cannot arrive by accident.
993pub 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
1037/// The fields of one domain the public dataset carries: an allow-list, for the reason
1038/// [`PUBLIC_FEATURE_FIELDS`] is one. The body stays in the file the record names in
1039/// `source`, as a feature's does.
1040pub 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
1059/// Where the site's evidence verdicts come from, named in the dataset beside them.
1060pub const EVIDENCE_PRODUCER: &str = "majordomus evidence show";
1061
1062/// The evidence verdict of every claim, as the site renders it beside the declared status:
1063/// the `evidence` section of `product.json`.
1064///
1065/// A projection of the `evidence show` report, never a second opinion about it. A claim is
1066/// `supported` when it declares `guaranteed` and the report has no finding against it. The
1067/// state is the report's own word, hyphenated the way the design vocabulary spells it, with
1068/// one deliberate weakening: `proven` is published as `inputs-unchanged`. This dataset is
1069/// committed, and committing it is itself a change after any recorded run, so a verdict it
1070/// carries can never be proof of the tree it is read from.
1071///
1072/// `available` is false when the ledger holds nothing or the index could not read the
1073/// whole matrix: the verdicts are then about nothing, and a page says `unknown`.
1074///
1075/// ```
1076/// use majordomus_cli::site::claim_evidence;
1077/// let report = serde_json::json!({
1078///     "subject": {"complete": true},
1079///     "ledger": {"path": "l.json", "present": true, "executions": 2, "newest": "2026-09-01"},
1080///     "claims": [
1081///         {"id": "a", "status": "guaranteed", "state": "proven"},
1082///         {"id": "b", "status": "guaranteed", "state": "not_run"},
1083///         {"id": "c", "status": "advisory", "state": "no_test"}
1084///     ],
1085///     "findings": [{"claim": "b"}]
1086/// });
1087/// let e = claim_evidence(&report);
1088/// assert_eq!(e["available"], true);
1089/// assert_eq!(e["declared"], 2);
1090/// assert_eq!(e["supported"], 1);
1091/// assert_eq!(e["claims"]["a"]["state"], "inputs-unchanged", "never published as proven");
1092/// assert_eq!(e["claims"]["b"]["supported"], false);
1093/// assert_eq!(e["claims"]["c"]["supported"], false, "only a guarantee is judged");
1094/// ```
1095pub 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
1141/// The fields of `v` an allow-list names, and nothing else: what reaches the published site is
1142/// what was named, not what was not excluded.
1143///
1144/// ```
1145/// use majordomus_cli::site::allowed;
1146/// let v = serde_json::json!({"id": "context", "body": "prose", "route": "/domains/context/"});
1147/// let public = allowed(&v, &["id", "route"]);
1148/// assert_eq!(public, serde_json::json!({"id": "context", "route": "/domains/context/"}));
1149/// assert_eq!(allowed(&serde_json::json!("not an object"), &["id"]), serde_json::json!({}));
1150/// ```
1151pub 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
1162/// The product model as the site's templates read it: `site/data/registry/product.json`.
1163///
1164/// A projection of [`crate::product::ProductModel`] through the `product.*` capabilities, so the
1165/// homepage renders what the API and MCP answer. The bodies are not in it: a feature's
1166/// page takes its prose from the file the record names in `source`, so the dataset stays
1167/// the metadata every card, chapter, matrix row and count needs and never a second copy of
1168/// the writing.
1169///
1170/// Every feature is copied field by field through [`PUBLIC_FEATURE_FIELDS`]: the site is
1171/// public, and what reaches it is what was named, not what was not excluded.
1172///
1173/// Index-independent, like the Why dataset: the payload depends on the features, the
1174/// registry, the catalogue and the topology and never on the index's fingerprint, so both
1175/// `generate` passes of `scripts/derive` produce the same bytes.
1176pub 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        // the allow-list, applied: the body stays in the file, and nothing the view did not
1200        // name reaches the site
1201        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    // The domains of every status, read from the model `product.domains` answers from — the
1212    // same value, not a second derivation — and copied through their own allow-list.
1213    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    // the telemetry the homepage shows: every number a fact of the registry and the index,
1229    // counted here once so that no template counts and no template can be handed a number
1230    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        // the domains a reader is shown: stable, and named by a stable feature
1263        "domains": ctx.product.public_domains().iter().filter(|d| !d.features.is_empty()).count(),
1264        // what every checkout has, as web.json lists it: a report a producer wrote here is
1265        // served here and counted nowhere, or the count would follow who ran the suite
1266        "web_surfaces": crate::generate::surfaces_every_checkout_has(&ctx.web).count(),
1267    });
1268
1269    // The doctrine the product rests on: every rule a public feature names, once, carrying the
1270    // class and the enforcement the rule itself declares. Deduplicated here and not in a
1271    // template, because which rules the product rests on is a fact of the model, and a page
1272    // that had to work it out would be a page inferring domain structure.
1273    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    // What the recorded evidence says about each claim, from the one derivation of it
1292    // (`majordomus evidence show`), so that a page can put the declared status beside the
1293    // verdict and never render the first as the second.
1294    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    /// A repository holding the crate at the path this repository's crate lives, with the
1362    /// given sources under `src/`.
1363    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        // a module the crate exports is linked at the page rustdoc documents it on, under the
1395        // surface the topology publishes the reference as
1396        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        // one rustdoc gives no page is never linked: `pub(crate)` and private alike
1404        assert!(!pages.contains_key("majordomus_cli::capability::builtin::crated"));
1405        assert!(!pages.contains_key("majordomus_cli::capability::builtin::closed"));
1406
1407        // a repository without the crate has no reference and links nothing
1408        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        // half a crate would drop modules without saying so
1415        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}