Skip to main content

opendev_runtime/approval/
manager.rs

1//! `ApprovalRulesManager` — manages approval rules and command history.
2
3use chrono::Utc;
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7use super::persistence;
8use super::types::{ApprovalRule, CommandHistory, RuleAction, RuleScope, RuleType};
9
10/// Manager for approval rules and command history.
11///
12/// Supports both session-only (ephemeral) and persistent rules.
13/// Persistent rules are loaded from disk on init and survive across sessions.
14pub struct ApprovalRulesManager {
15    rules: Vec<ApprovalRule>,
16    history: Vec<CommandHistory>,
17    project_dir: Option<PathBuf>,
18}
19
20impl ApprovalRulesManager {
21    /// Create a new manager, loading default danger rules and persistent rules.
22    pub fn new(project_dir: Option<&Path>) -> Self {
23        let mut mgr = Self {
24            rules: Vec::new(),
25            history: Vec::new(),
26            project_dir: project_dir.map(|p| p.to_path_buf()),
27        };
28        mgr.initialize_default_rules();
29        persistence::load_persistent_rules(&mut mgr.rules, project_dir);
30        mgr
31    }
32
33    /// Read-only access to the current rule set.
34    pub fn rules(&self) -> &[ApprovalRule] {
35        &self.rules
36    }
37
38    /// Read-only access to command history.
39    pub fn history(&self) -> &[CommandHistory] {
40        &self.history
41    }
42
43    // ------------------------------------------------------------------
44    // Default rules
45    // ------------------------------------------------------------------
46
47    fn initialize_default_rules(&mut self) {
48        let now = Utc::now().to_rfc3339();
49        self.rules.push(ApprovalRule {
50            id: "default_danger_rm".to_string(),
51            name: "Dangerous rm commands".to_string(),
52            description: "Require approval for dangerous rm commands".to_string(),
53            rule_type: RuleType::Danger,
54            pattern: r"rm\s+(-rf?|-fr?)\s+(/|\*|~)".to_string(),
55            action: RuleAction::RequireApproval,
56            enabled: true,
57            priority: 100,
58            created_at: Some(now.clone()),
59            modified_at: None,
60            compiled_regex: OnceLock::new(),
61        });
62        self.rules.push(ApprovalRule {
63            id: "default_danger_chmod".to_string(),
64            name: "Dangerous chmod 777".to_string(),
65            description: "Require approval for chmod 777".to_string(),
66            rule_type: RuleType::Danger,
67            pattern: r"chmod\s+777".to_string(),
68            action: RuleAction::RequireApproval,
69            enabled: true,
70            priority: 100,
71            created_at: Some(now.clone()),
72            modified_at: None,
73            compiled_regex: OnceLock::new(),
74        });
75        self.rules.push(ApprovalRule {
76            id: "default_danger_git_force_push".to_string(),
77            name: "Git force push to protected branches".to_string(),
78            description:
79                "Require approval for force push to main/master/develop/production/staging"
80                    .to_string(),
81            rule_type: RuleType::Danger,
82            pattern: r"git\s+push\s+.*--force.*\b(main|master|develop|production|staging)\b"
83                .to_string(),
84            action: RuleAction::RequireApproval,
85            enabled: true,
86            priority: 100,
87            created_at: Some(now),
88            modified_at: None,
89            compiled_regex: OnceLock::new(),
90        });
91    }
92
93    // ------------------------------------------------------------------
94    // Rule evaluation
95    // ------------------------------------------------------------------
96
97    /// Evaluate a command against all enabled rules (highest priority first).
98    ///
99    /// Returns the first matching rule, or `None` if no rule applies.
100    pub fn evaluate_command(&self, command: &str) -> Option<&ApprovalRule> {
101        let mut enabled: Vec<&ApprovalRule> = self.rules.iter().filter(|r| r.enabled).collect();
102        enabled.sort_by(|a, b| b.priority.cmp(&a.priority));
103        enabled.into_iter().find(|r| r.matches(command))
104    }
105
106    // ------------------------------------------------------------------
107    // CRUD
108    // ------------------------------------------------------------------
109
110    /// Add a session-only rule.
111    pub fn add_rule(&mut self, rule: ApprovalRule) {
112        self.rules.push(rule);
113    }
114
115    /// Update fields on an existing rule by ID.
116    ///
117    /// Returns `true` if a rule was found and updated.
118    pub fn update_rule<F>(&mut self, rule_id: &str, updater: F) -> bool
119    where
120        F: FnOnce(&mut ApprovalRule),
121    {
122        if let Some(rule) = self.rules.iter_mut().find(|r| r.id == rule_id) {
123            updater(rule);
124            rule.modified_at = Some(Utc::now().to_rfc3339());
125            true
126        } else {
127            false
128        }
129    }
130
131    /// Remove a rule by ID. Returns `true` if something was removed.
132    pub fn remove_rule(&mut self, rule_id: &str) -> bool {
133        let before = self.rules.len();
134        self.rules.retain(|r| r.id != rule_id);
135        self.rules.len() != before
136    }
137
138    // ------------------------------------------------------------------
139    // History
140    // ------------------------------------------------------------------
141
142    /// Record a command evaluation in the session history.
143    pub fn add_history(
144        &mut self,
145        command: &str,
146        approved: bool,
147        edited_command: Option<String>,
148        rule_matched: Option<String>,
149    ) {
150        self.history.push(CommandHistory {
151            command: command.to_string(),
152            approved,
153            edited_command,
154            timestamp: Some(Utc::now().to_rfc3339()),
155            rule_matched,
156        });
157    }
158
159    // ------------------------------------------------------------------
160    // Persistent rules
161    // ------------------------------------------------------------------
162
163    /// Add a rule and persist it to disk.
164    pub fn add_persistent_rule(&mut self, rule: ApprovalRule, scope: RuleScope) {
165        self.add_rule(rule);
166        persistence::save_persistent_rules(&self.rules, self.project_dir.as_deref(), scope);
167    }
168
169    /// Remove a rule and update persistent storage.
170    pub fn remove_persistent_rule(&mut self, rule_id: &str) -> bool {
171        let removed = self.remove_rule(rule_id);
172        if removed {
173            persistence::save_persistent_rules(
174                &self.rules,
175                self.project_dir.as_deref(),
176                RuleScope::User,
177            );
178            if self.project_dir.is_some() {
179                persistence::save_persistent_rules(
180                    &self.rules,
181                    self.project_dir.as_deref(),
182                    RuleScope::Project,
183                );
184            }
185        }
186        removed
187    }
188
189    /// Remove all persistent (non-default) rules. Returns count removed.
190    pub fn clear_persistent_rules(&mut self, scope: RuleScope) -> usize {
191        let before = self.rules.len();
192        self.rules.retain(|r| r.id.starts_with("default_"));
193        let removed = before - self.rules.len();
194
195        if matches!(scope, RuleScope::User | RuleScope::All)
196            && let Some(path) = persistence::user_permissions_path()
197        {
198            persistence::delete_permissions_file(&path);
199        }
200        if matches!(scope, RuleScope::Project | RuleScope::All)
201            && let Some(ref dir) = self.project_dir
202        {
203            persistence::delete_permissions_file(&dir.join(".opendev").join("permissions.json"));
204        }
205
206        removed
207    }
208
209    /// List all non-default rules in a display-friendly format.
210    pub fn list_persistent_rules(&self) -> Vec<serde_json::Value> {
211        self.rules
212            .iter()
213            .filter(|r| !r.id.starts_with("default_"))
214            .map(|r| {
215                serde_json::json!({
216                    "id": r.id,
217                    "name": r.name,
218                    "pattern": r.pattern,
219                    "action": r.action,
220                    "type": r.rule_type,
221                    "enabled": r.enabled,
222                })
223            })
224            .collect()
225    }
226}