Skip to main content

ta_changeset/
review_session_store.rs

1// review_session_store.rs — Persistent storage for ReviewSession instances.
2//
3// Stores review sessions as JSON files in ~/.ta/review_sessions/<session-id>.json
4// Enables multi-invocation review workflows where reviewers can pause and resume.
5
6use std::fs;
7use std::path::PathBuf;
8
9use uuid::Uuid;
10
11use crate::review_session::ReviewSession;
12use crate::ChangeSetError;
13
14/// Storage backend for ReviewSession instances.
15pub struct ReviewSessionStore {
16    sessions_dir: PathBuf,
17}
18
19impl ReviewSessionStore {
20    /// Create a new store with the given sessions directory.
21    pub fn new(sessions_dir: PathBuf) -> Result<Self, ChangeSetError> {
22        fs::create_dir_all(&sessions_dir)?;
23        Ok(Self { sessions_dir })
24    }
25
26    /// Save a review session to disk.
27    pub fn save(&self, session: &ReviewSession) -> Result<(), ChangeSetError> {
28        let path = self.session_path(session.session_id);
29        let json = serde_json::to_string_pretty(session)?;
30        fs::write(&path, json)?;
31        Ok(())
32    }
33
34    /// Load a review session from disk by ID.
35    pub fn load(&self, session_id: Uuid) -> Result<ReviewSession, ChangeSetError> {
36        let path = self.session_path(session_id);
37        if !path.exists() {
38            return Err(ChangeSetError::InvalidData(format!(
39                "Review session not found: {}",
40                session_id
41            )));
42        }
43        let json = fs::read_to_string(&path)?;
44        let session = serde_json::from_str(&json)?;
45        Ok(session)
46    }
47
48    /// List all review sessions.
49    pub fn list(&self) -> Result<Vec<ReviewSession>, ChangeSetError> {
50        let mut sessions = Vec::new();
51        if !self.sessions_dir.exists() {
52            return Ok(sessions);
53        }
54
55        for entry in fs::read_dir(&self.sessions_dir)? {
56            let entry = entry?;
57            let path = entry.path();
58            if path.extension().is_some_and(|ext| ext == "json") {
59                if let Ok(json) = fs::read_to_string(&path) {
60                    if let Ok(session) = serde_json::from_str::<ReviewSession>(&json) {
61                        sessions.push(session);
62                    }
63                }
64            }
65        }
66
67        // Sort by updated_at descending (most recent first).
68        sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
69        Ok(sessions)
70    }
71
72    /// Find the active review session for a given draft package (if any).
73    pub fn find_active_for_draft(
74        &self,
75        draft_package_id: Uuid,
76    ) -> Result<Option<ReviewSession>, ChangeSetError> {
77        let sessions = self.list()?;
78        Ok(sessions.into_iter().find(|s| {
79            s.draft_package_id == draft_package_id
80                && s.state == crate::review_session::ReviewState::Active
81        }))
82    }
83
84    /// Delete a review session from disk.
85    pub fn delete(&self, session_id: Uuid) -> Result<(), ChangeSetError> {
86        let path = self.session_path(session_id);
87        if path.exists() {
88            fs::remove_file(&path)?;
89        }
90        Ok(())
91    }
92
93    /// Get the file path for a session ID.
94    fn session_path(&self, session_id: Uuid) -> PathBuf {
95        self.sessions_dir.join(format!("{}.json", session_id))
96    }
97
98    /// Check if a session exists.
99    pub fn exists(&self, session_id: Uuid) -> bool {
100        self.session_path(session_id).exists()
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use tempfile::TempDir;
108
109    #[test]
110    fn save_and_load_session() {
111        let temp = TempDir::new().unwrap();
112        let store = ReviewSessionStore::new(temp.path().to_path_buf()).unwrap();
113
114        let mut session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
115        session.add_comment("fs://workspace/main.rs", "reviewer-1", "Looks good");
116        session.set_disposition(
117            "fs://workspace/main.rs",
118            crate::draft_package::ArtifactDisposition::Approved,
119        );
120
121        store.save(&session).unwrap();
122
123        let loaded = store.load(session.session_id).unwrap();
124        assert_eq!(loaded.session_id, session.session_id);
125        assert_eq!(loaded.reviewer, session.reviewer);
126        assert_eq!(loaded.artifact_reviews.len(), 1);
127    }
128
129    #[test]
130    fn list_sessions_returns_all() {
131        let temp = TempDir::new().unwrap();
132        let store = ReviewSessionStore::new(temp.path().to_path_buf()).unwrap();
133
134        let session1 = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
135        let session2 = ReviewSession::new(Uuid::new_v4(), "reviewer-2".to_string());
136
137        store.save(&session1).unwrap();
138        store.save(&session2).unwrap();
139
140        let sessions = store.list().unwrap();
141        assert_eq!(sessions.len(), 2);
142    }
143
144    #[test]
145    fn find_active_for_draft_returns_active_session() {
146        let temp = TempDir::new().unwrap();
147        let store = ReviewSessionStore::new(temp.path().to_path_buf()).unwrap();
148
149        let draft_id = Uuid::new_v4();
150        let session = ReviewSession::new(draft_id, "reviewer-1".to_string());
151        store.save(&session).unwrap();
152
153        let found = store.find_active_for_draft(draft_id).unwrap();
154        assert!(found.is_some());
155        assert_eq!(found.unwrap().draft_package_id, draft_id);
156    }
157
158    #[test]
159    fn find_active_for_draft_returns_none_when_no_active() {
160        let temp = TempDir::new().unwrap();
161        let store = ReviewSessionStore::new(temp.path().to_path_buf()).unwrap();
162
163        let draft_id = Uuid::new_v4();
164        let mut session = ReviewSession::new(draft_id, "reviewer-1".to_string());
165        session.state = crate::review_session::ReviewState::Completed;
166        store.save(&session).unwrap();
167
168        let found = store.find_active_for_draft(draft_id).unwrap();
169        assert!(found.is_none());
170    }
171
172    #[test]
173    fn delete_removes_session() {
174        let temp = TempDir::new().unwrap();
175        let store = ReviewSessionStore::new(temp.path().to_path_buf()).unwrap();
176
177        let session = ReviewSession::new(Uuid::new_v4(), "reviewer-1".to_string());
178        let session_id = session.session_id;
179
180        store.save(&session).unwrap();
181        assert!(store.exists(session_id));
182
183        store.delete(session_id).unwrap();
184        assert!(!store.exists(session_id));
185    }
186
187    #[test]
188    fn exists_returns_false_for_nonexistent_session() {
189        let temp = TempDir::new().unwrap();
190        let store = ReviewSessionStore::new(temp.path().to_path_buf()).unwrap();
191
192        assert!(!store.exists(Uuid::new_v4()));
193    }
194}