Skip to main content

kardo_core/workspace/
mod.rs

1// === RPIV: Workspace Module ===
2// Purpose: Multi-project workspace management backed by AppDb
3// Related: db/app_db.rs, db/migrations/004_add_workspaces.sql, db/migrations/005_add_workspace_scores_and_standards.sql
4// === END RPIV ===
5
6//! Multi-project workspace management.
7//!
8//! Workspaces group multiple Git projects together, enabling cross-project
9//! comparison, shared standards, and team dashboards.
10
11pub 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/// A workspace grouping multiple projects.
21#[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/// A project within a workspace, with cached score data.
31#[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
40/// Manages workspace CRUD operations backed by AppDb.
41pub struct WorkspaceManager<'a> {
42    db: &'a AppDb,
43}
44
45/// Generate a workspace ID from name + current timestamp via SHA-256.
46fn 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    /// Create a new `WorkspaceManager` from an `AppDb` reference.
57    pub fn new(db: &'a AppDb) -> Self {
58        Self { db }
59    }
60
61    // ── Workspace CRUD ──
62
63    /// Create a new workspace with the given name.
64    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    /// Get a workspace by ID, including its projects.
74    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    /// List all workspaces with their projects.
98    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    /// Delete a workspace by ID. Cascades to `workspace_projects`.
124    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    // ── Workspace Projects ──
133
134    /// Add a project to a workspace. Derives the name from the path.
135    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    /// Remove a project from a workspace.
154    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    /// Update a project's cached score and traffic light within a workspace.
165    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    // ── Private helpers ──
184
185    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        // Verify project names derived from path
293        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        // Remove one
298        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(); // duplicate
312
313        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        // Verify projects were cascaded
347        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}