Skip to main content

opendev_runtime/approval/
types.rs

1//! Rule-related type definitions for the approval system.
2
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::sync::OnceLock;
6use tracing::warn;
7
8/// Action to take when a rule matches.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum RuleAction {
12    AutoApprove,
13    AutoDeny,
14    RequireApproval,
15    RequireEdit,
16}
17
18/// How the rule pattern is matched against commands.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum RuleType {
22    /// Regex search within the command.
23    Pattern,
24    /// Exact string match.
25    Command,
26    /// Prefix match (exact or with trailing space + args).
27    Prefix,
28    /// Danger-pattern regex (same as Pattern but semantically distinct).
29    Danger,
30}
31
32/// A single approval rule.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ApprovalRule {
35    pub id: String,
36    pub name: String,
37    pub description: String,
38    pub rule_type: RuleType,
39    pub pattern: String,
40    pub action: RuleAction,
41    #[serde(default = "default_true")]
42    pub enabled: bool,
43    #[serde(default)]
44    pub priority: i32,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub created_at: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub modified_at: Option<String>,
49
50    /// Compiled regex pattern, lazily initialized on first match.
51    /// Skipped during serialization; rebuilt on demand via `OnceLock`.
52    #[serde(skip)]
53    pub(crate) compiled_regex: OnceLock<Option<Regex>>,
54}
55
56fn default_true() -> bool {
57    true
58}
59
60impl ApprovalRule {
61    /// Create a new approval rule.
62    #[allow(clippy::too_many_arguments)]
63    pub fn new(
64        id: String,
65        name: String,
66        description: String,
67        rule_type: RuleType,
68        pattern: String,
69        action: RuleAction,
70        enabled: bool,
71        priority: i32,
72    ) -> Self {
73        Self {
74            id,
75            name,
76            description,
77            rule_type,
78            pattern,
79            action,
80            enabled,
81            priority,
82            created_at: None,
83            modified_at: None,
84            compiled_regex: OnceLock::new(),
85        }
86    }
87
88    /// Get the compiled regex, initializing it on first access.
89    /// Returns `None` for non-regex rule types or invalid patterns.
90    fn get_compiled_regex(&self) -> Option<&Regex> {
91        if !matches!(self.rule_type, RuleType::Pattern | RuleType::Danger) {
92            return None;
93        }
94        self.compiled_regex
95            .get_or_init(|| match Regex::new(&self.pattern) {
96                Ok(re) => Some(re),
97                Err(e) => {
98                    warn!("Invalid regex pattern '{}': {}", self.pattern, e);
99                    None
100                }
101            })
102            .as_ref()
103    }
104
105    /// Check whether this rule matches the given command string.
106    pub fn matches(&self, command: &str) -> bool {
107        if !self.enabled {
108            return false;
109        }
110        match self.rule_type {
111            RuleType::Pattern | RuleType::Danger => self
112                .get_compiled_regex()
113                .map(|re| re.is_match(command))
114                .unwrap_or(false),
115            RuleType::Command => command == self.pattern,
116            RuleType::Prefix => {
117                command == self.pattern || command.starts_with(&format!("{} ", self.pattern))
118            }
119        }
120    }
121}
122
123/// Record of a command that was evaluated by the approval system.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CommandHistory {
126    pub command: String,
127    pub approved: bool,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub edited_command: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub timestamp: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub rule_matched: Option<String>,
134}
135
136/// Persistence scope for rules.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum RuleScope {
139    User,
140    Project,
141    All,
142}