1use crate::domain::config::AppConfig;
2use crate::domain::review::ReviewSession;
3use std::io::Error;
4use std::io::ErrorKind;
5use std::path::Path;
6use std::path::PathBuf;
7use tokio::fs;
8
9#[derive(Debug, thiserror::Error)]
10pub enum StoreError {
11 #[error("invalid review name: {0}")]
12 InvalidReviewName(String),
13 #[error("review not found: {0}")]
14 ReviewNotFound(String),
15 #[error("io error: {0}")]
16 Io(#[from] Error),
17 #[error("json error: {0}")]
18 Json(#[from] serde_json::Error),
19 #[error("toml deserialize error: {0}")]
20 TomlDeserialize(#[from] toml::de::Error),
21 #[error("toml serialize error: {0}")]
22 TomlSerialize(#[from] toml::ser::Error),
23}
24
25pub type StoreResult<T> = Result<T, StoreError>;
26
27#[derive(Debug, Clone)]
28pub struct Store {
29 root: PathBuf,
30}
31
32impl Store {
33 pub fn from_project_root(project_root: impl AsRef<Path>) -> Self {
34 Self {
35 root: project_root.as_ref().join(".parley"),
36 }
37 }
38
39 pub async fn ensure_dirs(&self) -> StoreResult<()> {
43 fs::create_dir_all(self.reviews_dir()).await?;
44 Ok(())
45 }
46
47 pub async fn create_review(&self, session: &ReviewSession) -> StoreResult<()> {
51 self.save_review(session).await
52 }
53
54 pub async fn save_review(&self, session: &ReviewSession) -> StoreResult<()> {
59 self.ensure_dirs().await?;
60
61 let path = self.review_path(&session.name)?;
62 if let Some(parent) = path.parent() {
63 fs::create_dir_all(parent).await?;
64 }
65 let data = serde_json::to_vec_pretty(session)?;
66 fs::write(path, data).await?;
67 Ok(())
68 }
69
70 pub async fn load_review(&self, name: &str) -> StoreResult<ReviewSession> {
75 let review_path = self.review_path(name)?;
76 if let Some(review) = read_review_file(&review_path).await? {
77 return Ok(review);
78 }
79
80 if let Some(review) = self.load_legacy_review(name).await? {
81 return Ok(review);
82 }
83
84 Err(StoreError::ReviewNotFound(name.to_string()))
85 }
86
87 pub async fn list_reviews(&self) -> StoreResult<Vec<String>> {
92 self.ensure_dirs().await?;
93 let mut dir = fs::read_dir(self.reviews_dir()).await?;
94 let mut result = Vec::new();
95
96 while let Some(entry) = dir.next_entry().await? {
97 let path = entry.path();
98 let file_type = entry.file_type().await?;
99 if file_type.is_dir() {
100 let review_path = path.join("review.json");
101 if let Some(review) = read_review_file(&review_path).await? {
102 result.push(review.name);
103 }
104 } else if let Some(name) = self.legacy_review_name(&path).await? {
105 result.push(name);
106 }
107 }
108
109 result.sort_unstable();
110 result.dedup();
111 Ok(result)
112 }
113
114 pub fn review_log_path(&self, review_name: &str) -> StoreResult<PathBuf> {
118 Ok(self.review_dir(review_name)?.join("logs").join("tui.log"))
119 }
120
121 fn review_path(&self, name: &str) -> StoreResult<PathBuf> {
122 Ok(self.review_dir(name)?.join("review.json"))
123 }
124
125 fn review_dir(&self, name: &str) -> StoreResult<PathBuf> {
126 Ok(self.reviews_dir().join(normalize_review_name(name)?))
127 }
128
129 fn reviews_dir(&self) -> PathBuf {
130 self.root.join("reviews")
131 }
132
133 pub async fn load_config(&self) -> StoreResult<AppConfig> {
138 self.ensure_dirs().await?;
139 let path = self.config_path();
140
141 let Some(bytes) = read_optional_file(&path).await? else {
142 return Ok(AppConfig::default());
143 };
144 let text = String::from_utf8(bytes).map_err(|error| {
145 StoreError::Io(Error::new(
146 ErrorKind::InvalidData,
147 format!("invalid utf-8 in config.toml: {error}"),
148 ))
149 })?;
150 Ok(toml::from_str(&text)?)
151 }
152
153 pub async fn save_config(&self, config: &AppConfig) -> StoreResult<()> {
158 self.ensure_dirs().await?;
159 let data = toml::to_string_pretty(config)?;
160 fs::write(self.config_path(), data).await?;
161 Ok(())
162 }
163
164 fn config_path(&self) -> PathBuf {
165 self.root.join("config.toml")
166 }
167
168 async fn load_legacy_review(&self, name: &str) -> StoreResult<Option<ReviewSession>> {
170 let legacy_path = self.legacy_review_path(name)?;
171 read_review_file(&legacy_path).await
172 }
173
174 async fn legacy_review_name(&self, path: &Path) -> StoreResult<Option<String>> {
175 if path.extension().and_then(|value| value.to_str()) != Some("json") {
176 return Ok(None);
177 }
178
179 let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else {
180 return Ok(None);
181 };
182
183 let normalized_path = self.review_path(stem)?;
184 if fs::try_exists(normalized_path).await? {
185 Ok(None)
186 } else {
187 Ok(Some(stem.to_string()))
188 }
189 }
190
191 fn legacy_review_path(&self, name: &str) -> StoreResult<PathBuf> {
192 validate_review_name(name)?;
193 Ok(self.reviews_dir().join(format!("{name}.json")))
194 }
195}
196
197async fn read_review_file(path: &Path) -> StoreResult<Option<ReviewSession>> {
198 let Some(bytes) = read_optional_file(path).await? else {
199 return Ok(None);
200 };
201 Ok(Some(serde_json::from_slice(&bytes)?))
202}
203
204async fn read_optional_file(path: &Path) -> StoreResult<Option<Vec<u8>>> {
205 match fs::read(path).await {
206 Ok(bytes) => Ok(Some(bytes)),
207 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
208 Err(error) => Err(StoreError::Io(error)),
209 }
210}
211
212pub fn normalize_review_name(name: &str) -> StoreResult<String> {
217 validate_review_name(name)?;
218 let normalized = name.trim_matches(|ch| matches!(ch, '_' | '.')).to_string();
219 if normalized.is_empty() {
220 return Err(StoreError::InvalidReviewName(name.to_string()));
221 }
222 Ok(normalized)
223}
224
225pub fn validate_review_name(name: &str) -> StoreResult<()> {
230 if name.is_empty() {
231 return Err(StoreError::InvalidReviewName(name.to_string()));
232 }
233
234 if name
235 .chars()
236 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
237 {
238 Ok(())
239 } else {
240 Err(StoreError::InvalidReviewName(name.to_string()))
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use crate::domain::config::{AiConfig, DiffViewMode};
247 use crate::domain::review::{
248 Author, DiffSide, NewLineComment, SourceAnchorSnapshot, StoredAnchorSnapshot,
249 };
250 use anyhow::Result;
251 use tempfile::tempdir;
252
253 #[tokio::test]
254 async fn save_and_load_review_should_round_trip() -> Result<()> {
255 let tmp = tempdir()?;
256 let store = super::Store::from_project_root(tmp.path());
257 let review = super::ReviewSession::new("r1".into(), 1);
258
259 store.save_review(&review).await?;
260 let loaded = store.load_review("r1").await?;
261
262 assert_eq!(loaded.name, "r1");
263 assert_eq!(loaded.state, review.state);
264 Ok(())
265 }
266
267 #[tokio::test]
268 async fn save_review_should_use_normalized_review_directory() -> Result<()> {
269 let tmp = tempdir()?;
270 let store = super::Store::from_project_root(tmp.path());
271 let review = super::ReviewSession::new("__r1__".into(), 1);
272
273 store.save_review(&review).await?;
274
275 let path = tmp.path().join(".parley/reviews/r1/review.json");
276 assert!(path.exists());
277 Ok(())
278 }
279
280 #[tokio::test]
281 async fn load_and_list_reviews_should_support_legacy_flat_files() -> Result<()> {
282 let tmp = tempdir()?;
283 let store = super::Store::from_project_root(tmp.path());
284 store.ensure_dirs().await?;
285 let review = super::ReviewSession::new("legacy".into(), 1);
286 let data = serde_json::to_vec_pretty(&review)?;
287 tokio::fs::write(tmp.path().join(".parley/reviews/legacy.json"), data).await?;
288
289 let loaded = store.load_review("legacy").await?;
290 let reviews = store.list_reviews().await?;
291
292 assert_eq!(loaded.name, "legacy");
293 assert_eq!(reviews, vec!["legacy"]);
294 Ok(())
295 }
296
297 #[tokio::test]
298 async fn load_review_should_default_missing_original_anchor() -> Result<()> {
299 let tmp = tempdir()?;
300 let store = super::Store::from_project_root(tmp.path());
301 store.ensure_dirs().await?;
302 let review_dir = tmp.path().join(".parley/reviews/old");
303 super::fs::create_dir_all(&review_dir).await?;
304 super::fs::write(
305 review_dir.join("review.json"),
306 r#"{
307 "name": "old",
308 "state": "open",
309 "created_at_ms": 1,
310 "updated_at_ms": 1,
311 "comments": [
312 {
313 "id": 1,
314 "file_path": "src/lib.rs",
315 "old_line": null,
316 "new_line": 1,
317 "line_range": null,
318 "side": "right",
319 "line_anchor": null,
320 "detached": false,
321 "body": "old",
322 "author": "user",
323 "status": "open",
324 "replies": [],
325 "created_at_ms": 1,
326 "updated_at_ms": 1,
327 "addressed_at_ms": null
328 }
329 ],
330 "next_comment_id": 2,
331 "next_reply_id": 1
332}"#,
333 )
334 .await?;
335
336 let loaded = store.load_review("old").await?;
337
338 assert_eq!(loaded.comments[0].original_anchor, None);
339 Ok(())
340 }
341
342 #[tokio::test]
343 async fn save_and_load_review_should_round_trip_original_anchor() -> Result<()> {
344 let tmp = tempdir()?;
345 let store = super::Store::from_project_root(tmp.path());
346 let mut review = super::ReviewSession::new("anchored".into(), 1);
347 let original_anchor = StoredAnchorSnapshot {
348 file_path: "src/lib.rs".into(),
349 side: DiffSide::Right,
350 old_line: None,
351 new_line: Some(10),
352 line_range: None,
353 selected_text: "let value = 1;".into(),
354 before_context: vec!["fn main() {".into()],
355 after_context: vec!["}".into()],
356 diff: None,
357 source: Some(SourceAnchorSnapshot {
358 file_content_hash: Some("file-hash".into()),
359 selected_text_hash: Some("text-hash".into()),
360 }),
361 base_rev: Some("base".into()),
362 head_rev: Some("head".into()),
363 };
364 review.add_comment(
365 NewLineComment {
366 file_path: "src/lib.rs".into(),
367 old_line: None,
368 new_line: Some(10),
369 line_range: None,
370 side: DiffSide::Right,
371 line_anchor: None,
372 original_anchor: Some(original_anchor.clone()),
373 body: "anchor".into(),
374 author: Author::User,
375 },
376 2,
377 );
378
379 store.save_review(&review).await?;
380 let loaded = store.load_review("anchored").await?;
381
382 assert_eq!(loaded.comments[0].original_anchor, Some(original_anchor));
383 Ok(())
384 }
385
386 #[test]
387 fn validate_review_name_should_reject_slash() {
388 let result = super::validate_review_name("bad/name");
389
390 assert!(result.is_err());
391 }
392
393 #[tokio::test]
394 async fn save_and_load_config_should_round_trip() -> Result<()> {
395 let tmp = tempdir()?;
396 let store = super::Store::from_project_root(tmp.path());
397 let config = super::AppConfig {
398 user_name: "User".to_string(),
399 theme: "nord".to_string(),
400 diff_view: DiffViewMode::Unified,
401 ignore_parley_dir: true,
402 log_level: "debug".to_string(),
403 ai: AiConfig::default(),
404 };
405
406 store.save_config(&config).await?;
407 let loaded = store.load_config().await?;
408
409 assert_eq!(loaded, config);
410 Ok(())
411 }
412
413 #[tokio::test]
414 async fn load_config_should_support_legacy_name_field() -> Result<()> {
415 let tmp = tempdir()?;
416 let store = super::Store::from_project_root(tmp.path());
417 store.ensure_dirs().await?;
418
419 super::fs::write(
420 tmp.path().join(".parley").join("config.toml"),
421 "name = \"User\"\ntheme = \"nord\"\n",
422 )
423 .await?;
424
425 let loaded = store.load_config().await?;
426
427 assert_eq!(loaded.user_name, "User");
428 assert_eq!(loaded.theme, "nord");
429 assert_eq!(loaded.diff_view, DiffViewMode::SideBySide);
430 assert!(loaded.ignore_parley_dir);
431 assert_eq!(loaded.log_level, "info");
432 Ok(())
433 }
434}