1use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
14use crate::capability::handler::{CapabilityError, Context};
15use crate::capability::model::{CliExposure, Exposure, Stability};
16use crate::capability::module::ModuleDescriptor;
17use crate::distribution::release::{Channel, Releases};
18use crate::distribution::{Arch, Libc, Model, Os, Status};
19use crate::{capability, module};
20
21use super::{get, mcp, Empty};
22
23#[derive(Debug, Clone, Serialize, JsonSchema)]
26pub struct TargetView {
27 pub id: String,
29 pub title: String,
31 pub os: Os,
33 pub arch: Arch,
35 pub libc: Option<Libc>,
37 pub rust_target: String,
39 pub status: Status,
41 pub reason: Option<String>,
43 pub artifact: String,
45}
46
47#[derive(Debug, Clone, Serialize, JsonSchema)]
49pub struct DistributionReport {
50 pub binary: String,
52 pub repository: String,
54 pub installer_url: String,
56 pub install_command: String,
58 pub install_and_init_command: String,
60 pub next_command: String,
62 pub checksum: String,
64 pub install_dir: String,
66 pub prefix: String,
68 pub latest_url: String,
70 pub supported: usize,
72 pub targets: Vec<TargetView>,
74}
75
76#[derive(Debug, Clone, Serialize, JsonSchema)]
78pub struct ReleaseView {
79 pub tag: String,
81 pub version: String,
83 pub channel: Channel,
85 pub published_at: String,
87 pub commit: String,
89 pub yanked: bool,
91 pub artifacts: usize,
93 pub metadata_url: String,
95}
96
97#[derive(Debug, Clone, Serialize, JsonSchema)]
99pub struct ReleasesReport {
100 pub count: usize,
102 pub latest: Option<ReleaseView>,
104 pub releases: Vec<ReleaseView>,
106}
107
108#[derive(Debug, Clone, Serialize, JsonSchema)]
110pub struct BuildReport {
111 pub version: String,
113 pub target: String,
115 pub profile: String,
117 pub commit: String,
119 pub distribution_target: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126pub struct ReleaseArtifactInput {
127 pub target: String,
129 pub tag: String,
131}
132
133impl BenchmarkCases for ReleaseArtifactInput {
134 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
135 ctx.index
136 .distribution
137 .as_ref()
138 .and_then(|m| m.published().next())
139 .map(|t| {
140 vec![NamedCase::new(
141 "first-published-target",
142 ReleaseArtifactInput {
143 target: t.rust_target.clone(),
144 tag: "v0.0.0".into(),
145 },
146 )]
147 })
148 .unwrap_or_default()
149 }
150}
151
152#[derive(Debug, Clone, Serialize, JsonSchema)]
154pub struct ReleaseArtifactView {
155 pub target: String,
157 pub rust_target: String,
159 pub tag: String,
161 pub name: String,
163 pub root: String,
165 pub url: String,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172#[serde(rename_all = "lowercase")]
173pub enum CheckState {
174 Ok,
176 Failed,
178 Unknown,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
187pub struct InstallCheck {
188 pub id: String,
190 pub state: CheckState,
192 pub observed: String,
194 pub cause: Option<String>,
196 pub next: Option<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
210pub struct InstallabilityReport {
211 pub installable: bool,
213 pub summary: String,
215 pub local_version: String,
217 pub stable_tag: Option<String>,
219 pub required_targets: usize,
221 pub published_artifacts: usize,
223 pub install_command: String,
225 pub installer_url: String,
227 pub latest_url: String,
229 pub checks: Vec<InstallCheck>,
231}
232
233fn model(ctx: &Context) -> Result<&Model, CapabilityError> {
235 ctx.index.distribution.as_ref().ok_or_else(|| {
236 CapabilityError::NotFound(
237 "this installation carries no share/distribution.yaml, so it declares no distribution"
238 .into(),
239 )
240 })
241}
242
243fn distribution(ctx: &Context, _: Empty) -> Result<DistributionReport, CapabilityError> {
244 let m = model(ctx)?;
245 Ok(DistributionReport {
246 binary: m.project.binary.clone(),
247 repository: m.project.repository.clone(),
248 installer_url: m.installer_url(),
249 install_command: m.install_command(),
250 install_and_init_command: m.install_and_init_command(),
251 next_command: format!("{} init", m.project.binary),
252 checksum: m.archive.checksum.clone(),
253 install_dir: m.installer.install_dir.clone(),
254 prefix: m.installer.prefix.clone(),
255 latest_url: m.release_url(crate::distribution::release::LATEST),
256 supported: m.published().count(),
257 targets: m
258 .targets
259 .iter()
260 .map(|t| TargetView {
261 id: t.id.clone(),
262 title: t.title(&m.titles),
263 os: t.os,
264 arch: t.arch,
265 libc: t.libc,
266 rust_target: t.rust_target.clone(),
267 status: t.status,
268 reason: t.reason.clone(),
269 artifact: t.artifact_name(
270 &m.project,
271 &m.archive,
272 crate::distribution::render::TAG_PLACEHOLDER,
273 ),
274 })
275 .collect(),
276 })
277}
278
279fn view(m: &Model, r: &crate::distribution::Release) -> ReleaseView {
280 ReleaseView {
281 tag: r.tag.clone(),
282 version: r.version.clone(),
283 channel: r.channel,
284 published_at: r.published_at.clone(),
285 commit: r.commit.clone(),
286 yanked: r.yanked,
287 artifacts: r.artifacts.len(),
288 metadata_url: m.release_url(&r.tag),
289 }
290}
291
292fn releases(ctx: &Context, _: Empty) -> Result<ReleasesReport, CapabilityError> {
293 let m = model(ctx)?;
294 let records = Releases::from_index(&ctx.index).map_err(CapabilityError::Internal)?;
295 Ok(ReleasesReport {
296 count: records.releases.len(),
297 latest: records.latest_stable().map(|r| view(m, r)),
298 releases: records.releases.iter().map(|r| view(m, r)).collect(),
299 })
300}
301
302fn build(ctx: &Context, _: Empty) -> Result<BuildReport, CapabilityError> {
303 let distribution_target = ctx
304 .index
305 .distribution
306 .as_ref()
307 .and_then(|m| m.targets.iter().find(|t| t.rust_target == crate::TARGET))
308 .map(|t| t.id.clone());
309 Ok(BuildReport {
310 version: crate::VERSION.to_string(),
311 target: crate::TARGET.to_string(),
312 profile: crate::PROFILE.to_string(),
313 commit: crate::COMMIT.to_string(),
314 distribution_target,
315 })
316}
317
318fn artifact(
319 ctx: &Context,
320 input: ReleaseArtifactInput,
321) -> Result<ReleaseArtifactView, CapabilityError> {
322 let m = model(ctx)?;
323 let t = m
324 .targets
325 .iter()
326 .find(|t| t.id == input.target || t.rust_target == input.target)
327 .ok_or_else(|| {
328 CapabilityError::NotFound(format!(
329 "the distribution model declares no target `{}`",
330 input.target
331 ))
332 })?;
333 if !t.status.is_published() {
334 return Err(CapabilityError::Refused(format!(
335 "`{}` is declared and not built: {}",
336 t.id,
337 t.reason.as_deref().unwrap_or("no reason recorded")
338 )));
339 }
340 let tag = input.tag;
341 if tag != crate::distribution::render::TAG_PLACEHOLDER
342 && !(tag.starts_with('v') && tag[1..].starts_with(|c: char| c.is_ascii_digit()))
343 {
344 return Err(CapabilityError::InvalidInput(format!(
345 "`{tag}` is not a tag: a tag is `v` followed by a version"
346 )));
347 }
348 let name = t.artifact_name(&m.project, &m.archive, &tag);
349 Ok(ReleaseArtifactView {
350 target: t.id.clone(),
351 rust_target: t.rust_target.clone(),
352 url: format!("{}{tag}/{name}", m.project.download_prefix()),
353 root: t.archive_root(&m.project, &tag),
354 name,
355 tag,
356 })
357}
358
359fn status(ctx: &Context, _: Empty) -> Result<InstallabilityReport, CapabilityError> {
368 let m = model(ctx)?;
369 let records = Releases::from_index(&ctx.index).map_err(CapabilityError::Internal)?;
370 Ok(installability(m, &records))
371}
372
373pub(crate) fn installability(m: &Model, records: &Releases) -> InstallabilityReport {
380 let required = m.published().count();
381 let mut checks = Vec::new();
382
383 let model_findings = records.findings(m);
386 let model_ok = required > 0;
387 checks.push(InstallCheck {
388 id: "model".into(),
389 state: if model_ok {
390 CheckState::Ok
391 } else {
392 CheckState::Failed
393 },
394 observed: format!(
395 "{} declared target(s), {required} published",
396 m.targets.len()
397 ),
398 cause: (!model_ok)
399 .then(|| "the model publishes no target, so a release could build nothing".into()),
400 next: (!model_ok)
401 .then(|| "declare a target with `status: supported` in share/distribution.yaml".into()),
402 });
403
404 let local_version = crate::VERSION.to_string();
407 checks.push(InstallCheck {
408 id: "version".into(),
409 state: CheckState::Ok,
410 observed: format!("this tree releases v{local_version}"),
411 cause: None,
412 next: None,
413 });
414
415 let latest = records.latest_stable();
418 let stable_tag = latest.map(|r| r.tag.clone());
419 checks.push(InstallCheck {
420 id: "release".into(),
421 state: if latest.is_some() {
422 CheckState::Ok
423 } else {
424 CheckState::Failed
425 },
426 observed: match &stable_tag {
427 Some(t) => format!("the stable channel resolves to {t}"),
428 None => format!(
429 "no stable release is recorded ({} record(s) in total)",
430 records.releases.len()
431 ),
432 },
433 cause: latest.is_none().then(|| {
434 "there is no release record for the stable channel, so nothing derives the public \
435 metadata an unpinned installation reads"
436 .into()
437 }),
438 next: latest
439 .is_none()
440 .then(|| format!("git tag v{local_version} && git push origin v{local_version}")),
441 });
442
443 let published_artifacts = latest.map_or(0, |r| r.artifacts.len());
445 checks.push(InstallCheck {
446 id: "artifacts".into(),
447 state: match latest {
448 None => CheckState::Unknown,
449 Some(_) if published_artifacts >= required => CheckState::Ok,
450 Some(_) => CheckState::Failed,
451 },
452 observed: match latest {
453 None => "no release to count artifacts for".into(),
454 Some(_) => format!("{published_artifacts} of {required} required artifact(s)"),
455 },
456 cause: latest
457 .filter(|_| published_artifacts < required)
458 .map(|_| "the stable release does not publish every target the model requires; a partial release is not a release".into()),
459 next: latest
460 .filter(|_| published_artifacts < required)
461 .map(|_| "withdraw the record (yanked: true) and release again; the pipeline refuses to publish a partial release, so a record like this was not written by it".into()),
462 });
463
464 checks.push(InstallCheck {
467 id: "metadata".into(),
468 state: if model_findings.is_empty() {
469 CheckState::Ok
470 } else {
471 CheckState::Failed
472 },
473 observed: if model_findings.is_empty() {
474 format!("{} record(s) agree with the model", records.releases.len())
475 } else {
476 format!(
477 "{} finding(s): {}",
478 model_findings.len(),
479 model_findings.join("; ")
480 )
481 },
482 cause: (!model_findings.is_empty()).then(|| {
483 "a release record states something the distribution model does not derive".into()
484 }),
485 next: (!model_findings.is_empty()).then(|| "majordomus distribution validate".into()),
486 });
487
488 checks.push(InstallCheck {
491 id: "public".into(),
492 state: CheckState::Unknown,
493 observed: "not checked here; this report reaches no network".into(),
494 cause: None,
495 next: Some(format!(
496 "curl -fsSL {} | head -1",
497 m.release_url(crate::distribution::release::LATEST)
498 )),
499 });
500
501 let installable = checks.iter().all(|c| c.state != CheckState::Failed);
502 let summary = if installable {
503 match &stable_tag {
504 Some(t) => format!(
505 "{t} is recorded, complete and consistent; the published command installs it"
506 ),
507 None => "installable".into(),
508 }
509 } else {
510 checks
511 .iter()
512 .find(|c| c.state == CheckState::Failed)
513 .and_then(|c| c.cause.clone())
514 .unwrap_or_else(|| "the published installation command does not currently work".into())
515 };
516
517 InstallabilityReport {
518 installable,
519 summary,
520 local_version,
521 stable_tag,
522 required_targets: required,
523 published_artifacts,
524 install_command: m.install_command(),
525 installer_url: m.installer_url(),
526 latest_url: m.release_url(crate::distribution::release::LATEST),
527 checks,
528 }
529}
530
531pub fn module() -> ModuleDescriptor {
533 module! {
534 id: "distribution",
535 title: "Distribution",
536 description: "How this project is packaged, published and installed: the platforms a release builds, the artifact names the one naming function derives, the installer's canonical command, the releases that were published, and what this build itself is. Every answer comes from share/distribution.yaml and the release records; no surface here states a fact of its own.",
537 stability: Stability::BehaviorallyVerified,
538 capabilities: [
539 capability! {
540 id: "distribution.model",
541 title: "The distribution model",
542 description: "The one-line install command, where an installation goes, and every declared target with the artifact name it derives. This is what the installation page, the landing page's install block and the cockpit's install card render; none of them holds a platform list of its own.",
543 input: Empty,
544 output: DistributionReport,
545 stability: Stability::BehaviorallyVerified,
546 exposure: Exposure {
547 mcp: mcp("majordomus_distribution"),
548 http: get("/api/v1/distribution"),
549 cli: Some(CliExposure { path: vec!["distribution".into(), "show".into()] }),
550 },
551 tags: ["distribution", "install", "introspection"],
552 handler: distribution,
553 },
554 capability! {
555 id: "distribution.status",
556 title: "Whether the published installation works",
557 description: "Whether a machine that has never seen this project can install it right now with the advertised one-line command, and when it cannot, which link in the chain is missing and what changes it. Derived from the distribution model and the release records alone: it reaches no network, so it is as fast as any other local query and answers offline. The served bytes are proved by the release pipeline's smoke phase, not guessed at here.",
558 input: Empty,
559 output: InstallabilityReport,
560 stability: Stability::BehaviorallyVerified,
561 exposure: Exposure {
562 mcp: mcp("majordomus_install_status"),
563 http: get("/api/v1/distribution/status"),
564 cli: Some(CliExposure { path: vec!["distribution".into(), "status".into()] }),
565 },
566 tags: ["distribution", "install", "release", "diagnostics"],
567 handler: status,
568 },
569 capability! {
570 id: "distribution.releases",
571 title: "Published releases",
572 description: "Every release this repository recorded, newest first, and the one an unpinned installation resolves to: the highest version among the stable, unwithdrawn records. The pointer is derived here and never authored anywhere.",
573 input: Empty,
574 output: ReleasesReport,
575 stability: Stability::BehaviorallyVerified,
576 exposure: Exposure {
577 mcp: mcp("majordomus_releases"),
578 http: get("/api/v1/distribution/releases"),
579 cli: Some(CliExposure { path: vec!["distribution".into(), "releases".into()] }),
580 },
581 tags: ["distribution", "release"],
582 cache: crate::capability::CachePolicy::Generation { max_entries: 1 },
583 handler: releases,
584 },
585 capability! {
586 id: "distribution.build",
587 title: "This build",
588 description: "What this executable is: the version of the crate it was built from, the Rust target triple, the profile, and the commit — all compiled in at build time, so an installed binary answers without a repository, a toolchain or git.",
589 input: Empty,
590 output: BuildReport,
591 stability: Stability::BehaviorallyVerified,
592 exposure: Exposure {
593 mcp: mcp("majordomus_build"),
594 http: get("/api/v1/distribution/build"),
595 cli: Some(CliExposure { path: vec!["distribution".into(), "build".into()] }),
596 },
597 tags: ["distribution", "introspection"],
598 handler: build,
599 },
600 capability! {
601 id: "distribution.artifact",
602 title: "The artifact of a target",
603 description: "The archive name a target and a tag derive, the directory it unpacks into, and where a release publishes it. The one naming function answers; the release pipeline asks it rather than composing a name in a workflow file.",
604 input: ReleaseArtifactInput,
605 output: ReleaseArtifactView,
606 stability: Stability::BehaviorallyVerified,
607 exposure: Exposure {
608 mcp: mcp("majordomus_artifact"),
609 http: get("/api/v1/distribution/artifact"),
610 cli: Some(CliExposure { path: vec!["distribution".into(), "artifact".into()] }),
611 },
612 tags: ["distribution", "release"],
613 handler: artifact,
614 },
615 ],
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
629 fn the_declaration_yields_the_projections_it_claims() {
630 let m = module();
631 assert_eq!(m.id.as_str(), "distribution");
632 let expected: &[(&str, &str, &str)] = &[
633 (
634 "distribution.model",
635 "majordomus_distribution",
636 "/api/v1/distribution",
637 ),
638 (
639 "distribution.status",
640 "majordomus_install_status",
641 "/api/v1/distribution/status",
642 ),
643 (
644 "distribution.releases",
645 "majordomus_releases",
646 "/api/v1/distribution/releases",
647 ),
648 (
649 "distribution.build",
650 "majordomus_build",
651 "/api/v1/distribution/build",
652 ),
653 (
654 "distribution.artifact",
655 "majordomus_artifact",
656 "/api/v1/distribution/artifact",
657 ),
658 ];
659 let ids: Vec<&str> = m
660 .capabilities
661 .iter()
662 .map(|e| e.capability.id.as_str())
663 .collect();
664 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
665 assert_eq!(
666 ids, want,
667 "the module declares a different set of capabilities"
668 );
669 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
670 let exposure = &executable.capability.exposure;
671 assert_eq!(
672 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
673 Some(*tool),
674 "{id} lost or renamed its MCP tool"
675 );
676 assert_eq!(
677 exposure.http.as_ref().map(|h| h.path.as_str()),
678 Some(*path),
679 "{id} lost or renamed its HTTP route"
680 );
681 }
682 }
683}