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        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}