parley/persistence/
store.rs1use 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}