backbone_bucket/domain/state_machine/
file_comment_state_machine.rs1use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum FileCommentState {
12 Active,
14 Resolved,
15 Deleted,
17}
18
19impl Default for FileCommentState {
20 fn default() -> Self {
21 Self::Active
22 }
23}
24
25impl std::fmt::Display for FileCommentState {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 Self::Active => write!(f, "active"),
29 Self::Resolved => write!(f, "resolved"),
30 Self::Deleted => write!(f, "deleted"),
31 }
32 }
33}
34
35impl FromStr for FileCommentState {
36 type Err = StateMachineError;
37
38 fn from_str(s: &str) -> Result<Self, Self::Err> {
39 match s.to_lowercase().as_str() {
40 "active" => Ok(Self::Active),
41 "resolved" => Ok(Self::Resolved),
42 "deleted" => Ok(Self::Deleted),
43 _ => Err(StateMachineError::InvalidState(s.to_string())),
44 }
45 }
46}
47
48impl FileCommentState {
49 pub fn is_initial(&self) -> bool {
51 matches!(self, Self::Active)
52 }
53
54 pub fn is_final(&self) -> bool {
56 matches!(self, Self::Deleted)
57 }
58
59 pub fn all() -> Vec<Self> {
61 vec![
62 Self::Active,
63 Self::Resolved,
64 Self::Deleted,
65 ]
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum FileCommentTransition {
73 Resolve,
75 Edit,
77 DeleteActive,
79 Reopen,
81 DeleteResolved,
83}
84
85impl std::fmt::Display for FileCommentTransition {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self {
88 Self::Resolve => write!(f, "resolve"),
89 Self::Edit => write!(f, "edit"),
90 Self::DeleteActive => write!(f, "delete_active"),
91 Self::Reopen => write!(f, "reopen"),
92 Self::DeleteResolved => write!(f, "delete_resolved"),
93 }
94 }
95}
96
97impl FromStr for FileCommentTransition {
98 type Err = StateMachineError;
99
100 fn from_str(s: &str) -> Result<Self, Self::Err> {
101 match s.to_lowercase().as_str() {
102 "resolve" => Ok(Self::Resolve),
103 "edit" => Ok(Self::Edit),
104 "delete_active" => Ok(Self::DeleteActive),
105 "reopen" => Ok(Self::Reopen),
106 "delete_resolved" => Ok(Self::DeleteResolved),
107 _ => Err(StateMachineError::InvalidTransition(s.to_string())),
108 }
109 }
110}
111
112impl FileCommentTransition {
113 pub fn target_state(&self) -> FileCommentState {
115 match self {
116 Self::Resolve => FileCommentState::Resolved,
117 Self::Edit => FileCommentState::Active,
118 Self::DeleteActive => FileCommentState::Deleted,
119 Self::Reopen => FileCommentState::Active,
120 Self::DeleteResolved => FileCommentState::Deleted,
121 }
122 }
123
124 pub fn all() -> Vec<Self> {
126 vec![
127 Self::Resolve,
128 Self::Edit,
129 Self::DeleteActive,
130 Self::Reopen,
131 Self::DeleteResolved,
132 ]
133 }
134
135 pub fn allowed_roles(&self) -> &'static [&'static str] {
137 match self {
138 Self::Resolve => &["owner", "file_owner"],
139 Self::Edit => &["owner"],
140 Self::DeleteActive => &["owner", "file_owner"],
141 Self::Reopen => &["user"],
142 Self::DeleteResolved => &["owner", "file_owner"],
143 }
144 }
145}
146
147use super::StateMachineError;
148
149#[derive(Debug, Clone)]
151pub struct FileCommentStateMachine {
152 current_state: FileCommentState,
153}
154
155impl FileCommentStateMachine {
156 pub fn new() -> Self {
158 Self {
159 current_state: FileCommentState::default(),
160 }
161 }
162
163 pub fn from_state(state: FileCommentState) -> Self {
165 Self { current_state: state }
166 }
167
168 pub fn current_state(&self) -> FileCommentState {
170 self.current_state
171 }
172
173 pub fn can_transition(&self, transition: FileCommentTransition) -> bool {
175 if matches!(self.current_state, FileCommentState::Deleted) {
176 return false;
177 }
178
179 match (self.current_state, transition) {
180 (FileCommentState::Active, FileCommentTransition::Resolve) => true,
181 (FileCommentState::Active, FileCommentTransition::Edit) => true,
182 (FileCommentState::Active, FileCommentTransition::DeleteActive) => true,
183 (FileCommentState::Resolved, FileCommentTransition::Reopen) => true,
184 (FileCommentState::Resolved, FileCommentTransition::DeleteResolved) => true,
185 _ => false,
186 }
187 }
188
189 pub fn can_transition_with_role(&self, transition: FileCommentTransition, role: &str) -> bool {
191 if !self.can_transition(transition) {
192 return false;
193 }
194
195 let allowed_roles = transition.allowed_roles();
196 if allowed_roles.is_empty() {
197 return true; }
199
200 allowed_roles.iter().any(|r| *r == role || *r == "*")
201 }
202
203 pub fn transition(&mut self, transition: FileCommentTransition) -> Result<FileCommentState, StateMachineError> {
205 if !self.can_transition(transition) {
206 return Err(StateMachineError::TransitionNotAllowed {
207 transition: transition.to_string(),
208 from: self.current_state.to_string(),
209 });
210 }
211
212 self.current_state = transition.target_state();
213 Ok(self.current_state)
214 }
215
216 pub fn transition_with_role(&mut self, transition: FileCommentTransition, role: &str) -> Result<FileCommentState, StateMachineError> {
218 if !self.can_transition(transition) {
220 return Err(StateMachineError::TransitionNotAllowed {
221 transition: transition.to_string(),
222 from: self.current_state.to_string(),
223 });
224 }
225
226 if !self.can_transition_with_role(transition, role) {
228 return Err(StateMachineError::RoleNotAuthorized {
229 role: role.to_string(),
230 transition: transition.to_string(),
231 });
232 }
233
234 self.current_state = transition.target_state();
235 Ok(self.current_state)
236 }
237
238 pub fn available_transitions(&self) -> Vec<FileCommentTransition> {
240 FileCommentTransition::all()
241 .into_iter()
242 .filter(|t| self.can_transition(*t))
243 .collect()
244 }
245
246 pub fn available_transitions_for_role(&self, role: &str) -> Vec<FileCommentTransition> {
248 FileCommentTransition::all()
249 .into_iter()
250 .filter(|t| self.can_transition_with_role(*t, role))
251 .collect()
252 }
253
254 pub fn transition_to_state(&mut self, target: FileCommentState) -> Result<FileCommentState, StateMachineError> {
259 let valid = FileCommentTransition::all().into_iter()
260 .filter(|t| self.can_transition(*t))
261 .find(|t| t.target_state() == target);
262 match valid {
263 Some(t) => self.transition(t),
264 None => Err(StateMachineError::TransitionNotAllowed {
265 transition: target.to_string(),
266 from: self.current_state.to_string(),
267 }),
268 }
269 }
270}
271
272impl Default for FileCommentStateMachine {
273 fn default() -> Self {
274 Self::new()
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_initial_state() {
284 let sm = FileCommentStateMachine::new();
285 assert_eq!(sm.current_state(), FileCommentState::Active);
286 assert!(sm.current_state().is_initial());
287 }
288
289 #[test]
290 fn test_valid_transition() {
291 let mut sm = FileCommentStateMachine::from_state(FileCommentState::Active);
292 assert!(sm.can_transition(FileCommentTransition::Resolve));
293 let result = sm.transition(FileCommentTransition::Resolve);
294 assert!(result.is_ok());
295 assert_eq!(sm.current_state(), FileCommentState::Resolved);
296 }
297
298 #[test]
299 fn test_invalid_transition() {
300 let mut sm = FileCommentStateMachine::from_state(FileCommentState::Active);
301 let result = sm.transition(FileCommentTransition::Reopen);
303 assert!(result.is_err());
304 }
305
306 #[test]
307 fn test_state_parsing() {
308 let state: FileCommentState = "active".parse().unwrap();
309 assert_eq!(state, FileCommentState::Active);
310 }
311
312 #[test]
313 fn test_available_transitions() {
314 let sm = FileCommentStateMachine::new();
315 let available = sm.available_transitions();
316 assert!(!available.is_empty() || sm.current_state().is_final());
318 }
319}