repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use super::lifecycle::RuleLifecycle;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum RuleScope {
    File,
    Repository,
    FrameworkProject,
    ChangeSet,
}

impl RuleScope {
    pub fn label(self) -> &'static str {
        match self {
            Self::File => "file",
            Self::Repository => "repository",
            Self::FrameworkProject => "framework-project",
            Self::ChangeSet => "change-set",
        }
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum FactKind {
    FileMetadata,
    FileContent,
    ParsedSyntax,
    Imports,
    Exports,
    FileContext,
    WorkspaceMetadata,
    DependencyGraph,
    DependencyManifest,
    ConfigFile,
    FrameworkContext,
    GitDiff,
}

impl FactKind {
    pub fn label(self) -> &'static str {
        match self {
            Self::FileMetadata => "file-metadata",
            Self::FileContent => "file-content",
            Self::ParsedSyntax => "parsed-syntax",
            Self::Imports => "imports",
            Self::Exports => "exports",
            Self::FileContext => "file-context",
            Self::WorkspaceMetadata => "workspace-metadata",
            Self::DependencyGraph => "dependency-graph",
            Self::DependencyManifest => "dependency-manifest",
            Self::ConfigFile => "config-file",
            Self::FrameworkContext => "framework-context",
            Self::GitDiff => "git-diff",
        }
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum RuleCachePolicy {
    PerFileContent,
    PerWorkspaceRevision,
    PerChangeSet,
    Uncached,
}

impl RuleCachePolicy {
    pub fn label(self) -> &'static str {
        match self {
            Self::PerFileContent => "per-file-content",
            Self::PerWorkspaceRevision => "per-workspace-revision",
            Self::PerChangeSet => "per-change-set",
            Self::Uncached => "uncached",
        }
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum RuleOutputKind {
    Finding,
}

impl RuleOutputKind {
    pub fn label(self) -> &'static str {
        match self {
            Self::Finding => "finding",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RuleRequirements {
    pub scope: RuleScope,
    pub fact_kinds: &'static [FactKind],
    pub lifecycle: RuleLifecycle,
    pub cache_policy: RuleCachePolicy,
    pub produces: &'static [RuleOutputKind],
}

const FINDING_OUTPUT: &[RuleOutputKind] = &[RuleOutputKind::Finding];

const FILE_TEXT_FACTS: &[FactKind] = &[FactKind::FileMetadata, FactKind::FileContent];
const FILE_AST_FACTS: &[FactKind] = &[
    FactKind::FileMetadata,
    FactKind::ParsedSyntax,
    FactKind::FileContext,
];
const REPOSITORY_TEXT_FACTS: &[FactKind] = &[
    FactKind::FileMetadata,
    FactKind::FileContent,
    FactKind::WorkspaceMetadata,
];
const REPOSITORY_CONFIG_FACTS: &[FactKind] = &[FactKind::ConfigFile, FactKind::WorkspaceMetadata];
const REPOSITORY_GRAPH_FACTS: &[FactKind] = &[
    FactKind::Imports,
    FactKind::Exports,
    FactKind::FileContext,
    FactKind::DependencyGraph,
    FactKind::WorkspaceMetadata,
];
const REPOSITORY_MIXED_FACTS: &[FactKind] = &[
    FactKind::FileContent,
    FactKind::ParsedSyntax,
    FactKind::FileContext,
    FactKind::WorkspaceMetadata,
];
const FRAMEWORK_AST_FACTS: &[FactKind] = &[
    FactKind::ParsedSyntax,
    FactKind::FileContext,
    FactKind::FrameworkContext,
];
const FRAMEWORK_MANIFEST_FACTS: &[FactKind] = &[
    FactKind::DependencyManifest,
    FactKind::FrameworkContext,
    FactKind::WorkspaceMetadata,
];
const FRAMEWORK_CONFIG_FACTS: &[FactKind] = &[
    FactKind::ConfigFile,
    FactKind::FrameworkContext,
    FactKind::WorkspaceMetadata,
];
const FRAMEWORK_DETECTOR_FACTS: &[FactKind] =
    &[FactKind::FrameworkContext, FactKind::WorkspaceMetadata];
const FRAMEWORK_TEXT_FACTS: &[FactKind] = &[
    FactKind::FileContent,
    FactKind::FileContext,
    FactKind::FrameworkContext,
];
const CHANGE_SET_FACTS: &[FactKind] = &[FactKind::GitDiff, FactKind::WorkspaceMetadata];

impl RuleRequirements {
    pub const UNDECLARED: Self = Self {
        scope: RuleScope::File,
        fact_kinds: &[],
        lifecycle: RuleLifecycle::Preview,
        cache_policy: RuleCachePolicy::Uncached,
        produces: &[],
    };

    const fn declared(
        scope: RuleScope,
        fact_kinds: &'static [FactKind],
        lifecycle: RuleLifecycle,
        cache_policy: RuleCachePolicy,
    ) -> Self {
        Self {
            scope,
            fact_kinds,
            lifecycle,
            cache_policy,
            produces: FINDING_OUTPUT,
        }
    }

    pub const fn file_text(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::File,
            FILE_TEXT_FACTS,
            lifecycle,
            RuleCachePolicy::PerFileContent,
        )
    }

    pub const fn file_ast(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::File,
            FILE_AST_FACTS,
            lifecycle,
            RuleCachePolicy::PerFileContent,
        )
    }

