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("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    /// # Errors
66    ///
67    /// Returns an error when global storage cannot be resolved or an existing local `.parley`
68    /// marker is not a directory.
69    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    /// # Errors
84    ///
85    /// Returns an error when global storage cannot be resolved or an existing local `.parley`
86    /// marker is not a directory.
87    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    /// # Errors
93    ///
94    /// Returns an error when an existing local `.parley` marker is not a directory.
95    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    /// # Errors
111    ///
112    /// Returns an error when an existing local `.parley` marker is not a directory.
113    /// Prefers local_root if it exists, falls back to storage_root, then to global storage.
114    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    /// # Errors
168    ///
169    /// Returns an error when the `.parley` review directories cannot be created.
170    pub async fn ensure_dirs(&self) -> StoreResult<()> {
171        fs::create_dir_all(self.reviews_dir()).await?;
172        Ok(())
173    }
174
175    /// # Errors
176    ///
177    /// Returns an error when the review name is invalid or the review cannot be written.
178    pub async fn create_review(&self, session: &ReviewSession) -> StoreResult<()> {
179        self.save_review(session).await
180    }
181
182    /// # Errors
183    ///
184    /// Returns an error when the review name is invalid, directories cannot be created, the session
185    /// cannot be serialized, or the review file cannot be written.
186    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    /// # Errors
199    ///
200    /// Returns an error when the review name is invalid, the review is missing, or review data
201    /// cannot be read or deserialized.
202    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    /// # Errors
216    ///
217    /// Returns an error when review directories cannot be read or persisted review files cannot be
218    /// deserialized.
219    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    /// # Errors
243    ///
244    /// Returns an error when `review_name` is invalid.
245    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    /// # Errors
262    ///
263    /// Returns an error when config directories cannot be created or config data cannot be read or
264    /// deserialized.
265    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    /// # Errors
275    ///
276    /// Returns an error when config directories cannot be created, config data cannot be serialized,
277    /// or the config file cannot be written.
278    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    // Legacy compatibility for flat review files that predate per-review directories.
296    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
411/// # Errors
412///
413/// Returns an error when the review name is empty after trimming or contains unsupported
414/// characters.
415pub 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
424/// # Errors
425///
426/// Returns an error when the review name is empty or contains characters other than ASCII
427/// alphanumerics, `.`, `_`, or `-`.
428pub 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        // default_user_name() uses $USER env var, so just check it's non-empty
669        assert!(!loaded.user_name.is_empty());
670        Ok(())
671    }
672}