use std::ffi::OsString;
use std::path::{Path, PathBuf};
use camino::{Utf8Path, Utf8PathBuf};
use anyhow::{Context, Result, bail};
use cabin_config::{
ConfigDiscoveryInputs, ConfigSource, EffectiveCompilerWrapper, EffectiveConfig,
EffectivePathSetting, EffectiveRegistrySource, EffectiveTool, EffectiveToolchain,
WorkspaceLayout, discover_config_files, merge_loaded_files,
};
use cabin_core::{
CompilerWrapperSource, ConfigValueSource, ProfileName, ProfileSelection, ToolSource,
};
use cabin_toolchain::{ConfigToolEntry, ConfigToolchainLayer, ConfigWrapperLayer};
use cabin_workspace::PackageGraph;
pub(crate) fn load_effective_config(graph: &PackageGraph) -> Result<EffectiveConfig> {
let workspace = WorkspaceLayout {
root_dir: graph.root_dir.as_path(),
is_workspace_root: graph.is_workspace_root,
};
let inputs = ConfigDiscoveryInputs::from_process(Some(workspace));
let discovery = discover_config_files(&inputs).context("failed to load Cabin config")?;
Ok(merge_loaded_files(discovery.loaded_files))
}
pub(crate) fn load_effective_config_for_manifest(manifest_path: &Path) -> Result<EffectiveConfig> {
let Ok(parsed) = cabin_manifest::load_manifest(manifest_path) else {
return Ok(merge_loaded_files(Vec::new()));
};
let root_dir = manifest_path.parent().ok_or_else(|| {
anyhow::anyhow!(
"manifest path {} has no parent directory",
manifest_path.display()
)
})?;
let workspace = WorkspaceLayout {
root_dir,
is_workspace_root: parsed.workspace.is_some(),
};
let inputs = ConfigDiscoveryInputs::from_process(Some(workspace));
let discovery = discover_config_files(&inputs).context("failed to load Cabin config")?;
Ok(merge_loaded_files(discovery.loaded_files))
}
pub(crate) fn toolchain_layer(config: &EffectiveConfig) -> Option<ConfigToolchainLayer> {
let layer = ConfigToolchainLayer {
cc: tool_entry(config.toolchain.cc.as_ref()),
cxx: tool_entry(config.toolchain.cxx.as_ref()),
ar: tool_entry(config.toolchain.ar.as_ref()),
};
if layer.is_empty() { None } else { Some(layer) }
}
pub(crate) fn wrapper_layer(config: &EffectiveConfig) -> Option<ConfigWrapperLayer> {
let EffectiveCompilerWrapper { request, source } = config.compiler_wrapper.as_ref()?;
Some(ConfigWrapperLayer {
request: request.clone(),
source: wrapper_source_for(*source),
})
}
fn tool_entry(value: Option<&EffectiveTool>) -> Option<ConfigToolEntry> {
let entry = value?;
Some(ConfigToolEntry {
spec: entry.spec.clone(),
source: tool_source_for(entry.source),
})
}
fn tool_source_for(source: ConfigSource) -> ToolSource {
match source {
ConfigSource::User => ToolSource::UserConfig,
ConfigSource::Workspace => ToolSource::WorkspaceConfig,
ConfigSource::Package => ToolSource::PackageConfig,
ConfigSource::Explicit => ToolSource::ExplicitConfig,
}
}
fn wrapper_source_for(source: ConfigSource) -> CompilerWrapperSource {
match source {
ConfigSource::User => CompilerWrapperSource::UserConfig,
ConfigSource::Workspace => CompilerWrapperSource::WorkspaceConfig,
ConfigSource::Package => CompilerWrapperSource::PackageConfig,
ConfigSource::Explicit => CompilerWrapperSource::ExplicitConfig,
}
}
pub(crate) fn config_value_source(source: ConfigSource) -> ConfigValueSource {
match source {
ConfigSource::User => ConfigValueSource::UserConfig,
ConfigSource::Workspace => ConfigValueSource::WorkspaceConfig,
ConfigSource::Package => ConfigValueSource::PackageConfig,
ConfigSource::Explicit => ConfigValueSource::ExplicitConfig,
}
}
pub(crate) struct ResolvedIndexSource {
pub kind: IndexSourceKind,
}
pub(crate) enum IndexSourceKind {
Path(Utf8PathBuf),
Url(String),
}
pub(crate) fn index_source_kind_to_locator(kind: &IndexSourceKind) -> cabin_core::SourceLocator {
match kind {
IndexSourceKind::Path(p) => cabin_core::SourceLocator::IndexPath { path: p.clone() },
IndexSourceKind::Url(u) => cabin_core::SourceLocator::IndexUrl { url: u.clone() },
}
}
pub(crate) fn resolve_index_source(
cli_index_path: Option<&Path>,
cli_index_url: Option<&str>,
config: &EffectiveConfig,
) -> Result<Option<ResolvedIndexSource>> {
if cli_index_path.is_some() && cli_index_url.is_some() {
bail!("use either --index-path or --index-url, not both");
}
if let Some(path) = cli_index_path {
let path = Utf8Path::from_path(path).ok_or_else(|| {
anyhow::anyhow!("`--index-path` is not valid UTF-8: {}", path.display())
})?;
return Ok(Some(ResolvedIndexSource {
kind: IndexSourceKind::Path(path.to_path_buf()),
}));
}
if let Some(url) = cli_index_url {
if cabin_config::url_contains_credentials(url) {
bail!(
"`--index-url` must not contain credentials (userinfo): `{}`",
cabin_config::redact_userinfo(url)
);
}
return Ok(Some(ResolvedIndexSource {
kind: IndexSourceKind::Url(url.to_owned()),
}));
}
Ok(config.registry.source.as_ref().map(|src| match src {
EffectiveRegistrySource::Path(value) => ResolvedIndexSource {
kind: IndexSourceKind::Path(value.value.clone()),
},
EffectiveRegistrySource::Url(value) => ResolvedIndexSource {
kind: IndexSourceKind::Url(value.value.clone()),
},
}))
}
pub(crate) fn effective_offline(cli: bool) -> Result<bool> {
if cli {
return Ok(true);
}
if let Some(raw) = std::env::var_os(cabin_env::CABIN_NET_OFFLINE) {
let Some(s) = raw.to_str() else {
bail!(
"invalid {} value: expected valid UTF-8 boolean spelling",
cabin_env::CABIN_NET_OFFLINE
);
};
return cabin_env::parse_bool(s).map_err(|err| {
anyhow::anyhow!(
"invalid {} value {:?}: {err}",
cabin_env::CABIN_NET_OFFLINE,
s
)
});
}
Ok(false)
}
pub(crate) fn enforce_offline_index_source(
offline: bool,
resolved: Option<&ResolvedIndexSource>,
) -> Result<()> {
if !offline {
return Ok(());
}
if let Some(ResolvedIndexSource {
kind: IndexSourceKind::Url(url),
..
}) = resolved
{
bail!(
"--offline forbids network access, but the resolved index source is the URL `{url}`; pass `--index-path <dir>` or remove `[registry] index-url` from the active config and re-run with a local index (e.g. a `cabin vendor` output)"
);
}
Ok(())
}
pub(crate) fn enforce_offline_post_replacement(
offline: bool,
resolution: &cabin_core::SourceReplacementResolution,
) -> Result<()> {
if !offline {
return Ok(());
}
let cabin_core::SourceLocator::IndexUrl { url } = &resolution.resolved else {
return Ok(());
};
if resolution.hops.is_empty() {
bail!(
"--offline forbids network access, but the resolved index source is the URL `{url}`; pass `--index-path <dir>` or remove `[registry] index-url` from the active config and re-run with a local index (e.g. a `cabin vendor` output)"
);
}
bail!(
"--offline forbids network access, but `[source-replacement]` redirected the index to the URL `{url}`; remove the offending source-replacement entry, pass `--no-patches`, or drop `--offline`"
);
}
pub(crate) fn enforce_vendor_local_index_post_replacement(
resolution: &cabin_core::SourceReplacementResolution,
) -> Result<()> {
let cabin_core::SourceLocator::IndexUrl { url } = &resolution.resolved else {
return Ok(());
};
if resolution.hops.is_empty() {
bail!(
"`cabin vendor` requires a local `--index-path` source so per-package metadata can be copied verbatim into the vendor directory; the resolved index source is the URL `{url}`"
);
}
bail!(
"`cabin vendor` requires a local `--index-path` source, but `[source-replacement]` redirected the index to the URL `{url}`; remove the offending source-replacement entry or pass `--no-patches`"
);
}
pub(crate) struct PipelineInputs {
pub mode: crate::cli::LockMode,
pub allow_write: bool,
pub cache_dir: PathBuf,
pub index_path: Option<PathBuf>,
pub index_url: Option<String>,
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
pub(crate) fn resolve_pipeline_inputs(
index_source: &ResolvedIndexSource,
effective_config: &EffectiveConfig,
cache_dir_arg: Option<&Path>,
resolved_cache_dir: Option<&(PathBuf, ConfigValueSource)>,
offline: bool,
locked: bool,
frozen: bool,
no_patches: bool,
vendor_local_index: bool,
) -> Result<PipelineInputs> {
let mode = crate::cli::lock_mode_for_flags(locked, frozen);
let allow_write = !(locked || frozen);
let cache_dir = match resolved_cache_dir {
Some((path, _)) => path.clone(),
None => crate::cli::cache_dir_for(cache_dir_arg)?,
};
let initial_locator = index_source_kind_to_locator(&index_source.kind);
let resolved_locator =
crate::cli::patch::apply_source_replacement(initial_locator, effective_config, no_patches)?;
enforce_offline_post_replacement(offline, &resolved_locator)?;
if vendor_local_index {
enforce_vendor_local_index_post_replacement(&resolved_locator)?;
}
let (index_path, index_url) =
crate::cli::patch::locator_to_index_inputs(&resolved_locator.resolved);
Ok(PipelineInputs {
mode,
allow_write,
cache_dir,
index_path,
index_url,
})
}
pub(crate) fn resolve_build_dir_with_env(
cli_value: Option<&Path>,
config: &EffectiveConfig,
) -> (PathBuf, ConfigValueSource) {
resolve_build_dir_layered(
cli_value,
std::env::var_os(cabin_env::CABIN_BUILD_DIR),
config,
)
}
fn resolve_build_dir_layered(
cli_value: Option<&Path>,
env_value: Option<OsString>,
config: &EffectiveConfig,
) -> (PathBuf, ConfigValueSource) {
if let Some(p) = cli_value {
return (p.to_path_buf(), ConfigValueSource::Cli);
}
if let Some(value) = env_value.filter(|v| !v.is_empty()) {
return (PathBuf::from(value), ConfigValueSource::Env);
}
if let Some(setting) = &config.paths.build_dir {
return (
setting.absolute().into_std_path_buf(),
config_value_source(setting.source),
);
}
(PathBuf::from("build"), ConfigValueSource::BuiltinDefault)
}
pub(crate) fn resolve_build_jobs(
cli_value: Option<cabin_core::BuildJobs>,
config: &EffectiveConfig,
) -> Result<Option<cabin_core::BuildJobs>> {
if let Some(jobs) = cli_value {
return Ok(Some(jobs));
}
if let Some(raw) = std::env::var_os(cabin_env::CABIN_BUILD_JOBS) {
let raw = raw.into_string().map_err(|_| {
anyhow::anyhow!(
"{env} is not valid UTF-8",
env = cabin_env::CABIN_BUILD_JOBS
)
})?;
if !raw.is_empty() {
let jobs = raw.parse::<cabin_core::BuildJobs>().map_err(|err| {
anyhow::anyhow!(
"invalid {env} value {raw:?}: {err}",
env = cabin_env::CABIN_BUILD_JOBS
)
})?;
return Ok(Some(jobs));
}
}
if let Some(setting) = &config.build.jobs {
return Ok(Some(setting.value));
}
Ok(None)
}
pub(crate) fn resolve_incompatible_standards(
config: &EffectiveConfig,
) -> Result<cabin_core::IncompatibleStandards> {
resolve_incompatible_standards_sourced(config).map(|(value, _)| value)
}
pub(crate) fn resolve_incompatible_standards_sourced(
config: &EffectiveConfig,
) -> Result<(cabin_core::IncompatibleStandards, ConfigValueSource)> {
if let Some(raw) = std::env::var_os(cabin_env::CABIN_RESOLVER_INCOMPATIBLE_STANDARDS) {
let raw = raw.into_string().map_err(|_| {
anyhow::anyhow!(
"{env} is not valid UTF-8",
env = cabin_env::CABIN_RESOLVER_INCOMPATIBLE_STANDARDS
)
})?;
let trimmed = raw.trim();
if !trimmed.is_empty() {
let value = cabin_core::IncompatibleStandards::parse(trimmed).map_err(|err| {
anyhow::anyhow!(
"invalid {env} value {trimmed:?}: {err}",
env = cabin_env::CABIN_RESOLVER_INCOMPATIBLE_STANDARDS
)
})?;
return Ok((value, ConfigValueSource::Env));
}
}
if let Some(setting) = &config.resolver.incompatible_standards {
return Ok((setting.value, config_value_source(setting.source)));
}
Ok((
cabin_core::IncompatibleStandards::default(),
ConfigValueSource::BuiltinDefault,
))
}
pub(crate) fn resolve_cache_dir(
cli_value: Option<&Path>,
config: &EffectiveConfig,
) -> Option<(PathBuf, ConfigValueSource)> {
resolve_cache_dir_layered(
cli_value,
std::env::var_os(cabin_env::CABIN_CACHE_DIR),
config,
)
}
fn resolve_cache_dir_layered(
cli_value: Option<&Path>,
env_value: Option<OsString>,
config: &EffectiveConfig,
) -> Option<(PathBuf, ConfigValueSource)> {
if let Some(p) = cli_value {
return Some((p.to_path_buf(), ConfigValueSource::Cli));
}
if let Some(value) = env_value.filter(|v| !v.is_empty()) {
return Some((PathBuf::from(value), ConfigValueSource::Env));
}
config.paths.cache_dir.as_ref().map(|setting| {
(
setting.absolute().into_std_path_buf(),
config_value_source(setting.source),
)
})
}
pub(crate) fn config_profile_selection(
config: &EffectiveConfig,
) -> Result<Option<(ProfileSelection, ConfigValueSource)>> {
let Some(profile) = config.build.profile.as_ref() else {
return Ok(None);
};
let name = ProfileName::new(profile.name.clone())
.with_context(|| format!("invalid `build.profile` in config: `{}`", profile.name))?;
Ok(Some((
ProfileSelection::from_name(name),
config_value_source(profile.source),
)))
}
pub(crate) fn config_view_json(
config: &EffectiveConfig,
resolver_incompatible_standards: (cabin_core::IncompatibleStandards, ConfigValueSource),
) -> serde_json::Value {
let loaded_files: Vec<serde_json::Value> = config
.loaded_files
.iter()
.map(|file| {
serde_json::json!({
"source": file.source.as_key(),
"path": file.path.as_str().to_owned(),
})
})
.collect();
let registry = match &config.registry.source {
Some(EffectiveRegistrySource::Path(value)) => serde_json::json!({
"kind": "path",
"value": value.value.as_str().to_owned(),
"value_source": config_value_source(value.source).as_key(),
}),
Some(EffectiveRegistrySource::Url(value)) => serde_json::json!({
"kind": "url",
"value": value.value,
"value_source": config_value_source(value.source).as_key(),
}),
None => serde_json::Value::Null,
};
let paths = serde_json::json!({
"cache_dir": path_setting_view(config.paths.cache_dir.as_ref()),
"build_dir": path_setting_view(config.paths.build_dir.as_ref()),
});
let build = serde_json::json!({
"profile": match &config.build.profile {
Some(profile) => serde_json::json!({
"name": profile.name,
"value_source": config_value_source(profile.source).as_key(),
}),
None => serde_json::Value::Null,
},
});
let (resolver_value, resolver_source) = resolver_incompatible_standards;
let resolver = serde_json::json!({
"incompatible_standards": resolver_value.as_str(),
"value_source": resolver_source.as_key(),
});
let toolchain = toolchain_view_json(&config.toolchain);
let compiler_wrapper = match &config.compiler_wrapper {
Some(wrapper) => serde_json::json!({
"request": wrapper.request.as_key(),
"value_source": config_value_source(wrapper.source).as_key(),
}),
None => serde_json::Value::Null,
};
serde_json::json!({
"loaded_files": loaded_files,
"registry": registry,
"paths": paths,
"build": build,
"resolver": resolver,
"toolchain": toolchain,
"compiler_wrapper": compiler_wrapper,
})
}
fn toolchain_view_json(toolchain: &EffectiveToolchain) -> serde_json::Value {
serde_json::json!({
"cc": tool_view(toolchain.cc.as_ref()),
"cxx": tool_view(toolchain.cxx.as_ref()),
"ar": tool_view(toolchain.ar.as_ref()),
})
}
fn tool_view(value: Option<&EffectiveTool>) -> serde_json::Value {
match value {
Some(tool) => serde_json::json!({
"spec": tool.spec.display(),
"value_source": config_value_source(tool.source).as_key(),
}),
None => serde_json::Value::Null,
}
}
fn path_setting_view(setting: Option<&EffectivePathSetting>) -> serde_json::Value {
match setting {
Some(s) => serde_json::json!({
"value": s.value.as_str().to_owned(),
"absolute": s.absolute().as_str().to_owned(),
"value_source": config_value_source(s.source).as_key(),
}),
None => serde_json::Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
use cabin_core::{SourceLocator, SourceReplacementResolution};
#[test]
fn resolve_index_source_rejects_cli_url_with_credentials() {
let cfg = cabin_config::EffectiveConfig::default();
let Err(err) = resolve_index_source(None, Some("https://user:pw@bad.example.com/"), &cfg)
else {
panic!("expected credential rejection");
};
let message = err.to_string();
assert!(
!message.contains("user:pw"),
"credentials must be redacted from error, got: {message}"
);
assert!(
message.contains("credentials") || message.contains("userinfo"),
"expected message to mention credentials, got: {message}"
);
}
fn path_resolution(path: &str) -> SourceReplacementResolution {
SourceReplacementResolution {
resolved: SourceLocator::IndexPath {
path: Utf8PathBuf::from(path),
},
hops: Vec::new(),
}
}
fn url_resolution_with_hops(
url: &str,
hops: Vec<SourceLocator>,
) -> SourceReplacementResolution {
SourceReplacementResolution {
resolved: SourceLocator::IndexUrl {
url: url.to_owned(),
},
hops,
}
}
#[test]
fn enforce_offline_post_replacement_allows_when_not_offline() {
let resolution = url_resolution_with_hops(
"https://example.com/idx",
vec![SourceLocator::IndexPath {
path: Utf8PathBuf::from("./mirror"),
}],
);
enforce_offline_post_replacement(false, &resolution)
.expect("non-offline must always succeed");
}
#[test]
fn enforce_offline_post_replacement_allows_path_terminal() {
let resolution = path_resolution("./mirror");
enforce_offline_post_replacement(true, &resolution)
.expect("offline + path terminal is the supported combination");
}
#[test]
fn enforce_offline_post_replacement_blames_source_replacement_when_hops_present() {
let resolution = url_resolution_with_hops(
"https://example.com/idx",
vec![SourceLocator::IndexPath {
path: Utf8PathBuf::from("./mirror"),
}],
);
let err = enforce_offline_post_replacement(true, &resolution)
.expect_err("offline + url-after-replacement must bail");
let message = err.to_string();
assert!(
message.contains("source-replacement"),
"message must blame source-replacement, got: {message}"
);
assert!(
message.contains("https://example.com/idx"),
"message must name the offending URL, got: {message}"
);
}
#[test]
fn enforce_offline_post_replacement_falls_back_to_pre_check_wording_without_hops() {
let resolution = url_resolution_with_hops("https://example.com/idx", Vec::new());
let err = enforce_offline_post_replacement(true, &resolution)
.expect_err("defensive: offline + url terminal still bails");
let message = err.to_string();
assert!(
message.contains("--offline"),
"message must reference --offline, got: {message}"
);
assert!(
message.contains("https://example.com/idx"),
"message must name the offending URL, got: {message}"
);
}
#[test]
fn enforce_vendor_local_index_post_replacement_allows_path_terminal() {
let resolution = path_resolution("./mirror");
enforce_vendor_local_index_post_replacement(&resolution)
.expect("path terminal is acceptable for vendor");
}
fn cfg_with_cache_dir(value: &str, source: ConfigSource) -> EffectiveConfig {
let mut cfg = EffectiveConfig::default();
cfg.paths.cache_dir = Some(EffectivePathSetting {
value: Utf8PathBuf::from(value),
source,
base: Utf8PathBuf::from("/base"),
});
cfg
}
fn cfg_with_build_dir(value: &str, source: ConfigSource) -> EffectiveConfig {
let mut cfg = EffectiveConfig::default();
cfg.paths.build_dir = Some(EffectivePathSetting {
value: Utf8PathBuf::from(value),
source,
base: Utf8PathBuf::from("/base"),
});
cfg
}
#[test]
fn resolve_build_dir_explicit_cli_wins_even_when_value_equals_default() {
let cfg = EffectiveConfig::default();
let cli = PathBuf::from("build");
let (path, source) = resolve_build_dir_layered(
Some(cli.as_path()),
Some(OsString::from("/tmp/env-build")),
&cfg,
);
assert_eq!(path, cli);
assert_eq!(source, ConfigValueSource::Cli);
}
#[test]
fn resolve_build_dir_env_beats_config() {
let cfg = cfg_with_build_dir("config-build", ConfigSource::Workspace);
let (path, source) =
resolve_build_dir_layered(None, Some(OsString::from("/tmp/env-build")), &cfg);
assert_eq!(path, PathBuf::from("/tmp/env-build"));
assert_eq!(source, ConfigValueSource::Env);
}
#[test]
fn resolve_build_dir_falls_back_to_config() {
let cfg = cfg_with_build_dir("config-build", ConfigSource::Workspace);
let (path, source) = resolve_build_dir_layered(None, None, &cfg);
assert_eq!(path, PathBuf::from("/base").join("config-build"));
assert_eq!(source, ConfigValueSource::WorkspaceConfig);
}
#[test]
fn resolve_build_dir_builtin_default_when_nothing_set() {
let cfg = EffectiveConfig::default();
let (path, source) = resolve_build_dir_layered(None, None, &cfg);
assert_eq!(path, PathBuf::from("build"));
assert_eq!(source, ConfigValueSource::BuiltinDefault);
}
#[test]
fn resolve_build_dir_empty_env_falls_through_to_config() {
let cfg = cfg_with_build_dir("config-build", ConfigSource::Workspace);
let (path, source) = resolve_build_dir_layered(None, Some(OsString::new()), &cfg);
assert_eq!(path, PathBuf::from("/base").join("config-build"));
assert_eq!(source, ConfigValueSource::WorkspaceConfig);
}
#[test]
fn resolve_cache_dir_env_beats_config() {
let cfg = cfg_with_cache_dir("config-cache", ConfigSource::Workspace);
let (path, source) =
resolve_cache_dir_layered(None, Some(OsString::from("/tmp/env-cache")), &cfg)
.expect("env value should resolve");
assert_eq!(path, PathBuf::from("/tmp/env-cache"));
assert_eq!(source, ConfigValueSource::Env);
}
#[test]
fn resolve_cache_dir_cli_beats_env() {
let cfg = cfg_with_cache_dir("config-cache", ConfigSource::Workspace);
let cli = PathBuf::from("/tmp/cli-cache");
let (path, source) = resolve_cache_dir_layered(
Some(cli.as_path()),
Some(OsString::from("/tmp/env-cache")),
&cfg,
)
.expect("cli value should resolve");
assert_eq!(path, cli);
assert_eq!(source, ConfigValueSource::Cli);
}
#[test]
fn resolve_cache_dir_empty_env_falls_through_to_config() {
let cfg = cfg_with_cache_dir("config-cache", ConfigSource::Workspace);
let (path, source) = resolve_cache_dir_layered(None, Some(OsString::new()), &cfg)
.expect("config value should resolve");
assert_eq!(path, PathBuf::from("/base").join("config-cache"));
assert_eq!(source, ConfigValueSource::WorkspaceConfig);
}
#[test]
fn enforce_vendor_local_index_post_replacement_rejects_url_after_replacement() {
let resolution = url_resolution_with_hops(
"https://example.com/idx",
vec![SourceLocator::IndexPath {
path: Utf8PathBuf::from("./mirror"),
}],
);
let err = enforce_vendor_local_index_post_replacement(&resolution)
.expect_err("vendor must reject URL terminals");
let message = err.to_string();
assert!(
message.contains("source-replacement"),
"message must blame source-replacement, got: {message}"
);
assert!(
message.contains("cabin vendor"),
"message must reference `cabin vendor`, got: {message}"
);
}
}