use crate::sanitise::sanitise_display;
const ECHO_CAP: usize = 256;
#[derive(Debug, thiserror::Error)]
pub enum ToolsetFormatError {
#[error("I/O error reading TOOLSET.md: {}", sanitise_display(.detail, ECHO_CAP))]
Io {
detail: String,
},
#[error("TOOLSET.md is too large: {size} bytes (cap is {cap} bytes)")]
ToolsetFileTooLarge {
size: u64,
cap: u64,
},
#[error("TOOLSET.md is not valid UTF-8")]
NotUtf8,
#[error("TOOLSET.md has no frontmatter (file must begin with '---')")]
MissingFrontmatter,
#[error("TOOLSET.md frontmatter is malformed YAML: {}", sanitise_display(.detail, ECHO_CAP))]
MalformedFrontmatter {
detail: String,
},
#[error("TOOLSET.md frontmatter contains YAML anchors or aliases (forbidden)")]
YamlAnchorsForbidden,
#[error("TOOLSET.md frontmatter nesting exceeds the maximum depth of 8")]
FrontmatterTooDeep,
#[error("TOOLSET.md frontmatter has a duplicate mapping key: {}", sanitise_display(.key, ECHO_CAP))]
DuplicateKey {
key: String,
},
#[error(
"TOOLSET.md metadata key '{}' uses the reserved 'stellar-agent-' prefix",
sanitise_display(.key, ECHO_CAP)
)]
ReservedMetadataKey {
key: String,
},
#[error("TOOLSET.md frontmatter is missing the required 'name' field")]
MissingName,
#[error("TOOLSET.md 'name' is empty")]
NameEmpty,
#[error("TOOLSET.md 'name' exceeds 64 characters")]
NameTooLong,
#[error("TOOLSET.md 'name' contains a character outside [a-z0-9-]")]
NameInvalidChar,
#[error("TOOLSET.md 'name' must not start or end with a hyphen")]
NameLeadingTrailingHyphen,
#[error("TOOLSET.md 'name' must not contain consecutive hyphens ('--')")]
NameConsecutiveHyphens,
#[error(
"TOOLSET.md 'name' ('{}') does not match the toolset directory name ('{}')",
sanitise_display(.name, ECHO_CAP),
sanitise_display(.dir, ECHO_CAP),
)]
NameDirMismatch {
name: String,
dir: String,
},
#[error("TOOLSET.md frontmatter is missing the required 'description' field")]
MissingDescription,
#[error("TOOLSET.md 'description' must not be empty")]
DescriptionEmpty,
#[error("TOOLSET.md 'description' exceeds 1024 characters")]
DescriptionTooLong,
#[error("TOOLSET.md 'compatibility' exceeds 500 characters")]
CompatibilityTooLong,
#[error(
"capability token '{}' contains a character outside [a-z0-9-]",
sanitise_display(.token, ECHO_CAP)
)]
CapabilityTokenInvalidChar {
token: String,
},
#[error(
"capability token '{}' is not in the recognised taxonomy",
sanitise_display(.token, ECHO_CAP)
)]
UnknownCapability {
token: String,
},
#[error(
"capability token 'sign-transaction' is not grantable; signing is \
gated and cannot be declared as a flat capability"
)]
BareSignTransactionForbidden,
#[error(
"stellar-agent-capabilities metadata value is malformed: {}",
sanitise_display(.detail, ECHO_CAP)
)]
CapabilityManifestMalformed {
detail: String,
},
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test-only; panics acceptable in unit tests"
)]
use super::*;
#[test]
fn variant_count_parity() {
fn count_variants(e: &ToolsetFormatError) -> u32 {
match e {
ToolsetFormatError::Io { .. } => 1,
ToolsetFormatError::ToolsetFileTooLarge { .. } => 2,
ToolsetFormatError::NotUtf8 => 3,
ToolsetFormatError::MissingFrontmatter => 4,
ToolsetFormatError::MalformedFrontmatter { .. } => 5,
ToolsetFormatError::YamlAnchorsForbidden => 6,
ToolsetFormatError::FrontmatterTooDeep => 7,
ToolsetFormatError::DuplicateKey { .. } => 8,
ToolsetFormatError::ReservedMetadataKey { .. } => 9,
ToolsetFormatError::MissingName => 10,
ToolsetFormatError::NameEmpty => 11,
ToolsetFormatError::NameTooLong => 12,
ToolsetFormatError::NameInvalidChar => 13,
ToolsetFormatError::NameLeadingTrailingHyphen => 14,
ToolsetFormatError::NameConsecutiveHyphens => 15,
ToolsetFormatError::NameDirMismatch { .. } => 16,
ToolsetFormatError::MissingDescription => 17,
ToolsetFormatError::DescriptionEmpty => 18,
ToolsetFormatError::DescriptionTooLong => 19,
ToolsetFormatError::CompatibilityTooLong => 20,
ToolsetFormatError::CapabilityTokenInvalidChar { .. } => 21,
ToolsetFormatError::UnknownCapability { .. } => 22,
ToolsetFormatError::BareSignTransactionForbidden => 23,
ToolsetFormatError::CapabilityManifestMalformed { .. } => 24,
}
}
let variants: &[ToolsetFormatError] = &[
ToolsetFormatError::Io {
detail: String::new(),
},
ToolsetFormatError::ToolsetFileTooLarge { size: 0, cap: 0 },
ToolsetFormatError::NotUtf8,
ToolsetFormatError::MissingFrontmatter,
ToolsetFormatError::MalformedFrontmatter {
detail: String::new(),
},
ToolsetFormatError::YamlAnchorsForbidden,
ToolsetFormatError::FrontmatterTooDeep,
ToolsetFormatError::DuplicateKey { key: String::new() },
ToolsetFormatError::ReservedMetadataKey { key: String::new() },
ToolsetFormatError::MissingName,
ToolsetFormatError::NameEmpty,
ToolsetFormatError::NameTooLong,
ToolsetFormatError::NameInvalidChar,
ToolsetFormatError::NameLeadingTrailingHyphen,
ToolsetFormatError::NameConsecutiveHyphens,
ToolsetFormatError::NameDirMismatch {
name: String::new(),
dir: String::new(),
},
ToolsetFormatError::MissingDescription,
ToolsetFormatError::DescriptionEmpty,
ToolsetFormatError::DescriptionTooLong,
ToolsetFormatError::CompatibilityTooLong,
ToolsetFormatError::CapabilityTokenInvalidChar {
token: String::new(),
},
ToolsetFormatError::UnknownCapability {
token: String::new(),
},
ToolsetFormatError::BareSignTransactionForbidden,
ToolsetFormatError::CapabilityManifestMalformed {
detail: String::new(),
},
];
let max = variants.iter().map(count_variants).max().unwrap_or(0);
assert_eq!(
variants.len(),
24,
"constructor array length changed; add the new variant to `variants`"
);
assert_eq!(max, 24, "variant count changed; update parity test + doc");
}
#[test]
fn bare_sign_transaction_message_is_user_facing() {
let msg = ToolsetFormatError::BareSignTransactionForbidden.to_string();
assert!(
msg.contains("sign-transaction"),
"message must name the token: {msg}"
);
assert!(
msg.contains("gated") || msg.contains("not grantable"),
"message must explain that signing is gated: {msg}"
);
assert!(
!msg.contains("PR-"),
"message must not contain PR refs: {msg}"
);
assert!(
!msg.contains("Phase-"),
"message must not contain Phase refs: {msg}"
);
}
}