Skip to main content

parley/services/
review_service.rs

1use crate::domain::config::AppConfig;
2use crate::domain::review::{
3    Author, CommentLineRange, CommentStatus, DiffSide, LineAnchorSnapshot, NewLineComment,
4    ReanchorLineComment, ReviewSession, ReviewState, StoredAnchorSnapshot,
5};
6use crate::persistence::store::{Store, StoreError};
7use crate::utils::time::now_ms;
8use anyhow::{Context, Result};
9use std::path::PathBuf;
10
11#[derive(Debug, Clone)]
12pub struct ReviewService {
13    store: Store,
14}
15
16#[derive(Debug, Clone)]
17pub struct AddCommentInput {
18    pub file_path: String,
19    pub old_line: Option<u32>,
20    pub new_line: Option<u32>,
21    pub line_range: Option<CommentLineRange>,
22    pub side: DiffSide,
23    pub line_anchor: Option<LineAnchorSnapshot>,
24    pub original_anchor: Option<StoredAnchorSnapshot>,
25    pub body: String,
26    pub author: Author,
27}
28
29#[derive(Debug, Clone)]
30pub struct AddReplyInput {
31    pub comment_id: u64,
32    pub author: Author,
33    pub body: String,
34}
35
36#[derive(Debug, Clone)]
37pub struct ReanchorCommentInput {
38    pub comment_id: u64,
39    pub file_path: String,
40    pub old_line: Option<u32>,
41    pub new_line: Option<u32>,
42    pub line_range: Option<CommentLineRange>,
43    pub side: DiffSide,
44    pub line_anchor: Option<LineAnchorSnapshot>,
45}
46
47impl ReviewService {
48    #[must_use]
49    pub fn new(store: Store) -> Self {
50        Self { store }
51    }
52
53    /// # Errors
54    ///
55    /// Returns an error when the clock is invalid or the review cannot be persisted.
56    pub async fn create_review(&self, name: &str) -> Result<ReviewSession> {
57        let session = ReviewSession::new(name.to_string(), now_ms()?);
58        self.store
59            .create_review(&session)
60            .await
61            .with_context(|| format!("failed to create review {name}"))?;
62        Ok(session)
63    }
64
65    /// # Errors
66    ///
67    /// Returns an error when the review cannot be loaded from storage.
68    pub async fn load_review(&self, name: &str) -> Result<ReviewSession> {
69        self.store
70            .load_review(name)
71            .await
72            .with_context(|| format!("failed to load review {name}"))
73    }
74
75    /// # Errors
76    ///
77    /// Returns an error when the review exists but cannot be loaded, or when a missing review
78    /// cannot be created.
79    pub async fn load_or_create_review(&self, name: &str) -> Result<ReviewSession> {
80        match self.store.load_review(name).await {
81            Ok(session) => Ok(session),
82            Err(StoreError::ReviewNotFound(_)) => self.create_review(name).await,
83            Err(error) => Err(error).with_context(|| format!("failed to load review {name}")),
84        }
85    }
86
87    /// # Errors
88    ///
89    /// Returns an error when review storage cannot be listed.
90    pub async fn list_reviews(&self) -> Result<Vec<String>> {
91        self.store
92            .list_reviews()
93            .await
94            .context("failed to list reviews")
95    }
96
97    /// # Errors
98    ///
99    /// Returns an error when configuration cannot be loaded from storage.
100    pub async fn load_config(&self) -> Result<AppConfig> {
101        self.store
102            .load_config()
103            .await
104            .context("failed to load parler config")
105    }
106
107    /// # Errors
108    ///
109    /// Returns an error when configuration cannot be saved to storage.
110    pub async fn save_config(&self, config: &AppConfig) -> Result<()> {
111        self.store
112            .save_config(config)
113            .await
114            .context("failed to save parler config")
115    }
116
117    /// # Errors
118    ///
119    /// Returns an error when `review_name` is invalid.
120    pub fn review_log_path(&self, review_name: &str) -> Result<PathBuf> {
121        self.store
122            .review_log_path(review_name)
123            .with_context(|| format!("failed to resolve log path for review {review_name}"))
124    }
125
126    /// # Errors
127    ///
128    /// Returns an error when the review cannot be loaded, the clock is invalid, the state
129    /// transition is rejected, or the updated review cannot be saved.
130    pub async fn set_state(&self, name: &str, next: ReviewState) -> Result<ReviewSession> {
131        self.mutate_review(name, "failed to save state change", |session, now_ms| {
132            session.set_state(next, now_ms).map_err(Into::into)
133        })
134        .await
135    }
136
137    /// # Errors
138    ///
139    /// Returns an error when the review cannot be loaded, the clock is invalid, or the new comment
140    /// cannot be persisted.
141    pub async fn add_comment(&self, name: &str, input: AddCommentInput) -> Result<ReviewSession> {
142        let new_comment = NewLineComment {
143            file_path: input.file_path,
144            old_line: input.old_line,
145            new_line: input.new_line,
146            line_range: input.line_range,
147            side: input.side,
148            line_anchor: input.line_anchor,
149            original_anchor: input.original_anchor,
150            body: input.body,
151            author: input.author,
152        };
153
154        self.mutate_review(name, "failed to persist new comment", |session, now_ms| {
155            session.add_comment(new_comment, now_ms);
156            Ok(())
157        })
158        .await
159    }
160
161    /// # Errors
162    ///
163    /// Returns an error when the review cannot be loaded, the target comment is missing, the clock
164    /// is invalid, or the reply cannot be persisted.
165    pub async fn add_reply(&self, name: &str, input: AddReplyInput) -> Result<ReviewSession> {
166        self.mutate_review(name, "failed to persist new reply", |session, now_ms| {
167            session
168                .add_reply(input.comment_id, input.author, input.body, now_ms)
169                .map(|_| ())
170                .map_err(Into::into)
171        })
172        .await
173    }
174
175    /// # Errors
176    ///
177    /// Returns an error when the review cannot be loaded, the actor may not mark the comment
178    /// addressed, the clock is invalid, or the update cannot be persisted.
179    pub async fn mark_addressed(
180        &self,
181        name: &str,
182        comment_id: u64,
183        actor: Author,
184    ) -> Result<ReviewSession> {
185        self.set_comment_status(name, comment_id, CommentStatus::Addressed, actor)
186            .await
187    }
188
189    /// # Errors
190    ///
191    /// Returns an error when the review cannot be loaded, the actor may not reopen the comment, the
192    /// clock is invalid, or the update cannot be persisted.
193    pub async fn mark_open(
194        &self,
195        name: &str,
196        comment_id: u64,
197        actor: Author,
198    ) -> Result<ReviewSession> {
199        self.set_comment_status(name, comment_id, CommentStatus::Open, actor)
200            .await
201    }
202
203    /// # Errors
204    ///
205    /// Returns an error when the review cannot be loaded, the target comment is missing, the clock
206    /// is invalid, or the update cannot be persisted.
207    pub async fn force_mark_addressed(&self, name: &str, comment_id: u64) -> Result<ReviewSession> {
208        self.mutate_review(
209            name,
210            "failed to persist forced comment status",
211            |session, now_ms| {
212                session
213                    .set_comment_status_force(comment_id, CommentStatus::Addressed, now_ms)
214                    .map_err(Into::into)
215            },
216        )
217        .await
218    }
219
220    /// # Errors
221    ///
222    /// Returns an error when the review cannot be loaded, the target comment is missing, the clock
223    /// is invalid, or the re-anchor cannot be persisted.
224    pub async fn reanchor_comment(
225        &self,
226        name: &str,
227        input: ReanchorCommentInput,
228    ) -> Result<ReviewSession> {
229        let target = ReanchorLineComment {
230            file_path: input.file_path,
231            old_line: input.old_line,
232            new_line: input.new_line,
233            line_range: input.line_range,
234            side: input.side,
235            line_anchor: input.line_anchor,
236        };
237
238        self.mutate_review(
239            name,
240            "failed to persist comment re-anchor",
241            |session, now_ms| {
242                session
243                    .reanchor_comment(input.comment_id, target, now_ms)
244                    .map_err(Into::into)
245            },
246        )
247        .await
248    }
249
250    /// # Errors
251    ///
252    /// Returns an error when the review session cannot be saved.
253    pub async fn save_review(&self, session: &ReviewSession) -> Result<()> {
254        self.store
255            .save_review(session)
256            .await
257            .context("failed to save review session")
258    }
259
260    async fn set_comment_status(
261        &self,
262        name: &str,
263        comment_id: u64,
264        status: CommentStatus,
265        actor: Author,
266    ) -> Result<ReviewSession> {
267        self.mutate_review(
268            name,
269            "failed to persist comment status",
270            |session, now_ms| {
271                session
272                    .set_comment_status(comment_id, status, actor, now_ms)
273                    .map_err(Into::into)
274            },
275        )
276        .await
277    }
278
279    async fn mutate_review(
280        &self,
281        name: &str,
282        save_context: &'static str,
283        mutate: impl FnOnce(&mut ReviewSession, u64) -> Result<()>,
284    ) -> Result<ReviewSession> {
285        let mut session = self.load_review(name).await?;
286        mutate(&mut session, now_ms()?)?;
287        self.store
288            .save_review(&session)
289            .await
290            .context(save_context)?;
291        Ok(session)
292    }
293}