Radish alpha
r
rad:zwTxygwuz5LDGBq255RA2CbNGrz8
Radicle CI broker
Radicle
Git
radicle-ci-broker src filter.rs
use std::path::{Path, PathBuf};

use radicle_crypto::PublicKey;
use regex::Regex;
use serde::{Deserialize, Serialize};

use radicle::{
    cob::patch::PatchId,
    git::{BranchName, Oid, raw::ObjectType},
    node::NodeId,
    prelude::{Profile, RepoId},
    storage::{ReadRepository, git::Repository},
};

use crate::{
    ci_event::{CiEvent, CiEventV1},
    config::TriggerConfig,
    logger,
    refs::ref_string,
};

#[cfg(test)]
pub mod arbitrary;

#[derive(Clone)]
pub struct Trigger {
    adapter: String,
    filters: Vec<EventFilter>,
}

impl Trigger {
    pub fn adapter(&self) -> &str {
        &self.adapter
    }

    pub fn allows(&self, e: &CiEvent) -> bool {
        self.filters.iter().any(|filter| filter.allows(e))
    }
}

impl From<&TriggerConfig> for Trigger {
    fn from(config: &TriggerConfig) -> Self {
        Self {
            adapter: config.adapter.clone(),
            filters: config.filters.clone(),
        }
    }
}

/// A Boolean expression for filtering broker events.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub enum EventFilter {
    /// Event for a specific repository.
    Repository(RepoId),

    /// Event is for a specific branch.
    Branch(BranchName),

    /// Event is for a tag whose name matches a regular expression.
    Tag(String),

    /// Event if for the default branch for the repository.
    DefaultBranch,

    /// Branch was created.
    BranchCreated,

    /// Branch was updated.
    BranchUpdated,

    /// Branch was deleted.
    BranchDeleted,

    /// Event is for a specific patch.
    Patch(Oid),

    /// Patch was created.
    PatchCreated,

    /// Patch was updated,
    PatchUpdated,

    /// Annotated tag was created.
    TagCreated,

    /// Annotated tag was updated.
    TagUpdated,

    /// Annotated tag was deleted.
    TagDeleted,

    /// Change originated from specific node.
    Node(NodeId),

    /// Change originated from any delegate node. Note that will change to
    /// "from delegate" once Radicle separates the "user" and "node"
    /// concepts.
    AnyDelegate,

    /// Commit in change contains a file or directory with this name.
    HasFile(PathBuf),

    /// Allow any event.
    Allow,

    /// Don't allow any event.
    Deny,

    /// Allow the opposite of the contained filter.
    #[serde(alias = "NoneOf")]
    Not(Vec<EventFilter>),

    /// Allow if all contained filters allow.
    #[serde(alias = "AllOf")]
    And(Vec<EventFilter>),

    /// Allow if any contained filter allow.
    #[serde(alias = "AnyOf")]
    Or(Vec<EventFilter>),
}

