opendev_runtime/approval/
manager.rs1use chrono::Utc;
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7use super::persistence;
8use super::types::{ApprovalRule, CommandHistory, RuleAction, RuleScope, RuleType};
9
10pub struct ApprovalRulesManager {
15 rules: Vec<ApprovalRule>,
16 history: Vec<CommandHistory>,
17 project_dir: Option<PathBuf>,
18}
19
20impl ApprovalRulesManager {
21 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 pub fn rules(&self) -> &[ApprovalRule] {
35 &self.rules
36 }
37
38 pub fn history(&self) -> &[CommandHistory] {
40 &self.history
41 }
42
43 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 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 pub fn add_rule(&mut self, rule: ApprovalRule) {
112 self.rules.push(rule);
113 }
114
115 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 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 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 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 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 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 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}