use std::collections::HashSet;
use crate::ast::{Argument, Command, Test};
use crate::eval::EvalError;
const SUPPORTED: &[&str] = &[
"fileinto", "reject", "vacation", "envelope", "imap4flags", "subaddress", ];
fn is_supported(cap: &str) -> bool {
SUPPORTED.contains(&cap)
}
pub fn validate(commands: &[Command]) -> Result<(), EvalError> {
let declared = collect_required(commands)?;
for cmd in commands {
check_command(cmd, &declared)?;
}
Ok(())
}
fn collect_required(commands: &[Command]) -> Result<HashSet<String>, EvalError> {
let mut declared = HashSet::new();
let mut saw_non_require = false;
for cmd in commands {
if cmd.name == "require" {
if saw_non_require {
return Err(EvalError::RequireOutOfOrder);
}
for cap in collect_strings(&cmd.args) {
if !is_supported(&cap) {
return Err(EvalError::UnsupportedCapability(cap));
}
declared.insert(cap);
}
} else {
saw_non_require = true;
}
}
Ok(declared)
}
fn collect_strings(args: &[Argument]) -> Vec<String> {
let mut out = Vec::new();
for a in args {
match a {
Argument::String(s) => out.push(s.clone()),
Argument::StringList(v) => out.extend(v.iter().cloned()),
_ => {}
}
}
out
}
fn check_command(cmd: &Command, declared: &HashSet<String>) -> Result<(), EvalError> {
if cmd.name == "require" {
return Ok(());
}
if let Some(cap) = capability_for_command(&cmd.name) {
ensure_declared(declared, &cmd.name, cap)?;
}
for a in &cmd.args {
check_arg(a, declared)?;
}
for sub in &cmd.block {
check_command(sub, declared)?;
}
Ok(())
}
fn check_arg(a: &Argument, declared: &HashSet<String>) -> Result<(), EvalError> {
match a {
Argument::Tag(t) => {
if let Some(cap) = capability_for_tag(t) {
ensure_declared(declared, &format!(":{t}"), cap)?;
}
}
Argument::Test(t) => check_test(t, declared)?,
Argument::TestList(ts) => {
for t in ts {
check_test(t, declared)?;
}
}
_ => {}
}
Ok(())
}
fn check_test(t: &Test, declared: &HashSet<String>) -> Result<(), EvalError> {
if let Some(cap) = capability_for_test(&t.name) {
ensure_declared(declared, &t.name, cap)?;
}
for tag in &t.tags {
if let Some(cap) = capability_for_tag(tag) {
ensure_declared(declared, &format!(":{tag}"), cap)?;
}
}
for a in &t.args {
check_arg(a, declared)?;
}
for child in &t.children {
check_test(child, declared)?;
}
Ok(())
}
fn ensure_declared(
declared: &HashSet<String>,
feature: &str,
capability: &str,
) -> Result<(), EvalError> {
if declared.contains(capability) {
Ok(())
} else {
Err(EvalError::MissingCapability {
feature: feature.to_string(),
capability: capability.to_string(),
})
}
}
fn capability_for_command(name: &str) -> Option<&'static str> {
match name {
"fileinto" => Some("fileinto"),
"reject" => Some("reject"),
"vacation" => Some("vacation"),
"setflag" | "addflag" | "removeflag" => Some("imap4flags"),
_ => None,
}
}
fn capability_for_test(name: &str) -> Option<&'static str> {
match name {
"envelope" => Some("envelope"),
"hasflag" => Some("imap4flags"),
_ => None,
}
}
fn capability_for_tag(name: &str) -> Option<&'static str> {
match name {
"flags" => Some("imap4flags"),
"user" | "detail" => Some("subaddress"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::parse_script;
fn validate_src(src: &str) -> Result<(), EvalError> {
let cmds = parse_script(src).expect("test script must parse");
validate(&cmds)
}
#[test]
fn empty_script_ok() {
validate_src("").unwrap();
}
#[test]
fn keep_only_ok() {
validate_src("keep;").unwrap();
}
#[test]
fn fileinto_without_require_rejected() {
let err = validate_src(r#"fileinto "Junk";"#).unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref feature, ref capability }
if feature == "fileinto" && capability == "fileinto"
),
"got {err:?}",
);
}
#[test]
fn fileinto_with_require_ok() {
validate_src(r#"require ["fileinto"]; fileinto "Junk";"#).unwrap();
}
#[test]
fn reject_without_require_rejected() {
let err = validate_src(r#"reject "no";"#).unwrap_err();
assert!(
matches!(err, EvalError::MissingCapability { .. }),
"got {err:?}",
);
}
#[test]
fn envelope_test_without_require_rejected() {
let err =
validate_src(r#"if envelope :is "from" "x@y.com" { discard; }"#).unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref feature, .. } if feature == "envelope"
),
"got {err:?}",
);
}
#[test]
fn flags_tag_without_require_rejected() {
let err = validate_src(
r#"require ["fileinto"]; fileinto :flags "\\Seen" "Inbox";"#,
)
.unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref feature, ref capability }
if feature == ":flags" && capability == "imap4flags"
),
"got {err:?}",
);
}
#[test]
fn setflag_without_require_rejected() {
let err = validate_src(r#"setflag "\\Seen"; keep;"#).unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref capability, .. }
if capability == "imap4flags"
),
"got {err:?}",
);
}
#[test]
fn hasflag_without_require_rejected() {
let err = validate_src(r#"if hasflag "\\Seen" { discard; }"#).unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref feature, .. } if feature == "hasflag"
),
"got {err:?}",
);
}
#[test]
fn unsupported_capability_rejected() {
let err = validate_src(r#"require ["body"]; keep;"#).unwrap_err();
assert!(
matches!(err, EvalError::UnsupportedCapability(ref s) if s == "body"),
"got {err:?}",
);
}
#[test]
fn require_after_other_command_rejected() {
let err = validate_src(r#"keep; require ["fileinto"];"#).unwrap_err();
assert!(matches!(err, EvalError::RequireOutOfOrder), "got {err:?}");
}
#[test]
fn require_inside_if_block_rejected() {
let err = validate_src(
r#"if exists "Subject" { require ["fileinto"]; fileinto "X"; }"#,
)
.unwrap_err();
assert!(
matches!(err, EvalError::MissingCapability { .. }),
"got {err:?}",
);
}
#[test]
fn nested_fileinto_in_if_block_caught() {
let err = validate_src(
r#"if exists "Subject" { fileinto "X"; }"#,
)
.unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref feature, .. } if feature == "fileinto"
),
"got {err:?}",
);
}
#[test]
fn nested_envelope_in_anyof_caught() {
let err = validate_src(
r#"if anyof(exists "From", envelope :is "from" "x@y") { discard; }"#,
)
.unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref feature, .. } if feature == "envelope"
),
"got {err:?}",
);
}
#[test]
fn multiple_require_lists_combine() {
validate_src(
r#"require ["fileinto"];
require ["reject"];
if header :is "S" "x" { reject "no"; }
fileinto "Junk";"#,
)
.unwrap();
}
#[test]
fn require_with_multi_extension_list() {
validate_src(
r#"require ["fileinto", "imap4flags", "envelope"];
setflag "\\Seen";
if envelope :is "from" "x" { fileinto "X"; }"#,
)
.unwrap();
}
#[test]
fn subaddress_capability_supported() {
validate_src(r#"require ["subaddress"]; keep;"#).unwrap();
}
#[test]
fn user_tag_without_require_rejected() {
let err = validate_src(
r#"if address :user "From" "alice" { discard; }"#,
)
.unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref feature, ref capability }
if feature == ":user" && capability == "subaddress"
),
"got {err:?}",
);
}
#[test]
fn detail_tag_without_require_rejected() {
let err = validate_src(
r#"if address :detail "From" "work" { discard; }"#,
)
.unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref feature, ref capability }
if feature == ":detail" && capability == "subaddress"
),
"got {err:?}",
);
}
#[test]
fn subaddress_with_user_tag_ok() {
validate_src(
r#"require ["subaddress"];
if address :user "From" "alice" { discard; }"#,
)
.unwrap();
}
#[test]
fn subaddress_with_detail_tag_ok() {
validate_src(
r#"require ["subaddress"];
if address :detail "From" "work" { discard; }"#,
)
.unwrap();
}
}