impl EventFilter {
    pub fn decide(&self, event: &CiEvent) -> Decision {
        if matches!(event, CiEvent::V1(CiEventV1::Shutdown)) {
            return Decision::new("Shutdown", true, "shutdown event is always allowed");
        }

        match self {
            Self::Not(expr) => {
                let conds: Vec<Decision> = expr.iter().map(|op| op.decide(event)).collect();
                let allowed = !conds.iter().any(|op| op.allowed);
                Decision::parent("Not", allowed, "(combo)", conds)
            }
            Self::And(expr) => {
                let conds: Vec<Decision> = expr.iter().map(|op| op.decide(event)).collect();
                let allowed = conds.iter().all(|op| op.allowed);
                Decision::parent("And", allowed, "(combo)", conds)
            }
            Self::Or(expr) => {
                let conds: Vec<Decision> = expr.iter().map(|op| op.decide(event)).collect();
                let allowed = conds.iter().any(|op| op.allowed);
                Decision::parent("Or", allowed, "(combo)", conds)
            }
            Self::Allow => Decision::new("Allow", true, "always allowed"),
            Self::Deny => Decision::new("Deny", false, "never allowed"),
            Self::Node(wanted) => {
                let actual = event.from_node();
                let allowed = Some(wanted) == actual;
                Decision::string(
                    "Node",
                    allowed,
                    format!("wanted={wanted} actual={actual:?}"),
                )
            }
            #[allow(clippy::unwrap_used)]
            Self::AnyDelegate => {
                let repo_id = event.repository().unwrap();
                let radicle = crate::ergo::Radicle::new().unwrap();
                let repo = radicle.repository(repo_id).unwrap();
                let origin = event.from_node().unwrap();
                let delegates: Vec<PublicKey> = repo
                    .delegates()
                    .iter()
                    .flatten()
                    .map(|d| *d.as_key())
                    .collect();
                let allowed = delegates.contains(origin);
                Decision::string(
                    "AnyDelegate",
                    allowed,
                    format!("wanted={origin} delegates={delegates:?}",),
                )
            }
            Self::Repository(wanted) => {
                let actual = event.repository();
                let allowed = Some(wanted) == actual;
                Decision::string(
                    "Repository",
                    allowed,
                    format!("wanted={wanted} actual={actual:?}"),
                )
            }
            Self::Branch(wanted) => {
                let actual = event.branch();
                let allowed = Some(wanted) == actual;
                Decision::string(
                    "Branch",
                    allowed,
                    format!("wanted={wanted} actual={actual:?}"),
                )
            }
            Self::Tag(wanted) => match Regex::new(wanted) {
                Ok(re) => {
                    let actual = event.tag();
                    let allowed = match &actual {
                        Some(actual) => {
                            let actual = actual.as_str();
                            if let Some(m) = re.find(actual) {
                                m.start() == 0 && m.end() == actual.len()
                            } else {
                                false
                            }
                        }
                        _ => false,
                    };
                    Decision::string(
                        "Tag",
                        allowed,
                        format!("wanted={wanted:?} actual={actual:?}"),
                    )
                }
                Err(err) => {
                    logger::queueproc_filter_regex_error(wanted, err);
                    Decision::new("Tag", false, "regex syntax error")
                }
            },
            Self::DefaultBranch => {
                let repo = event.repository();
                let actual = event.branch();
                let allowed = match (repo, actual) {
                    (Some(repo), Some(actual)) => is_default_branch(repo, actual),
                    _ => false,
                };
                Decision::string(
                    "DefaultBranch",
                    allowed,
                    format!("repo={repo:?} actual={actual:?}"),
                )
            }
            Self::BranchCreated => {
                let allowed = matches!(event, CiEvent::V1(CiEventV1::BranchCreated { .. }));
                Decision::new("BranchCreated", allowed, "")
            }
            Self::BranchUpdated => {
                let allowed = matches!(event, CiEvent::V1(CiEventV1::BranchUpdated { .. }));
                Decision::new("BranchUpdated", allowed, "")
            }
            Self::BranchDeleted => {
                let allowed = matches!(event, CiEvent::V1(CiEventV1::BranchDeleted { .. }));
                Decision::new("BranchDeleted", allowed, "")
            }
            Self::TagCreated => {
                let allowed = matches!(event, CiEvent::V1(CiEventV1::TagCreated { .. }));
                Decision::new("TagCreated", allowed, "")
            }
            Self::TagUpdated => {
                let allowed = matches!(event, CiEvent::V1(CiEventV1::TagUpdated { .. }));
                Decision::new("TagUpdated", allowed, "")
            }
            Self::TagDeleted => {
                let allowed = matches!(event, CiEvent::V1(CiEventV1::TagDeleted { .. }));
                Decision::new("TagDeleted", allowed, "")
            }
            Self::Patch(wanted) => {
                let actual = event.patch_id();
                let allowed = Some(&PatchId::from(wanted)) == actual;
                Decision::string(
                    "Patch",
                    allowed,
                    format!("wanted={wanted} actual={actual:?}"),
                )
            }
            Self::PatchCreated => {
                let allowed = matches!(event, CiEvent::V1(CiEventV1::PatchCreated { .. }));
                Decision::new("PatchCreated", allowed, "")
            }
            Self::PatchUpdated => {
                let allowed = matches!(event, CiEvent::V1(CiEventV1::PatchUpdated { .. }));
                Decision::new("PatchUpdated", allowed, "")
            }
            Self::HasFile(wanted) => {
                let repo = event.repository();
                let tip = event.tip();
                let allowed = match (repo, tip) {
                    (Some(repo), Some(tip)) => has_file(repo, tip, wanted),
                    _ => false,
                };
                Decision::string(
                    "HasFile",
                    allowed,
                    format!("repo={repo:?} tip={tip:?} wanted={wanted:?}"),
                )
            }
        }
    }

