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 fs::create_dir_all(self.logs_dir()).await?;
39 Ok(())
40 }
41
42 pub async fn create_review(&self, session: &ReviewSession) -> StoreResult<()> {
43 validate_review_name(&session.name)?;
44 self.save_review(session).await
45 }
46
47 pub async fn save_review(&self, session: &ReviewSession) -> StoreResult<()> {
48 validate_review_name(&session.name)?;
49 self.ensure_dirs().await?;
50
51 let path = self.review_path(&session.name)?;
52 let data = serde_json::to_vec_pretty(session)?;
53 fs::write(path, data).await?;
54 Ok(())
55 }
56
57 pub async fn load_review(&self, name: &str) -> StoreResult<ReviewSession> {
58 validate_review_name(name)?;
59 let path = self.review_path(name)?;
60
61 match fs::read(&path).await {
62 Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
63 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
64 Err(StoreError::ReviewNotFound(name.to_string()))
65 }
66 Err(error) => Err(StoreError::Io(error)),
67 }
68 }
69
70 pub async fn list_reviews(&self) -> StoreResult<Vec<String>> {
71 self.ensure_dirs().await?;
72 let mut dir = fs::read_dir(self.reviews_dir()).await?;
73 let mut result = Vec::new();
74
75 while let Some(entry) = dir.next_entry().await? {
76 let path = entry.path();
77 if path.extension().and_then(|value| value.to_str()) == Some("json")
78 && let Some(stem) = path.file_stem().and_then(|value| value.to_str())
79 {
80 result.push(stem.to_string());
81 }
82 }
83
84 result.sort_unstable();
85 Ok(result)
86 }
87
88 pub async fn load_config(&self) -> StoreResult<AppConfig> {
89 self.ensure_dirs().await?;
90 let path = self.config_path();
91
92 match fs::read(&path).await {
93 Ok(bytes) => {
94 let text = String::from_utf8(bytes).map_err(|error| {
95 StoreError::Io(std::io::Error::new(
96 std::io::ErrorKind::InvalidData,
97 format!("invalid utf-8 in config.toml: {error}"),
98 ))
99 })?;
100 Ok(toml::from_str(&text)?)
101 }
102 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
103 self.load_legacy_json_config().await
104 }
105 Err(error) => Err(StoreError::Io(error)),
106 }
107 }
108
109 pub async fn save_config(&self, config: &AppConfig) -> StoreResult<()> {
110 self.ensure_dirs().await?;
111 let data = toml::to_string_pretty(config)?;
112 fs::write(self.config_path(), data).await?;
113 Ok(())
114 }
115
116 fn review_path(&self, name: &str) -> StoreResult<PathBuf> {
117 validate_review_name(name)?;
118 Ok(self.reviews_dir().join(format!("{name}.json")))
119 }
120
121 fn reviews_dir(&self) -> PathBuf {
122 self.root.join("reviews")
123 }
124
125 fn logs_dir(&self) -> PathBuf {
126 self.root.join("logs")
127 }
128
129 pub fn review_log_path(&self, review_name: &str) -> StoreResult<PathBuf> {
130 validate_review_name(review_name)?;
131 Ok(self.logs_dir().join(format!("{review_name}.log")))
132 }
133
134 fn config_path(&self) -> PathBuf {
135 self.root.join("config.toml")
136 }
137
138 fn legacy_config_path(&self) -> PathBuf {
139 self.root.join("config.json")
140 }
141
142 async fn load_legacy_json_config(&self) -> StoreResult<AppConfig> {
143 match fs::read(self.legacy_config_path()).await {
144 Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
145 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AppConfig::default()),
146 Err(error) => Err(StoreError::Io(error)),
147 }
148 }
149}
150
151pub fn validate_review_name(name: &str) -> StoreResult<()> {
152 if name.is_empty() {
153 return Err(StoreError::InvalidReviewName(name.to_string()));
154 }
155
156 if name
157 .chars()
158 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
159 {
160 Ok(())
161 } else {
162 Err(StoreError::InvalidReviewName(name.to_string()))
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use crate::domain::config::{AiConfig, DiffViewMode};
169 use tempfile::tempdir;
170
171 #[tokio::test]
172 async fn save_and_load_review_should_round_trip() {
173 let tmp = tempdir().expect("tempdir should exist");
174 let store = super::Store::from_project_root(tmp.path());
175 let review = super::ReviewSession::new("r1".into(), 1);
176
177 store
178 .save_review(&review)
179 .await
180 .expect("review should save successfully");
181 let loaded = store
182 .load_review("r1")
183 .await
184 .expect("review should load successfully");
185
186 assert_eq!(loaded.name, "r1");
187 assert_eq!(loaded.state, review.state);
188 }
189
190 #[test]
191 fn validate_review_name_should_reject_slash() {
192 let result = super::validate_review_name("bad/name");
193
194 assert!(result.is_err());
195 }
196
197 #[tokio::test]
198 async fn save_and_load_config_should_round_trip() {
199 let tmp = tempdir().expect("tempdir should exist");
200 let store = super::Store::from_project_root(tmp.path());
201 let config = super::AppConfig {
202 user_name: "User".to_string(),
203 theme: "nord".to_string(),
204 diff_view: DiffViewMode::Unified,
205 log_level: "debug".to_string(),
206 ai: AiConfig::default(),
207 };
208
209 store
210 .save_config(&config)
211 .await
212 .expect("config should save successfully");
213 let loaded = store
214 .load_config()
215 .await
216 .expect("config should load successfully");
217
218 assert_eq!(loaded, config);
219 }
220
221 #[tokio::test]
222 async fn load_config_should_support_legacy_name_field() {
223 let tmp = tempdir().expect("tempdir should exist");
224 let store = super::Store::from_project_root(tmp.path());
225 store
226 .ensure_dirs()
227 .await
228 .expect("store dirs should be created");
229
230 super::fs::write(
231 tmp.path().join(".parley").join("config.toml"),
232 "name = \"User\"\ntheme = \"nord\"\n",
233 )
234 .await
235 .expect("legacy config should be written");
236
237 let loaded = store
238 .load_config()
239 .await
240 .expect("legacy config should load successfully");
241
242 assert_eq!(loaded.user_name, "User");
243 assert_eq!(loaded.theme, "nord");
244 assert_eq!(loaded.diff_view, DiffViewMode::SideBySide);
245 assert_eq!(loaded.log_level, "info");
246 }
247}