Skip to main content

parley/persistence/
store.rs

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    /// # Errors
50    ///
51    /// Returns an error when global storage cannot be resolved or an existing local `.parley`
52    /// marker is not a directory.
53    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    /// # Errors
68    ///
69    /// Returns an error when global storage cannot be resolved or an existing local `.parley`
70    /// marker is not a directory.
71    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    /// # Errors
77    ///
78    /// Returns an error when an existing local `.parley` marker is not a directory.
79    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    /// # Errors
95    ///
96    /// Returns an error when an existing local `.parley` marker is not a directory.
97    /// Prefers local_root if it exists, falls back to storage_root, then to global storage.
98    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    /// # Errors
149    ///
150    /// Returns an error when the `.parley` review directories cannot be created.
151    pub async fn ensure_dirs(&self) -> StoreResult<()> {
152        fs::create_dir_all(self.reviews_dir()).await?;
153        Ok(())
154    }
155
156    /// # Errors
157    ///
158    /// Returns an error when the review name is invalid or the review cannot be written.
159    pub async fn create_review(&self, session: &ReviewSession) -> StoreResult<()> {
160        self.save_review(session).await
161    }
162
163    /// # Errors
164    ///
165    /// Returns an error when the review name is invalid, directories cannot be created, the session
166    /// cannot be serialized, or the review file cannot be written.
167    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    /// # Errors
180    ///
181    /// Returns an error when the review name is invalid, the review is missing, or review data
182    /// cannot be read or deserialized.
183    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    /// # Errors
197    ///
198    /// Returns an error when review directories cannot be read or persisted review files cannot be
199    /// deserialized.
200    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    /// # Errors
224    ///
225    /// Returns an error when `review_name` is invalid.
226    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    /// # Errors
243    ///
244    /// Returns an error when config directories cannot be created or config data cannot be read or
245    /// deserialized.
246    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    /// # Errors
263    ///
264    /// Returns an error when config directories cannot be created, config data cannot be serialized,
265    /// or the config file cannot be written.
266    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    // Legacy compatibility for flat review files that predate per-review directories.
278    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
372/// # Errors
373///
374/// Returns an error when the review name is empty after trimming or contains unsupported
375/// characters.
376pub 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
385/// # Errors
386///
387/// Returns an error when the review name is empty or contains characters other than ASCII
388/// alphanumerics, `.`, `_`, or `-`.
389pub 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}