    pub fn allows(&self, event: &CiEvent) -> bool {
        let dec = self.decide(event);
        logger::queueproc_filter_decision(event, self, &dec);
        dec.allowed
    }

    pub fn from_file(filename: &Path) -> Result<Vec<Self>, FilterError> {
        Filters::from_file(filename)
    }
}

#[derive(Debug, Serialize)]
pub struct Decision {
    filter: &'static str,
    allowed: bool,
    reason: String,
    children: Vec<Self>,
}

impl Decision {
    fn new(filter: &'static str, allowed: bool, reason: &'static str) -> Self {
        Self {
            filter,
            allowed,
            reason: reason.into(),
            children: vec![],
        }
    }

    fn parent(
        filter: &'static str,
        allowed: bool,
        reason: &'static str,
        children: Vec<Self>,
    ) -> Self {
        Self {
            filter,
            allowed,
            reason: reason.into(),
            children,
        }
    }

    fn string(filter: &'static str, allowed: bool, reason: String) -> Self {
        Self {
            filter,
            allowed,
            reason,
            children: vec![],
        }
    }

    pub fn allowed(&self) -> bool {
        self.allowed
    }

    pub fn print(&self, level: usize) {
        let mut x = String::new();
        for _ in 0..level {
            x.push_str("  ");
        }

        println!(
            "{x}{} allowed={} {}",
            self.filter, self.allowed, self.reason
        );

        for kid in self.children.iter() {
            kid.print(level + 1);
        }
    }
}

fn is_default_branch(repo_id: &RepoId, wanted: &str) -> bool {
    if let Ok(wanted) = ref_string(wanted)
        && let Ok(default) = get_default_branch(repo_id)
    {
        return wanted == default;
    }

    false
}

pub fn get_default_branch(repo_id: &RepoId) -> Result<BranchName, Box<dyn std::error::Error>> {
    let profile = Profile::load()?;
    let path = profile.storage.path().join(repo_id.canonical());
    let repo = Repository::open(path, *repo_id)?;
    let proj = repo.project()?;
    Ok(proj.default_branch().clone())
}

#[allow(clippy::unwrap_used)]
fn has_file(repo_id: &RepoId, oid: &Oid, filename: &Path) -> bool {
    fn helper(
        repo_id: &RepoId,
        oid: Oid,
        filename: &Path,
    ) -> Result<bool, Box<dyn std::error::Error>> {
        let profile = Profile::load()?;
        let repo = Repository::open(profile.storage.path().join(repo_id.canonical()), *repo_id)?;

        let obj = repo.backend.find_object(oid.into(), None);
        let obj = obj?;
        let commit = match obj.kind() {
            None => return Ok(false),
            Some(ObjectType::Any) => return Ok(false),
            Some(ObjectType::Tree) => return Ok(false),
            Some(ObjectType::Blob) => return Ok(false),

            Some(ObjectType::Commit) => obj.into_commit(),
            Some(ObjectType::Tag) => {
                let tag = obj.into_tag().unwrap();
                repo.backend
                    .find_object(tag.target_id(), None)
                    .unwrap()
                    .into_commit()
            }
        };
        let commit = if let Ok(commit) = commit {
            commit
        } else {
            return Ok(false);
        };
        let tree = commit.tree()?;
        let entry = if let Ok(entry) = tree.get_path(filename) {
            entry
        } else {
            return Ok(false);
        };
        let obj = entry.to_object(&repo.backend)?;
        let ok = obj.into_blob().is_ok();

        Ok(ok)
    }

    helper(repo_id, *oid, filename).unwrap_or(false)
}

#[derive(Deserialize)]
struct Filters {
    filters: Vec<EventFilter>,
}

