1pub mod comparison;
12pub mod standards;
13
14use rusqlite::params;
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::db::{AppDb, DbResult};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Workspace {
23 pub id: String,
24 pub name: String,
25 pub projects: Vec<WorkspaceProject>,
26 pub created_at: String,
27 pub updated_at: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct WorkspaceProject {
33 pub path: String,
34 pub name: String,
35 pub last_score: Option<f64>,
36 pub last_traffic_light: Option<String>,
37 pub last_scan: Option<String>,
38}
39
40pub struct WorkspaceManager<'a> {
42 db: &'a AppDb,
43}
44
45fn generate_workspace_id(name: &str) -> String {
47 let timestamp = chrono::Utc::now().to_rfc3339();
48 let mut hasher = Sha256::new();
49 hasher.update(name.as_bytes());
50 hasher.update(timestamp.as_bytes());
51 let result = hasher.finalize();
52 format!("{:x}", result)[..32].to_string()
53}
54
55impl<'a> WorkspaceManager<'a> {
56 pub fn new(db: &'a AppDb) -> Self {
58 Self { db }
59 }
60
61 pub fn create_workspace(&self, name: &str) -> DbResult<Workspace> {
65 let id = generate_workspace_id(name);
66 self.db.conn.execute(
67 "INSERT INTO workspaces (id, name) VALUES (?1, ?2)",
68 params![id, name],
69 )?;
70 self.get_workspace(&id)
71 }
72
73 pub fn get_workspace(&self, id: &str) -> DbResult<Workspace> {
75 let workspace = self.db.conn.query_row(
76 "SELECT id, name, created_at, updated_at FROM workspaces WHERE id = ?1",
77 params![id],
78 |row| {
79 Ok(Workspace {
80 id: row.get(0)?,
81 name: row.get(1)?,
82 projects: vec![],
83 created_at: row.get(2)?,
84 updated_at: row.get(3)?,
85 })
86 },
87 )?;
88
89 let projects = self.list_workspace_projects(id)?;
90
91 Ok(Workspace {
92 projects,
93 ..workspace
94 })
95 }
96
97 pub fn list_workspaces(&self) -> DbResult<Vec<Workspace>> {
99 let mut stmt = self.db.conn.prepare(
100 "SELECT id, name, created_at, updated_at
101 FROM workspaces ORDER BY updated_at DESC",
102 )?;
103
104 let rows = stmt.query_map([], |row| {
105 Ok(Workspace {
106 id: row.get(0)?,
107 name: row.get(1)?,
108 projects: vec![],
109 created_at: row.get(2)?,
110 updated_at: row.get(3)?,
111 })
112 })?;
113
114 let mut workspaces = Vec::new();
115 for row in rows {
116 let mut ws = row?;
117 ws.projects = self.list_workspace_projects(&ws.id)?;
118 workspaces.push(ws);
119 }
120 Ok(workspaces)
121 }
122
123 pub fn delete_workspace(&self, id: &str) -> DbResult<()> {
125 self.db.conn.execute(
126 "DELETE FROM workspaces WHERE id = ?1",
127 params![id],
128 )?;
129 Ok(())
130 }
131
132 pub fn add_project(&self, workspace_id: &str, project_path: &str) -> DbResult<()> {
136 let name = std::path::Path::new(project_path)
137 .file_name()
138 .unwrap_or_default()
139 .to_string_lossy()
140 .to_string();
141
142 self.db.conn.execute(
143 "INSERT INTO workspace_projects (workspace_id, path, name)
144 VALUES (?1, ?2, ?3)
145 ON CONFLICT(workspace_id, path) DO UPDATE SET name = excluded.name",
146 params![workspace_id, project_path, name],
147 )?;
148
149 self.touch_workspace(workspace_id)?;
150 Ok(())
151 }
152
153 pub fn remove_project(&self, workspace_id: &str, project_path: &str) -> DbResult<()> {
155 self.db.conn.execute(
156 "DELETE FROM workspace_projects WHERE workspace_id = ?1 AND path = ?2",
157 params![workspace_id, project_path],
158 )?;
159
160 self.touch_workspace(workspace_id)?;
161 Ok(())
162 }
163
164 pub fn update_project_score(
166 &self,
167 workspace_id: &str,
168 project_path: &str,
169 score: f64,
170 traffic_light: &str,
171 ) -> DbResult<()> {
172 self.db.conn.execute(
173 "UPDATE workspace_projects
174 SET last_score = ?1, last_traffic_light = ?2, last_scan = datetime('now')
175 WHERE workspace_id = ?3 AND path = ?4",
176 params![score, traffic_light, workspace_id, project_path],
177 )?;
178
179 self.touch_workspace(workspace_id)?;
180 Ok(())
181 }
182
183 fn list_workspace_projects(&self, workspace_id: &str) -> DbResult<Vec<WorkspaceProject>> {
186 let mut stmt = self.db.conn.prepare(
187 "SELECT path, name, last_score, last_traffic_light, last_scan
188 FROM workspace_projects
189 WHERE workspace_id = ?1
190 ORDER BY name ASC",
191 )?;
192
193 let rows = stmt.query_map(params![workspace_id], |row| {
194 Ok(WorkspaceProject {
195 path: row.get(0)?,
196 name: row.get(1)?,
197 last_score: row.get(2)?,
198 last_traffic_light: row.get(3)?,
199 last_scan: row.get(4)?,
200 })
201 })?;
202
203 let mut projects = Vec::new();
204 for row in rows {
205 projects.push(row?);
206 }
207 Ok(projects)
208 }
209
210 fn touch_workspace(&self, workspace_id: &str) -> DbResult<()> {
211 self.db.conn.execute(
212 "UPDATE workspaces SET updated_at = datetime('now') WHERE id = ?1",
213 params![workspace_id],
214 )?;
215 Ok(())
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use tempfile::TempDir;
223
224 fn setup() -> (TempDir, AppDb) {
225 let tmp = TempDir::new().unwrap();
226 let db_path = tmp.path().join("app.db");
227 let db = AppDb::open(&db_path).unwrap();
228 (tmp, db)
229 }
230
231 #[test]
232 fn test_create_workspace() {
233 let (_tmp, db) = setup();
234 let mgr = WorkspaceManager::new(&db);
235
236 let ws = mgr.create_workspace("My Team").unwrap();
237 assert_eq!(ws.name, "My Team");
238 assert!(!ws.id.is_empty());
239 assert_eq!(ws.id.len(), 32);
240 assert!(ws.projects.is_empty());
241 }
242
243 #[test]
244 fn test_get_workspace() {
245 let (_tmp, db) = setup();
246 let mgr = WorkspaceManager::new(&db);
247
248 let created = mgr.create_workspace("Test WS").unwrap();
249 let fetched = mgr.get_workspace(&created.id).unwrap();
250
251 assert_eq!(fetched.id, created.id);
252 assert_eq!(fetched.name, "Test WS");
253 }
254
255 #[test]
256 fn test_list_workspaces() {
257 let (_tmp, db) = setup();
258 let mgr = WorkspaceManager::new(&db);
259
260 mgr.create_workspace("Alpha").unwrap();
261 mgr.create_workspace("Beta").unwrap();
262
263 let list = mgr.list_workspaces().unwrap();
264 assert_eq!(list.len(), 2);
265 }
266
267 #[test]
268 fn test_delete_workspace() {
269 let (_tmp, db) = setup();
270 let mgr = WorkspaceManager::new(&db);
271
272 let ws = mgr.create_workspace("To Delete").unwrap();
273 mgr.delete_workspace(&ws.id).unwrap();
274
275 let list = mgr.list_workspaces().unwrap();
276 assert!(list.is_empty());
277 }
278
279 #[test]
280 fn test_add_and_remove_project() {
281 let (_tmp, db) = setup();
282 let mgr = WorkspaceManager::new(&db);
283
284 let ws = mgr.create_workspace("Project Test").unwrap();
285
286 mgr.add_project(&ws.id, "/home/user/project-a").unwrap();
287 mgr.add_project(&ws.id, "/home/user/project-b").unwrap();
288
289 let ws = mgr.get_workspace(&ws.id).unwrap();
290 assert_eq!(ws.projects.len(), 2);
291
292 let names: Vec<&str> = ws.projects.iter().map(|p| p.name.as_str()).collect();
294 assert!(names.contains(&"project-a"));
295 assert!(names.contains(&"project-b"));
296
297 mgr.remove_project(&ws.id, "/home/user/project-a").unwrap();
299 let ws = mgr.get_workspace(&ws.id).unwrap();
300 assert_eq!(ws.projects.len(), 1);
301 assert_eq!(ws.projects[0].path, "/home/user/project-b");
302 }
303
304 #[test]
305 fn test_add_duplicate_project_ignored() {
306 let (_tmp, db) = setup();
307 let mgr = WorkspaceManager::new(&db);
308
309 let ws = mgr.create_workspace("Dup Test").unwrap();
310 mgr.add_project(&ws.id, "/home/user/proj").unwrap();
311 mgr.add_project(&ws.id, "/home/user/proj").unwrap(); let ws = mgr.get_workspace(&ws.id).unwrap();
314 assert_eq!(ws.projects.len(), 1);
315 }
316
317 #[test]
318 fn test_update_project_score() {
319 let (_tmp, db) = setup();
320 let mgr = WorkspaceManager::new(&db);
321
322 let ws = mgr.create_workspace("Score Test").unwrap();
323 mgr.add_project(&ws.id, "/home/user/proj").unwrap();
324
325 mgr.update_project_score(&ws.id, "/home/user/proj", 85.5, "green")
326 .unwrap();
327
328 let ws = mgr.get_workspace(&ws.id).unwrap();
329 assert_eq!(ws.projects.len(), 1);
330 assert!((ws.projects[0].last_score.unwrap() - 85.5).abs() < f64::EPSILON);
331 assert_eq!(ws.projects[0].last_traffic_light.as_deref(), Some("green"));
332 assert!(ws.projects[0].last_scan.is_some());
333 }
334
335 #[test]
336 fn test_delete_workspace_cascades_projects() {
337 let (_tmp, db) = setup();
338 let mgr = WorkspaceManager::new(&db);
339
340 let ws = mgr.create_workspace("Cascade Test").unwrap();
341 mgr.add_project(&ws.id, "/proj/a").unwrap();
342 mgr.add_project(&ws.id, "/proj/b").unwrap();
343
344 mgr.delete_workspace(&ws.id).unwrap();
345
346 let count: i64 = db
348 .conn
349 .query_row(
350 "SELECT COUNT(*) FROM workspace_projects WHERE workspace_id = ?1",
351 params![&ws.id],
352 |row| row.get(0),
353 )
354 .unwrap();
355 assert_eq!(count, 0);
356 }
357
358 #[test]
359 fn test_get_nonexistent_workspace() {
360 let (_tmp, db) = setup();
361 let mgr = WorkspaceManager::new(&db);
362
363 let result = mgr.get_workspace("nonexistent-id-12345");
364 assert!(result.is_err());
365 }
366
367 #[test]
368 fn test_workspace_isolation() {
369 let (_tmp, db) = setup();
370 let mgr = WorkspaceManager::new(&db);
371
372 let ws1 = mgr.create_workspace("WS 1").unwrap();
373 let ws2 = mgr.create_workspace("WS 2").unwrap();
374
375 mgr.add_project(&ws1.id, "/proj/a").unwrap();
376 mgr.add_project(&ws2.id, "/proj/b").unwrap();
377 mgr.add_project(&ws2.id, "/proj/c").unwrap();
378
379 let ws1_data = mgr.get_workspace(&ws1.id).unwrap();
380 let ws2_data = mgr.get_workspace(&ws2.id).unwrap();
381
382 assert_eq!(ws1_data.projects.len(), 1);
383 assert_eq!(ws2_data.projects.len(), 2);
384 assert_eq!(ws1_data.projects[0].path, "/proj/a");
385 }
386}