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