impl Filters {
    fn from_file(filename: &Path) -> Result<Vec<EventFilter>, FilterError> {
        let data =
            std::fs::read(filename).map_err(|e| FilterError::ReadFile(filename.into(), e))?;
        let filters: Self =
            serde_norway::from_slice(&data).map_err(|e| FilterError::parse_yaml(filename, e))?;
        Ok(filters.filters)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
    use qcheck_macros::quickcheck;
    use radicle::prelude::{Did, RepoId};
    use std::str::FromStr;

    use crate::refs::{TagName, branch_from_str};

    use super::*;

    fn did() -> Did {
        Did::decode("did:key:z6MkgEMYod7Hxfy9qCvDv5hYHkZ4ciWmLFgfvm3Wn1b2w2FV").unwrap()
    }

    fn other_did() -> Did {
        Did::decode("did:key:z6MkfXa53s1ZSFy8rktvyXt5ADCojnxvjAoQpzajaXyLqG5n").unwrap()
    }

    fn rid() -> RepoId {
        const RID: &str = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5";
        RepoId::from_urn(RID).unwrap()
    }

    fn other_rid() -> RepoId {
        const RID: &str = "rad:zwTxygwuz5LDGBq255RA2CbNGrz8";
        RepoId::from_urn(RID).unwrap()
    }

    fn oid_from(oid: &str) -> Oid {
        Oid::from_str(oid).unwrap()
    }

    fn oid() -> Oid {
        const OID: &str = "ff3099ba5de28d954c41d0b5a84316f943794ea4";
        oid_from(OID)
    }

    fn other_oid() -> Oid {
        const OID: &str = "bde68ac76ce093bcc583aa612f45e13fee2353a0";
        oid_from(OID)
    }

    fn patch_id() -> PatchId {
        PatchId::from(oid())
    }

    fn other_patch_id() -> PatchId {
        PatchId::from(other_oid())
    }

    fn shutdown() -> CiEvent {
        CiEvent::V1(CiEventV1::Shutdown)
    }

    fn tag_created(name: &str, did: Did, repo: RepoId, tip: Oid) -> CiEvent {
        CiEvent::V1(CiEventV1::TagCreated {
            from_node: did.into(),
            repo,
            tag: TagName::try_from(name).unwrap(),
            tip,
        })
    }

    fn all_events(
        did: Did,
        repo: RepoId,
        branch: BranchName,
        patch: PatchId,
        tip: Oid,
        old_tip: Oid,
    ) -> Vec<CiEvent> {
        vec![
            CiEvent::V1(CiEventV1::BranchCreated {
                from_node: did.into(),
                repo,
                branch: branch.clone(),
                tip,
            }),
            CiEvent::V1(CiEventV1::BranchUpdated {
                from_node: did.into(),
                repo,
                branch: branch.clone(),
                tip,
                old_tip,
            }),
            CiEvent::V1(CiEventV1::BranchDeleted {
                from_node: did.into(),
                repo,
                branch,
                tip,
            }),
            CiEvent::V1(CiEventV1::PatchCreated {
                from_node: did.into(),
                repo,
                patch,
                new_tip: tip,
            }),
            CiEvent::V1(CiEventV1::PatchUpdated {
                from_node: did.into(),
                repo,
                patch,
                new_tip: tip,
            }),
            CiEvent::V1(CiEventV1::TagCreated {
                from_node: did.into(),
                repo,
                tag: TagName::try_from("test-tag").unwrap(),
                tip,
            }),
        ]
    }

    // Verify that shutdown is allowed, even when filtering for
    // something else.
    #[test]
    fn allows_shutdown() {
        let filter = EventFilter::Repository(rid());
        assert!(filter.allows(&shutdown()))
    }

    #[test]
    fn allows_all_for_default_repository() {
        let filter = EventFilter::Repository(rid());
        let events = all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        );
        assert!(events.iter().all(|e| filter.allows(e)));
    }

    #[test]
    fn doesnt_allow_any_for_other_repository() {
        let filter = EventFilter::Repository(rid());
        let events = all_events(
            did(),
            other_rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        );
        eprintln!("filter: {filter:#?}");
        for e in events.iter() {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(!filter.allows(e));
        }
    }

