Skip to main content

majordomus_cli/capability/builtin/
distribution.rs

1//! The `distribution` module: how this project is packaged, published and installed, and
2//! what this particular build is.
3//!
4//! Every value comes from the distribution model the process was started with
5//! (`share/distribution.yaml`, carried on the index) and from the release records the
6//! repository holds. Nothing here decides a platform, an artifact name or a URL; the
7//! module exists so that the same answers reach a person at a command line, a script over
8//! HTTP, an assistant over MCP and the cockpit, without any of them restating a fact.
9
10use 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/// One target, as every projection shows it: what it is, what it is called in prose, and
24/// the artifact name the naming function derives for a tag yet to be chosen.
25#[derive(Debug, Clone, Serialize, JsonSchema)]
26pub struct TargetView {
27    /// The target's id in the model.
28    pub id: String,
29    /// How it is written in prose: `Linux x86_64 musl`.
30    pub title: String,
31    /// The operating system.
32    pub os: Os,
33    /// The architecture.
34    pub arch: Arch,
35    /// The C library, on Linux.
36    pub libc: Option<Libc>,
37    /// The Rust target triple.
38    pub rust_target: String,
39    /// What the project promises about it.
40    pub status: Status,
41    /// Why, when it is not built.
42    pub reason: Option<String>,
43    /// The artifact name, with `{tag}` where a release's tag goes.
44    pub artifact: String,
45}
46
47/// The distribution, as a person or a script asks for it.
48#[derive(Debug, Clone, Serialize, JsonSchema)]
49pub struct DistributionReport {
50    /// The command a person types after installing.
51    pub binary: String,
52    /// `owner/name`, the only host release assets come from.
53    pub repository: String,
54    /// The installer's canonical URL.
55    pub installer_url: String,
56    /// The one-line install command, composed from its parts.
57    pub install_command: String,
58    /// The same, initialising the repository afterwards.
59    pub install_and_init_command: String,
60    /// What a person runs next.
61    pub next_command: String,
62    /// The digest algorithm the installer verifies.
63    pub checksum: String,
64    /// Where the launchers go by default.
65    pub install_dir: String,
66    /// Where the versioned trees go by default.
67    pub prefix: String,
68    /// Where the stable release metadata is published.
69    pub latest_url: String,
70    /// How many targets a release builds.
71    pub supported: usize,
72    /// Every declared target, in the model's order.
73    pub targets: Vec<TargetView>,
74}
75
76/// One release, as every projection shows it.
77#[derive(Debug, Clone, Serialize, JsonSchema)]
78pub struct ReleaseView {
79    /// The git tag.
80    pub tag: String,
81    /// The semantic version.
82    pub version: String,
83    /// Whether an unpinned installation may resolve it.
84    pub channel: Channel,
85    /// When it was published, UTC.
86    pub published_at: String,
87    /// The commit it was built from.
88    pub commit: String,
89    /// True when it has been withdrawn.
90    pub yanked: bool,
91    /// How many artifacts it published.
92    pub artifacts: usize,
93    /// Where its public metadata is served.
94    pub metadata_url: String,
95}
96
97/// The releases this repository recorded, and the one an unpinned installation resolves to.
98#[derive(Debug, Clone, Serialize, JsonSchema)]
99pub struct ReleasesReport {
100    /// How many records there are.
101    pub count: usize,
102    /// The release an unpinned installation resolves to, when there is one.
103    pub latest: Option<ReleaseView>,
104    /// Every record, newest first.
105    pub releases: Vec<ReleaseView>,
106}
107
108/// What this executable is: enough to tell two builds apart without asking a repository.
109#[derive(Debug, Clone, Serialize, JsonSchema)]
110pub struct BuildReport {
111    /// The version of the crate this executable was built from.
112    pub version: String,
113    /// The Rust target triple it was built for; the model names a target by the same string.
114    pub target: String,
115    /// The cargo profile.
116    pub profile: String,
117    /// The commit, or `unknown` when it was built outside a work tree.
118    pub commit: String,
119    /// The target of the model this build matches, when the model declares one for the triple.
120    pub distribution_target: Option<String>,
121}
122
123/// Which artifact a target and a tag name.
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126pub struct ReleaseArtifactInput {
127    /// A target's id or its Rust target triple.
128    pub target: String,
129    /// The tag, `v` and a version. `{tag}` asks for the name with the placeholder left in.
130    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/// The artifact a target and a tag name, and the directory it unpacks into.
153#[derive(Debug, Clone, Serialize, JsonSchema)]
154pub struct ReleaseArtifactView {
155    /// The target's id in the model.
156    pub target: String,
157    /// The Rust target triple.
158    pub rust_target: String,
159    /// The tag the name was derived for.
160    pub tag: String,
161    /// The archive's file name.
162    pub name: String,
163    /// The directory the archive unpacks into.
164    pub root: String,
165    /// Where a release publishes it, when the tag is a real one.
166    pub url: String,
167}
168
169/// The state of one check in the installability report. Three states and no more: a check
170/// either holds, does not, or could not be made from what this process can see.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172#[serde(rename_all = "lowercase")]
173pub enum CheckState {
174    /// The check holds.
175    Ok,
176    /// The check does not hold, and the public installation is affected.
177    Failed,
178    /// The check could not be made here; it says nothing either way.
179    Unknown,
180}
181
182/// One check: what was asked, what was seen, and — when it does not hold — why, and the
183/// command that changes it. The `cause` and `next` fields exist so that a report is
184/// actionable without a second document; a check that fails without naming its remedy is
185/// a diagnostic nobody can act on.
186#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
187pub struct InstallCheck {
188    /// A short stable name: `model`, `version`, `targets`, `release`, `artifacts`, `metadata`.
189    pub id: String,
190    /// Whether it holds.
191    pub state: CheckState,
192    /// What was observed, in one line.
193    pub observed: String,
194    /// Why it does not hold. Absent when it does.
195    pub cause: Option<String>,
196    /// The command or action that would change it. Absent when there is nothing to do.
197    pub next: Option<String>,
198}
199
200/// Whether the advertised one-line installation works right now, and if not, what is missing.
201///
202/// This answers the operator's actual question — *can a machine that has never seen this
203/// project install it with the published command?* — from local state alone. It performs no
204/// network access: a repository can be offline and this still answers, because everything it
205/// needs is the distribution model and the release records the repository itself carries.
206/// What it cannot see it reports as `unknown` rather than guessing; the public half of the
207/// question is answered by the release workflow's smoke phase, which installs from the
208/// published URL on every native runner and is the only thing that proves the public path.
209#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
210pub struct InstallabilityReport {
211    /// True when every check holds: a stable release is recorded and complete.
212    pub installable: bool,
213    /// One line for a person: what the state is, in the project's own terms.
214    pub summary: String,
215    /// The version this tree would release.
216    pub local_version: String,
217    /// The tag an unpinned installation resolves to, when a stable release is recorded.
218    pub stable_tag: Option<String>,
219    /// How many targets a release must publish for that release to be complete.
220    pub required_targets: usize,
221    /// How many of them the stable release actually publishes.
222    pub published_artifacts: usize,
223    /// The command a person would run, whether or not it currently works.
224    pub install_command: String,
225    /// Where the installer is served.
226    pub installer_url: String,
227    /// Where the stable pointer is served.
228    pub latest_url: String,
229    /// Every check, in the order they are worth reading.
230    pub checks: Vec<InstallCheck>,
231}
232
233/// The model this process was started with, or the reason there is none.
234fn 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
359/// Whether the published one-line installation works, from what this repository can see.
360///
361/// The checks are ordered the way the pipeline is: the model must be valid before a target
362/// list means anything, targets must exist before a release can be complete, a release must
363/// be recorded before metadata can be published, and the metadata must agree with the model
364/// before an installer can use it. The first failure is the one that matters, and the
365/// summary names it; the rest are still reported, because an operator reading this wants the
366/// whole shape and not one line at a time.
367fn 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
373/// The report itself, over a model and a set of records and nothing else.
374///
375/// Separated from the capability so that the answer can be examined for states this
376/// repository is not in — a complete release, a partial one, a model that publishes
377/// nothing — without constructing an index for each. The capability above is the only
378/// caller in the executable; the tests are the rest.
379pub(crate) fn installability(m: &Model, records: &Releases) -> InstallabilityReport {
380    let required = m.published().count();
381    let mut checks = Vec::new();
382
383    // The model itself. `findings` is the same function `distribution validate` reports and
384    // the release workflow refuses a tag over; this asks it rather than restating any of it.
385    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    // The version this tree would release. It is the crate's, compiled in, so it is the same
405    // string the released executable would print.
406    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    // A stable release, or none. This is the check that was failing while the public
416    // installer answered "no stable release is currently published".
417    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    // Completeness: a release is all of its supported targets or it is not a release.
444    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    // Whether every record agrees with the model. A record that disagrees renders metadata
465    // the installer would refuse, so this is part of installability and not merely of hygiene.
466    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    // The public half is deliberately not guessed at. Nothing here reaches the network, so
489    // the only honest thing to say about the served bytes is who proves them.
490    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
531/// The module.
532pub 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    /// The declaration is the only place these names exist, and every projection — the MCP
624    /// tool, the HTTP route, the OpenAPI operation, the benchmark target — is derived from
625    /// it. A refactor that dropped an exposure or renamed a route would still compile, and
626    /// the suites that exercise the behaviour behind it would still pass. This is the
627    /// assertion that would not.
628    #[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}