    pub const fn repository_text(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::Repository,
            REPOSITORY_TEXT_FACTS,
            lifecycle,
            RuleCachePolicy::PerWorkspaceRevision,
        )
    }

    pub const fn repository_config(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::Repository,
            REPOSITORY_CONFIG_FACTS,
            lifecycle,
            RuleCachePolicy::PerWorkspaceRevision,
        )
    }

    pub const fn repository_graph(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::Repository,
            REPOSITORY_GRAPH_FACTS,
            lifecycle,
            RuleCachePolicy::PerWorkspaceRevision,
        )
    }

    pub const fn repository_mixed(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::Repository,
            REPOSITORY_MIXED_FACTS,
            lifecycle,
            RuleCachePolicy::PerWorkspaceRevision,
        )
    }

    pub const fn framework_ast(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::FrameworkProject,
            FRAMEWORK_AST_FACTS,
            lifecycle,
            RuleCachePolicy::PerWorkspaceRevision,
        )
    }

    pub const fn framework_manifest(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::FrameworkProject,
            FRAMEWORK_MANIFEST_FACTS,
            lifecycle,
            RuleCachePolicy::PerWorkspaceRevision,
        )
    }

    pub const fn framework_config(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::FrameworkProject,
            FRAMEWORK_CONFIG_FACTS,
            lifecycle,
            RuleCachePolicy::PerWorkspaceRevision,
        )
    }

    pub const fn framework_detector(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::FrameworkProject,
            FRAMEWORK_DETECTOR_FACTS,
            lifecycle,
            RuleCachePolicy::PerWorkspaceRevision,
        )
    }

    pub const fn framework_text(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::FrameworkProject,
            FRAMEWORK_TEXT_FACTS,
            lifecycle,
            RuleCachePolicy::PerWorkspaceRevision,
        )
    }

    pub const fn change_set(lifecycle: RuleLifecycle) -> Self {
        Self::declared(
            RuleScope::ChangeSet,
            CHANGE_SET_FACTS,
            lifecycle,
            RuleCachePolicy::PerChangeSet,
        )
    }

    pub fn is_declared(self) -> bool {
        !self.fact_kinds.is_empty()
            && !self.produces.is_empty()
            && self.cache_policy != RuleCachePolicy::Uncached
    }

    pub fn requires_parsed_syntax(self) -> bool {
        self.fact_kinds.contains(&FactKind::ParsedSyntax)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn file_ast_requires_shared_parse_and_context() {
        let requirements = RuleRequirements::file_ast(RuleLifecycle::Preview);

        assert_eq!(requirements.scope, RuleScope::File);
        assert!(requirements.requires_parsed_syntax());
        assert!(requirements.fact_kinds.contains(&FactKind::FileContext));
        assert_eq!(requirements.cache_policy, RuleCachePolicy::PerFileContent);
        assert!(requirements.is_declared());
    }

    #[test]
    fn graph_rules_are_revision_scoped() {
        let requirements = RuleRequirements::repository_graph(RuleLifecycle::Stable);

        assert_eq!(requirements.scope, RuleScope::Repository);
        assert!(requirements.fact_kinds.contains(&FactKind::DependencyGraph));
        assert_eq!(
            requirements.cache_policy,
            RuleCachePolicy::PerWorkspaceRevision
        );
        assert_eq!(requirements.lifecycle, RuleLifecycle::Stable);
    }

    #[test]
    fn undeclared_requirements_are_detectable() {
        assert!(!RuleRequirements::UNDECLARED.is_declared());
    }
}