    #[test]
    fn allows_all_for_main_branch() {
        let filter = EventFilter::Branch(branch_from_str("main").unwrap());
        let events = all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        );
        eprintln!("filter: {filter:#?}");
        for e in events.iter().filter(|e| {
            matches!(
                e,
                CiEvent::V1(CiEventV1::BranchCreated { .. })
                    | CiEvent::V1(CiEventV1::BranchUpdated { .. })
                    | CiEvent::V1(CiEventV1::BranchDeleted { .. })
            )
        }) {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn doesnt_allow_any_for_other_branch() {
        let filter = EventFilter::Branch(branch_from_str("main").unwrap());
        let events = all_events(
            did(),
            other_rid(),
            branch_from_str("other").unwrap(),
            patch_id(),
            oid(),
            oid(),
        );
        eprintln!("filter: {filter:#?}");
        for e in events.iter() {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(!filter.allows(e));
        }
    }

    #[test]
    fn allows_branch_creation() {
        let filter = EventFilter::BranchCreated;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| matches!(e, CiEvent::V1(CiEventV1::BranchCreated { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn only_allows_branch_creation() {
        let filter = EventFilter::BranchCreated;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| !matches!(e, CiEvent::V1(CiEventV1::BranchCreated { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(!filter.allows(e));
        }
    }

    #[test]
    fn allows_branch_update() {
        let filter = EventFilter::BranchUpdated;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| matches!(e, CiEvent::V1(CiEventV1::BranchUpdated { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn only_allows_branch_update() {
        let filter = EventFilter::BranchUpdated;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| !matches!(e, CiEvent::V1(CiEventV1::BranchUpdated { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(!filter.allows(e));
        }
    }

    #[test]
    fn allows_branch_deletion() {
        let filter = EventFilter::BranchDeleted;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| matches!(e, CiEvent::V1(CiEventV1::BranchDeleted { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn only_allows_branch_deletion() {
        let filter = EventFilter::BranchDeleted;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| !matches!(e, CiEvent::V1(CiEventV1::BranchDeleted { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(!filter.allows(e));
        }
    }

    #[test]
    fn allows_specific_patch() {
        let filter = EventFilter::Patch(oid());
        let events = all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        );
        eprintln!("filter: {filter:#?}");
        for e in events.iter().filter(|e| {
            matches!(
                e,
                CiEvent::V1(CiEventV1::PatchCreated { .. })
                    | CiEvent::V1(CiEventV1::PatchUpdated { .. })
            )
        }) {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn doesnt_allows_other_patch() {
        let filter = EventFilter::Patch(oid());
        let events = all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            other_patch_id(),
            oid(),
            oid(),
        );
        eprintln!("filter: {filter:#?}");
        for e in events.iter().filter(|e| {
            matches!(
                e,
                CiEvent::V1(CiEventV1::PatchCreated { .. })
                    | CiEvent::V1(CiEventV1::PatchUpdated { .. })
            )
        }) {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(!filter.allows(e));
        }
    }

    #[test]
    fn allows_patch_creation() {
        let filter = EventFilter::PatchCreated;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| matches!(e, CiEvent::V1(CiEventV1::PatchCreated { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn only_allows_patch_creation() {
        let filter = EventFilter::PatchCreated;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| !matches!(e, CiEvent::V1(CiEventV1::PatchCreated { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(!filter.allows(e));
        }
    }

    #[test]
    fn allows_patch_update() {
        let filter = EventFilter::PatchUpdated;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| matches!(e, CiEvent::V1(CiEventV1::PatchUpdated { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn only_allows_patch_update() {
        let filter = EventFilter::PatchUpdated;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        .filter(|e| !matches!(e, CiEvent::V1(CiEventV1::PatchUpdated { .. })))
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(!filter.allows(e));
        }
    }

    #[test]
    fn allows_all_for_right_node() {
        let filter = EventFilter::Node(*did());
        let events = all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        );
        assert!(events.iter().all(|e| filter.allows(e)));
    }

    #[test]
    fn allows_none_for_wrong_node() {
        let filter = EventFilter::Node(*other_did());
        let events = all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        );
        assert!(!events.iter().any(|e| filter.allows(e)));
    }

    #[test]
    fn allows_any_event() {
        let filter = EventFilter::Allow;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn allows_no_event() {
        let filter = EventFilter::Deny;

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(!filter.allows(e));
        }
    }

    #[test]
    fn allows_opposite() {
        let filter = EventFilter::Not(vec![EventFilter::Deny]);

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn allows_if_all_allow() {
        let filter = EventFilter::And(vec![EventFilter::Allow, EventFilter::Allow]);

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    #[test]
    fn allows_if_any_allows() {
        let filter = EventFilter::Or(vec![EventFilter::Deny, EventFilter::Allow]);

        eprintln!("filter: {filter:#?}");
        for e in all_events(
            did(),
            rid(),
            branch_from_str("main").unwrap(),
            patch_id(),
            oid(),
            oid(),
        )
        .iter()
        {
            eprintln!("{:#?} → {}", e, filter.allows(e));
            assert!(filter.allows(e));
        }
    }

    /// This test ensures that we can deserialize a nested enum, which is only
    /// possible using the `serde_norway::with::singleton_map` helper on the
    /// `EventFilter::Not` variant.
    #[test]
    fn deserialize_yaml_nested_not() {
        let expected = EventFilter::And(vec![
            EventFilter::Not(vec![EventFilter::Repository(
                "rad:z32iyJDyFLqvPFzwHm8YadK4HQ2EY"
                    .parse::<RepoId>()
                    .unwrap(),
            )]),
            EventFilter::BranchCreated,
            EventFilter::PatchCreated,
        ]);
        let filters = r#"
!And
- !Not
   - !Repository "rad:z32iyJDyFLqvPFzwHm8YadK4HQ2EY"
- !BranchCreated
- !PatchCreated
"#;
        let evf = serde_norway::from_str::<EventFilter>(filters);
        assert!(evf.is_ok(), "Failed to deserialize filters: {evf:?}");
        assert_eq!(evf.unwrap(), expected);
    }

    #[quickcheck]
    fn yaml_roundtrip(filter: EventFilter) -> Result<bool, serde_norway::Error> {
        let ser = serde_norway::to_string(&filter)?;
        let de = serde_norway::from_str(&ser)?;
        Ok(filter == de)
    }

    #[test]
    fn allows_wanted_tag() {
        let filter = EventFilter::Tag("test-tag".to_string());
        eprintln!("filter: {filter:#?}");

        let e = tag_created("test-tag", did(), rid(), oid());
        eprintln!("{:#?} → {}", e, filter.allows(&e));
        assert!(filter.allows(&e));
    }

    #[test]
    fn doesnt_allow_unexpected_tag() {
        let filter = EventFilter::Tag("test-tag".to_string());
        eprintln!("filter: {filter:#?}");

        let e = tag_created("xyzzy", did(), rid(), oid());
        eprintln!("{:#?} → {}", e, filter.allows(&e));
        assert!(!filter.allows(&e));
    }

    #[test]
    fn doesnt_allow_unexpected_tag_even_if_wanted_is_prefix() {
        let filter = EventFilter::Tag("test-tag".to_string());
        eprintln!("filter: {filter:#?}");

        let e = tag_created("test-tag-with-junk", did(), rid(), oid());
        eprintln!("{:#?} → {}", e, filter.allows(&e));
        assert!(!filter.allows(&e));
    }

    #[test]
    fn doesnt_allow_unexpected_tag_even_if_wanted_is_suffix() {
        let filter = EventFilter::Tag("test-tag".to_string());
        eprintln!("filter: {filter:#?}");

        let e = tag_created("junk-test-tag", did(), rid(), oid());
        eprintln!("{:#?} → {}", e, filter.allows(&e));
        assert!(!filter.allows(&e));
    }
}

#[derive(Debug, thiserror::Error)]
pub enum FilterError {
    #[error("failed to read event filters file {0}")]
    ReadFile(PathBuf, #[source] std::io::Error),

    #[error("failed to parse YAML event filters file {0}")]
    ParseYaml(PathBuf, #[source] Box<serde_norway::Error>),
}

impl FilterError {
    fn parse_yaml(path: &Path, err: serde_norway::Error) -> Self {
        Self::ParseYaml(path.into(), Box::new(err))
    }
}