1use crate::domain::config::AppConfig;
2use crate::domain::review::ReviewSession;
3use std::env;
4use std::io::{Error, ErrorKind};
5use std::path::{Path, PathBuf};
6use tokio::fs;
7
8#[derive(Debug, thiserror::Error)]
9pub enum StoreError {
10 #[error("invalid review name: {0}")]
11 InvalidReviewName(String),
12 #[error("review not found: {0}")]
13 ReviewNotFound(String),
14 #[error("io error: {0}")]
15 Io(#[from] Error),
16 #[error("json error: {0}")]
17 Json(#[from] serde_json::Error),
18 #[error("toml deserialize error: {0}")]
19 TomlDeserialize(#[from] toml::de::Error),
20 #[error("toml serialize error: {0}")]
21 TomlSerialize(#[from] toml::ser::Error),
22 #[error("could not resolve $HOME for global parley storage")]
23 HomeNotFound,
24 #[error("local .parley path exists but is not a directory: {0}")]
25 LocalStorePathNotDirectory(PathBuf),
26}
27
28pub type StoreResult<T> = Result<T, StoreError>;
29
30#[derive(Debug, Clone)]
31pub struct Store {
32 root: PathBuf,
33}
34
35impl Store {
36 pub fn from_project_root(project_root: impl AsRef<Path>) -> Self {
37 Self {
38 root: project_root.as_ref().join(".parley"),
39 }
40 }
41
42 #[must_use]
43 pub fn from_storage_root(storage_root: impl AsRef<Path>) -> Self {
44 Self {
45 root: storage_root.as_ref().to_path_buf(),
46 }
47 }
48
49 pub async fn resolve_from_context(
54 ctx: &crate::git::worktree::RepositoryContext,
55 ) -> StoreResult<Self> {
56 let global_root = default_global_root()?;
57 let local_root = ctx.selected_worktree.join(".parley");
58 Self::resolve_with_local_and_global_root(
59 &local_root,
60 &ctx.storage_root,
61 global_root,
62 &ctx.selected_worktree,
63 )
64 .await
65 }
66
67 pub async fn resolve(project_root: impl AsRef<Path>) -> StoreResult<Self> {
72 let global_root = default_global_root()?;
73 Self::resolve_with_global_root(project_root, global_root).await
74 }
75
76 pub async fn resolve_with_global_root(
80 project_root: impl AsRef<Path>,
81 global_root: impl AsRef<Path>,
82 ) -> StoreResult<Self> {
83 let project_root = project_root.as_ref();
84 let local_root = project_root.join(".parley");
85 Self::resolve_with_local_and_global_root(
86 &local_root,
87 &local_root,
88 global_root,
89 project_root,
90 )
91 .await
92 }
93
94 pub async fn resolve_with_local_and_global_root(
99 local_root: impl AsRef<Path>,
100 storage_root: impl AsRef<Path>,
101 global_root: impl AsRef<Path>,
102 project_root: &Path,
103 ) -> StoreResult<Self> {
104 let local_root = local_root.as_ref();
105 let storage_root = storage_root.as_ref();
106 match fs::metadata(local_root).await {
107 Ok(metadata) if metadata.is_dir() => {
108 return Ok(Self {
109 root: local_root.to_path_buf(),
110 });
111 }
112 Ok(_) => {
113 return Err(StoreError::LocalStorePathNotDirectory(
114 local_root.to_path_buf(),
115 ));
116 }
117 Err(error) if error.kind() == ErrorKind::NotFound => {}
118 Err(error) => return Err(StoreError::Io(error)),
119 }
120
121 match fs::metadata(storage_root).await {
122 Ok(metadata) if metadata.is_dir() => {
123 return Ok(Self {
124 root: storage_root.to_path_buf(),
125 });
126 }
127 Ok(_) => {
128 return Err(StoreError::LocalStorePathNotDirectory(
129 storage_root.to_path_buf(),
130 ));
131 }
132 Err(error) if error.kind() == ErrorKind::NotFound => {}
133 Err(error) => return Err(StoreError::Io(error)),
134 }
135
136 let global_repos = global_root.as_ref().join("repos");
137 fs::create_dir_all(&global_repos).await?;
138 Ok(Self {
139 root: global_repos.join(repo_storage_name(project_root).await?),
140 })
141 }
142
143 #[must_use]
144 pub fn root_path(&self) -> &Path {
145 &self.root
146 }
147
148 pub async fn ensure_dirs(&self) -> StoreResult<()> {
152 fs::create_dir_all(self.reviews_dir()).await?;
153 Ok(())
154 }
155
156 pub async fn create_review(&self, session: &ReviewSession) -> StoreResult<()> {
160 self.save_review(session).await
161 }
162
163 pub async fn save_review(&self, session: &ReviewSession) -> StoreResult<()> {
168 self.ensure_dirs().await?;
169
170 let path = self.review_path(&session.name)?;
171 if let Some(parent) = path.parent() {
172 fs::create_dir_all(parent).await?;
173 }
174 let data = serde_json::to_vec_pretty(session)?;
175 fs::write(path, data).await?;
176 Ok(())
177 }
178
179 pub async fn load_review(&self, name: &str) -> StoreResult<ReviewSession> {
184 let review_path = self.review_path(name)?;
185 if let Some(review) = read_review_file(&review_path).await? {
186 return Ok(review);
187 }
188
189 if let Some(review) = self.load_legacy_review(name).await? {
190 return Ok(review);
191 }
192
193 Err(StoreError::ReviewNotFound(name.to_string()))
194 }
195
196 pub async fn list_reviews(&self) -> StoreResult<Vec<String>> {
201 self.ensure_dirs().await?;
202 let mut dir = fs::read_dir(self.reviews_dir()).await?;
203 let mut result = Vec::new();
204
205 while let Some(entry) = dir.next_entry().await? {
206 let path = entry.path();
207 let file_type = entry.file_type().await?;
208 if file_type.is_dir() {
209 let review_path = path.join("review.json");
210 if let Some(review) = read_review_file(&review_path).await? {
211 result.push(review.name);
212 }
213 } else if let Some(name) = self.legacy_review_name(&path).await? {
214 result.push(name);
215 }
216 }
217
218 result.sort_unstable();
219 result.dedup();
220 Ok(result)
221 }
222
223 pub fn review_log_path(&self, review_name: &str) -> StoreResult<PathBuf> {
227 Ok(self.review_dir(review_name)?.join("logs").join("tui.log"))
228 }
229
230 fn review_path(&self, name: &str) -> StoreResult<PathBuf> {
231 Ok(self.review_dir(name)?.join("review.json"))
232 }
233
234 fn review_dir(&self, name: &str) -> StoreResult<PathBuf> {
235 Ok(self.reviews_dir().join(normalize_review_name(name)?))
236 }
237
238 fn reviews_dir(&self) -> PathBuf {
239 self.root.join("reviews")
240 }
241
242 pub async fn load_config(&self) -> StoreResult<AppConfig> {
247 self.ensure_dirs().await?;
248 let path = self.config_path();
249
250 let Some(bytes) = read_optional_file(&path).await? else {
251 return Ok(AppConfig::default());
252 };
253 let text = String::from_utf8(bytes).map_err(|error| {
254 StoreError::Io(Error::new(
255 ErrorKind::InvalidData,
256 format!("invalid utf-8 in config.toml: {error}"),
257 ))
258 })?;
259 Ok(toml::from_str(&text)?)
260 }
261
262 pub async fn save_config(&self, config: &AppConfig) -> StoreResult<()> {
267 self.ensure_dirs().await?;
268 let data = toml::to_string_pretty(config)?;
269 fs::write(self.config_path(), data).await?;
270 Ok(())
271 }
272
273 fn config_path(&self) -> PathBuf {
274 self.root.join("config.toml")
275 }
276
277 async fn load_legacy_review(&self, name: &str) -> StoreResult<Option<ReviewSession>> {
279 let legacy_path = self.legacy_review_path(name)?;
280 read_review_file(&legacy_path).await
281 }
282
283 async fn legacy_review_name(&self, path: &Path) -> StoreResult<Option<String>> {
284 if path.extension().and_then(|value| value.to_str()) != Some("json") {
285 return Ok(None);
286 }
287
288 let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else {
289 return Ok(None);
290 };
291
292 let normalized_path = self.review_path(stem)?;
293 if fs::try_exists(normalized_path).await? {
294 Ok(None)
295 } else {
296 Ok(Some(stem.to_string()))
297 }
298 }
299
300 fn legacy_review_path(&self, name: &str) -> StoreResult<PathBuf> {
301 validate_review_name(name)?;
302 Ok(self.reviews_dir().join(format!("{name}.json")))
303 }
304}
305
306async fn read_review_file(path: &Path) -> StoreResult<Option<ReviewSession>> {
307 let Some(bytes) = read_optional_file(path).await? else {
308 return Ok(None);
309 };
310 Ok(Some(serde_json::from_slice(&bytes)?))
311}
312
313async fn read_optional_file(path: &Path) -> StoreResult<Option<Vec<u8>>> {
314 match fs::read(path).await {
315 Ok(bytes) => Ok(Some(bytes)),
316 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
317 Err(error) => Err(StoreError::Io(error)),
318 }
319}
320
321fn default_global_root() -> StoreResult<PathBuf> {
322 let home = env::var_os("HOME").ok_or(StoreError::HomeNotFound)?;
323 Ok(PathBuf::from(home).join(".config").join("parley"))
324}
325
326async fn repo_storage_name(project_root: &Path) -> StoreResult<String> {
327 let canonical_root = fs::canonicalize(project_root).await?;
328 let repo_name = canonical_root
329 .file_name()
330 .and_then(|value| value.to_str())
331 .map(normalize_path_component)
332 .filter(|value| !value.is_empty())
333 .unwrap_or_else(|| "repository".to_string());
334
335 Ok(format!(
336 "{repo_name}-{:016x}",
337 stable_path_hash(&canonical_root)
338 ))
339}
340
341fn stable_path_hash(path: &Path) -> u64 {
342 let mut hash = 14_695_981_039_346_656_037_u64;
343 for byte in path.to_string_lossy().as_bytes() {
344 hash ^= u64::from(*byte);
345 hash = hash.wrapping_mul(1_099_511_628_211);
346 }
347 hash
348}
349
350fn normalize_path_component(input: &str) -> String {
351 let mut output = String::with_capacity(input.len());
352 let mut previous_was_separator = false;
353
354 for ch in input.chars() {
355 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
356 output.push(ch);
357 previous_was_separator = false;
358 continue;
359 }
360
361 if !previous_was_separator && !output.is_empty() {
362 output.push('_');
363 previous_was_separator = true;
364 }
365 }
366
367 output
368 .trim_matches(|ch| matches!(ch, '_' | '.'))
369 .to_string()
370}
371
372pub fn normalize_review_name(name: &str) -> StoreResult<String> {
377 validate_review_name(name)?;
378 let normalized = name.trim_matches(|ch| matches!(ch, '_' | '.')).to_string();
379 if normalized.is_empty() {
380 return Err(StoreError::InvalidReviewName(name.to_string()));
381 }
382 Ok(normalized)
383}
384
385pub fn validate_review_name(name: &str) -> StoreResult<()> {
390 if name.is_empty() {
391 return Err(StoreError::InvalidReviewName(name.to_string()));
392 }
393
394 if name
395 .chars()
396 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
397 {
398 Ok(())
399 } else {
400 Err(StoreError::InvalidReviewName(name.to_string()))
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use crate::domain::config::{AiConfig, DiffViewMode};
407 use crate::domain::review::{
408 Author, DiffSide, NewLineComment, SourceAnchorSnapshot, StoredAnchorSnapshot,
409 };
410 use anyhow::Result;
411 use tempfile::tempdir;
412 use tokio::fs as tokio_fs;
413
414 #[tokio::test]
415 async fn save_and_load_review_should_round_trip() -> Result<()> {
416 let tmp = tempdir()?;
417 let store = super::Store::from_project_root(tmp.path());
418 let review = super::ReviewSession::new("r1".into(), 1);
419
420 store.save_review(&review).await?;
421 let loaded = store.load_review("r1").await?;
422
423 assert_eq!(loaded.name, "r1");
424 assert_eq!(loaded.state, review.state);
425 Ok(())
426 }
427
428 #[tokio::test]
429 async fn resolve_should_prefer_existing_local_store() -> Result<()> {
430 let tmp = tempdir()?;
431 let global = tempdir()?;
432 tokio_fs::create_dir(tmp.path().join(".parley")).await?;
433
434 let store = super::Store::resolve_with_global_root(tmp.path(), global.path()).await?;
435
436 assert_eq!(store.root_path(), tmp.path().join(".parley"));
437 Ok(())
438 }
439
440 #[tokio::test]
441 async fn resolve_should_use_global_repo_named_store_without_local_marker() -> Result<()> {
442 let tmp = tempdir()?;
443 let global = tempdir()?;
444
445 let store = super::Store::resolve_with_global_root(tmp.path(), global.path()).await?;
446
447 let expected = global
448 .path()
449 .join("repos")
450 .join(super::repo_storage_name(tmp.path()).await?);
451 assert_eq!(store.root_path(), expected);
452 assert!(!tokio_fs::try_exists(tmp.path().join(".parley")).await?);
453 Ok(())
454 }
455
456 #[tokio::test]
457 async fn resolve_should_reject_local_store_file() -> Result<()> {
458 let tmp = tempdir()?;
459 let global = tempdir()?;
460 tokio_fs::write(tmp.path().join(".parley"), "").await?;
461
462 let result = super::Store::resolve_with_global_root(tmp.path(), global.path()).await;
463
464 assert!(matches!(
465 result,
466 Err(super::StoreError::LocalStorePathNotDirectory(_))
467 ));
468 Ok(())
469 }
470
471 #[tokio::test]
472 async fn save_review_should_use_normalized_review_directory() -> Result<()> {
473 let tmp = tempdir()?;
474 let store = super::Store::from_project_root(tmp.path());
475 let review = super::ReviewSession::new("__r1__".into(), 1);
476
477 store.save_review(&review).await?;
478
479 let path = tmp.path().join(".parley/reviews/r1/review.json");
480 assert!(tokio_fs::try_exists(path).await?);
481 Ok(())
482 }
483
484 #[tokio::test]
485 async fn load_and_list_reviews_should_support_legacy_flat_files() -> Result<()> {
486 let tmp = tempdir()?;
487 let store = super::Store::from_project_root(tmp.path());
488 store.ensure_dirs().await?;
489 let review = super::ReviewSession::new("legacy".into(), 1);
490 let data = serde_json::to_vec_pretty(&review)?;
491 tokio_fs::write(tmp.path().join(".parley/reviews/legacy.json"), data).await?;
492
493 let loaded = store.load_review("legacy").await?;
494 let reviews = store.list_reviews().await?;
495
496 assert_eq!(loaded.name, "legacy");
497 assert_eq!(reviews, vec!["legacy"]);
498 Ok(())
499 }
500
501 #[tokio::test]
502 async fn load_review_should_default_missing_original_anchor() -> Result<()> {
503 let tmp = tempdir()?;
504 let store = super::Store::from_project_root(tmp.path());
505 store.ensure_dirs().await?;
506 let review_dir = tmp.path().join(".parley/reviews/old");
507 tokio_fs::create_dir_all(&review_dir).await?;
508 tokio_fs::write(
509 review_dir.join("review.json"),
510 r#"{
511 "name": "old",
512 "state": "open",
513 "created_at_ms": 1,
514 "updated_at_ms": 1,
515 "comments": [
516 {
517 "id": 1,
518 "file_path": "src/lib.rs",
519 "old_line": null,
520 "new_line": 1,
521 "line_range": null,
522 "side": "right",
523 "line_anchor": null,
524 "detached": false,
525 "body": "old",
526 "author": "user",
527 "status": "open",
528 "replies": [],
529 "created_at_ms": 1,
530 "updated_at_ms": 1,
531 "addressed_at_ms": null
532 }
533 ],
534 "next_comment_id": 2,
535 "next_reply_id": 1
536}"#,
537 )
538 .await?;
539
540 let loaded = store.load_review("old").await?;
541
542 assert_eq!(loaded.comments[0].original_anchor, None);
543 Ok(())
544 }
545
546 #[tokio::test]
547 async fn save_and_load_review_should_round_trip_original_anchor() -> Result<()> {
548 let tmp = tempdir()?;
549 let store = super::Store::from_project_root(tmp.path());
550 let mut review = super::ReviewSession::new("anchored".into(), 1);
551 let original_anchor = StoredAnchorSnapshot {
552 file_path: "src/lib.rs".into(),
553 side: DiffSide::Right,
554 old_line: None,
555 new_line: Some(10),
556 line_range: None,
557 selected_text: "let value = 1;".into(),
558 before_context: vec!["fn main() {".into()],
559 after_context: vec!["}".into()],
560 diff: None,
561 source: Some(SourceAnchorSnapshot {
562 file_content_hash: Some("file-hash".into()),
563 selected_text_hash: Some("text-hash".into()),
564 }),
565 base_rev: Some("base".into()),
566 head_rev: Some("head".into()),
567 };
568 review.add_comment(
569 NewLineComment {
570 file_path: "src/lib.rs".into(),
571 old_line: None,
572 new_line: Some(10),
573 line_range: None,
574 side: DiffSide::Right,
575 line_anchor: None,
576 original_anchor: Some(original_anchor.clone()),
577 body: "anchor".into(),
578 author: Author::User,
579 },
580 2,
581 );
582
583 store.save_review(&review).await?;
584 let loaded = store.load_review("anchored").await?;
585
586 assert_eq!(loaded.comments[0].original_anchor, Some(original_anchor));
587 Ok(())
588 }
589
590 #[test]
591 fn validate_review_name_should_reject_slash() {
592 let result = super::validate_review_name("bad/name");
593
594 assert!(result.is_err());
595 }
596
597 #[tokio::test]
598 async fn save_and_load_config_should_round_trip() -> Result<()> {
599 let tmp = tempdir()?;
600 let store = super::Store::from_project_root(tmp.path());
601 let config = super::AppConfig {
602 user_name: "User".to_string(),
603 theme: "nord".to_string(),
604 diff_view: DiffViewMode::Unified,
605 ignore_parley_dir: true,
606 log_level: "debug".to_string(),
607 ai: AiConfig::default(),
608 last_worktree: None,
609 };
610
611 store.save_config(&config).await?;
612 let loaded = store.load_config().await?;
613
614 assert_eq!(loaded, config);
615 Ok(())
616 }
617
618 #[tokio::test]
619 async fn load_config_should_support_legacy_name_field() -> Result<()> {
620 let tmp = tempdir()?;
621 let store = super::Store::from_project_root(tmp.path());
622 store.ensure_dirs().await?;
623
624 tokio_fs::write(
625 tmp.path().join(".parley").join("config.toml"),
626 "name = \"User\"\ntheme = \"nord\"\n",
627 )
628 .await?;
629
630 let loaded = store.load_config().await?;
631
632 assert_eq!(loaded.user_name, "User");
633 assert_eq!(loaded.theme, "nord");
634 assert_eq!(loaded.diff_view, DiffViewMode::SideBySide);
635 assert!(loaded.ignore_parley_dir);
636 assert_eq!(loaded.log_level, "info");
637 Ok(())
638 }
639}