Skip to main content

parley/persistence/
store.rs

1use std::path::{Path, PathBuf};
2
3use crate::domain::{config::AppConfig, review::ReviewSession};
4use tokio::fs;
5
6#[derive(Debug, thiserror::Error)]
7pub enum StoreError {
8    #[error("invalid review name: {0}")]
9    InvalidReviewName(String),
10    #[error("review not found: {0}")]
11    ReviewNotFound(String),
12    #[error("io error: {0}")]
13    Io(#[from] std::io::Error),
14    #[error("json error: {0}")]
15    Json(#[from] serde_json::Error),
16    #[error("toml deserialize error: {0}")]
17    TomlDeserialize(#[from] toml::de::Error),
18    #[error("toml serialize error: {0}")]
19    TomlSerialize(#[from] toml::ser::Error),
20}
21
22pub type StoreResult<T> = Result<T, StoreError>;
23
24#[derive(Debug, Clone)]
25pub struct Store {
26    root: PathBuf,
27}
28
29impl Store {
30    pub fn from_project_root(project_root: impl AsRef<Path>) -> Self {
31        Self {
32            root: project_root.as_ref().join(".parley"),
33        }
34    }
35
36    pub async fn ensure_dirs(&self) -> StoreResult<()> {
37        fs::create_dir_all(self.reviews_dir()).await?;
38        Ok(())
39    }
40
41    pub async fn create_review(&self, session: &ReviewSession) -> StoreResult<()> {
42        validate_review_name(&session.name)?;
43        self.save_review(session).await
44    }
45
46    pub async fn save_review(&self, session: &ReviewSession) -> StoreResult<()> {
47        validate_review_name(&session.name)?;
48        self.ensure_dirs().await?;
49
50        let path = self.review_path(&session.name)?;
51        if let Some(parent) = path.parent() {
52            fs::create_dir_all(parent).await?;
53        }
54        let data = serde_json::to_vec_pretty(session)?;
55        fs::write(path, data).await?;
56        Ok(())
57    }
58
59    pub async fn load_review(&self, name: &str) -> StoreResult<ReviewSession> {
60        validate_review_name(name)?;
61        match fs::read(self.review_path(name)?).await {
62            Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
63            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
64                match fs::read(self.legacy_review_path(name)?).await {
65                    Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
66                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
67                        Err(StoreError::ReviewNotFound(name.to_string()))
68                    }
69                    Err(error) => Err(StoreError::Io(error)),
70                }
71            }
72            Err(error) => Err(StoreError::Io(error)),
73        }
74    }
75
76    pub async fn list_reviews(&self) -> StoreResult<Vec<String>> {
77        self.ensure_dirs().await?;
78        let mut dir = fs::read_dir(self.reviews_dir()).await?;
79        let mut result = Vec::new();
80
81        while let Some(entry) = dir.next_entry().await? {
82            let path = entry.path();
83            let file_type = entry.file_type().await?;
84            if file_type.is_dir() {
85                let review_path = path.join("review.json");
86                match fs::read(review_path).await {
87                    Ok(bytes) => {
88                        let review: ReviewSession = serde_json::from_slice(&bytes)?;
89                        result.push(review.name);
90                    }
91                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
92                    Err(error) => return Err(StoreError::Io(error)),
93                }
94            } else if path.extension().and_then(|value| value.to_str()) == Some("json")
95                && let Some(stem) = path.file_stem().and_then(|value| value.to_str())
96            {
97                let name = stem.to_string();
98                let normalized_path = self.review_path(&name)?;
99                if fs::try_exists(normalized_path).await? {
100                    continue;
101                }
102                result.push(name);
103            }
104        }
105
106        result.sort_unstable();
107        result.dedup();
108        Ok(result)
109    }
110
111    pub fn review_log_path(&self, review_name: &str) -> StoreResult<PathBuf> {
112        validate_review_name(review_name)?;
113        Ok(self.review_dir(review_name)?.join("logs").join("tui.log"))
114    }
115
116    fn review_path(&self, name: &str) -> StoreResult<PathBuf> {
117        Ok(self.review_dir(name)?.join("review.json"))
118    }
119
120    fn legacy_review_path(&self, name: &str) -> StoreResult<PathBuf> {
121        validate_review_name(name)?;
122        Ok(self.reviews_dir().join(format!("{name}.json")))
123    }
124
125    fn review_dir(&self, name: &str) -> StoreResult<PathBuf> {
126        validate_review_name(name)?;
127        Ok(self.reviews_dir().join(normalize_review_name(name)?))
128    }
129
130    fn reviews_dir(&self) -> PathBuf {
131        self.root.join("reviews")
132    }
133
134    pub async fn load_config(&self) -> StoreResult<AppConfig> {
135        self.ensure_dirs().await?;
136        let path = self.config_path();
137
138        match fs::read(&path).await {
139            Ok(bytes) => {
140                let text = String::from_utf8(bytes).map_err(|error| {
141                    StoreError::Io(std::io::Error::new(
142                        std::io::ErrorKind::InvalidData,
143                        format!("invalid utf-8 in config.toml: {error}"),
144                    ))
145                })?;
146                Ok(toml::from_str(&text)?)
147            }
148            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
149                self.load_legacy_json_config().await
150            }
151            Err(error) => Err(StoreError::Io(error)),
152        }
153    }
154
155    pub async fn save_config(&self, config: &AppConfig) -> StoreResult<()> {
156        self.ensure_dirs().await?;
157        let data = toml::to_string_pretty(config)?;
158        fs::write(self.config_path(), data).await?;
159        Ok(())
160    }
161
162    fn config_path(&self) -> PathBuf {
163        self.root.join("config.toml")
164    }
165
166    fn legacy_config_path(&self) -> PathBuf {
167        self.root.join("config.json")
168    }
169
170    async fn load_legacy_json_config(&self) -> StoreResult<AppConfig> {
171        match fs::read(self.legacy_config_path()).await {
172            Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
173            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AppConfig::default()),
174            Err(error) => Err(StoreError::Io(error)),
175        }
176    }
177}
178
179pub fn normalize_review_name(name: &str) -> StoreResult<String> {
180    validate_review_name(name)?;
181    let normalized = name.trim_matches(|ch| matches!(ch, '_' | '.')).to_string();
182    if normalized.is_empty() {
183        return Err(StoreError::InvalidReviewName(name.to_string()));
184    }
185    Ok(normalized)
186}
187
188pub fn validate_review_name(name: &str) -> StoreResult<()> {
189    if name.is_empty() {
190        return Err(StoreError::InvalidReviewName(name.to_string()));
191    }
192
193    if name
194        .chars()
195        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
196    {
197        Ok(())
198    } else {
199        Err(StoreError::InvalidReviewName(name.to_string()))
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use crate::domain::config::{AiConfig, DiffViewMode};
206    use tempfile::tempdir;
207
208    #[tokio::test]
209    async fn save_and_load_review_should_round_trip() {
210        let tmp = tempdir().expect("tempdir should exist");
211        let store = super::Store::from_project_root(tmp.path());
212        let review = super::ReviewSession::new("r1".into(), 1);
213
214        store
215            .save_review(&review)
216            .await
217            .expect("review should save successfully");
218        let loaded = store
219            .load_review("r1")
220            .await
221            .expect("review should load successfully");
222
223        assert_eq!(loaded.name, "r1");
224        assert_eq!(loaded.state, review.state);
225    }
226
227    #[tokio::test]
228    async fn save_review_should_use_normalized_review_directory() {
229        let tmp = tempdir().expect("tempdir should exist");
230        let store = super::Store::from_project_root(tmp.path());
231        let review = super::ReviewSession::new("__r1__".into(), 1);
232
233        store
234            .save_review(&review)
235            .await
236            .expect("review should save successfully");
237
238        let path = tmp.path().join(".parley/reviews/r1/review.json");
239        assert!(path.exists());
240    }
241
242    #[tokio::test]
243    async fn load_and_list_reviews_should_support_legacy_flat_files() {
244        let tmp = tempdir().expect("tempdir should exist");
245        let store = super::Store::from_project_root(tmp.path());
246        store
247            .ensure_dirs()
248            .await
249            .expect("store dirs should be created");
250        let review = super::ReviewSession::new("legacy".into(), 1);
251        let data = serde_json::to_vec_pretty(&review).expect("review should serialize");
252        tokio::fs::write(tmp.path().join(".parley/reviews/legacy.json"), data)
253            .await
254            .expect("legacy review should be written");
255
256        let loaded = store
257            .load_review("legacy")
258            .await
259            .expect("legacy review should load");
260        let reviews = store
261            .list_reviews()
262            .await
263            .expect("reviews should list successfully");
264
265        assert_eq!(loaded.name, "legacy");
266        assert_eq!(reviews, vec!["legacy"]);
267    }
268
269    #[test]
270    fn validate_review_name_should_reject_slash() {
271        let result = super::validate_review_name("bad/name");
272
273        assert!(result.is_err());
274    }
275
276    #[tokio::test]
277    async fn save_and_load_config_should_round_trip() {
278        let tmp = tempdir().expect("tempdir should exist");
279        let store = super::Store::from_project_root(tmp.path());
280        let config = super::AppConfig {
281            user_name: "User".to_string(),
282            theme: "nord".to_string(),
283            diff_view: DiffViewMode::Unified,
284            ignore_parley_dir: true,
285            log_level: "debug".to_string(),
286            ai: AiConfig::default(),
287        };
288
289        store
290            .save_config(&config)
291            .await
292            .expect("config should save successfully");
293        let loaded = store
294            .load_config()
295            .await
296            .expect("config should load successfully");
297
298        assert_eq!(loaded, config);
299    }
300
301    #[tokio::test]
302    async fn load_config_should_support_legacy_name_field() {
303        let tmp = tempdir().expect("tempdir should exist");
304        let store = super::Store::from_project_root(tmp.path());
305        store
306            .ensure_dirs()
307            .await
308            .expect("store dirs should be created");
309
310        super::fs::write(
311            tmp.path().join(".parley").join("config.toml"),
312            "name = \"User\"\ntheme = \"nord\"\n",
313        )
314        .await
315        .expect("legacy config should be written");
316
317        let loaded = store
318            .load_config()
319            .await
320            .expect("legacy config should load successfully");
321
322        assert_eq!(loaded.user_name, "User");
323        assert_eq!(loaded.theme, "nord");
324        assert_eq!(loaded.diff_view, DiffViewMode::SideBySide);
325        assert!(loaded.ignore_parley_dir);
326        assert_eq!(loaded.log_level, "info");
327    }
328}