Skip to main content

parley/persistence/
store.rs

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    /// # Errors
40    ///
41    /// Returns an error when the `.parley` review directories cannot be created.
42    pub async fn ensure_dirs(&self) -> StoreResult<()> {
43        fs::create_dir_all(self.reviews_dir()).await?;
44        Ok(())
45    }
46
47    /// # Errors
48    ///
49    /// Returns an error when the review name is invalid or the review cannot be written.
50    pub async fn create_review(&self, session: &ReviewSession) -> StoreResult<()> {
51        self.save_review(session).await
52    }
53
54    /// # Errors
55    ///
56    /// Returns an error when the review name is invalid, directories cannot be created, the session
57    /// cannot be serialized, or the review file cannot be written.
58    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    /// # Errors
71    ///
72    /// Returns an error when the review name is invalid, the review is missing, or review data
73    /// cannot be read or deserialized.
74    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    /// # Errors
88    ///
89    /// Returns an error when review directories cannot be read or persisted review files cannot be
90    /// deserialized.
91    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    /// # Errors
115    ///
116    /// Returns an error when `review_name` is invalid.
117    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    /// # Errors
134    ///
135    /// Returns an error when config directories cannot be created or config data cannot be read or
136    /// deserialized.
137    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    /// # Errors
154    ///
155    /// Returns an error when config directories cannot be created, config data cannot be serialized,
156    /// or the config file cannot be written.
157    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    // Legacy compatibility for flat review files that predate per-review directories.
169    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
212/// # Errors
213///
214/// Returns an error when the review name is empty after trimming or contains unsupported
215/// characters.
216pub 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
225/// # Errors
226///
227/// Returns an error when the review name is empty or contains characters other than ASCII
228/// alphanumerics, `.`, `_`, or `-`.
229pub 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}