Skip to main content

oxios_kernel/project/
manager.rs

1//! ProjectManager: CRUD operations for Projects using SQLite.
2//!
3//! Replaces SpaceManager with a simpler, project-centric design:
4//! - No default project (project-less sessions are natural)
5//! - No active/inactive state (activity is per-session)
6//! - SQLite persistence alongside memories
7//! - Lookup by name, path, or tag
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use anyhow::Result;
14use chrono::Utc;
15use parking_lot::RwLock;
16
17use oxios_memory::memory::sqlite::MemoryDatabase;
18
19use super::project_db;
20use super::{DetectionResult, Project, ProjectId, ProjectSource, detect_project};
21use crate::event_bus::{EventBus, KernelEvent};
22
23/// Errors from ProjectManager operations.
24#[derive(thiserror::Error, Debug)]
25pub enum ProjectManagerError {
26    /// Project not found.
27    #[error("Project not found: {0}")]
28    NotFound(ProjectId),
29    /// Project name already taken.
30    #[error("Project name already exists: {0}")]
31    DuplicateName(String),
32    /// Invalid operation.
33    #[error("Invalid operation: {0}")]
34    Invalid(String),
35}
36
37/// Manages Projects: CRUD, lookup, and detection.
38///
39/// Projects are persisted in the `projects` SQLite table
40/// (same `memory.db` as memories).
41pub struct ProjectManager {
42    /// In-memory index of all Projects (loaded at startup).
43    projects: RwLock<HashMap<ProjectId, Project>>,
44    /// Name → ID index for fast name lookup.
45    name_index: RwLock<HashMap<String, ProjectId>>,
46    /// SQLite database for persistence.
47    db: Arc<MemoryDatabase>,
48    /// Event bus for publishing project events.
49    event_bus: Option<EventBus>,
50}
51
52impl ProjectManager {
53    /// Create a new ProjectManager, loading existing projects from SQLite.
54    pub fn new(db: Arc<MemoryDatabase>, event_bus: Option<EventBus>) -> Result<Self> {
55        // Ensure the schema exists (idempotent).
56        // Mirrors MountManager::new — MemoryDatabase only bootstraps
57        // memory tables, so project tables must be created here.
58        project_db::ensure_project_schema(&db.conn())?;
59
60        let mut projects = HashMap::new();
61        let mut name_index = HashMap::new();
62
63        // Load existing projects from SQLite
64        let rows = project_db::list_projects(&db.conn())?;
65        for project in rows {
66            name_index.insert(project.name.clone(), project.id);
67            projects.insert(project.id, project);
68        }
69
70        tracing::info!(count = projects.len(), "ProjectManager initialized");
71        Ok(Self {
72            projects: RwLock::new(projects),
73            name_index: RwLock::new(name_index),
74            db,
75            event_bus,
76        })
77    }
78
79    /// List all projects.
80    pub fn list_projects(&self) -> Vec<Project> {
81        self.projects.read().values().cloned().collect()
82    }
83
84    /// Get a project by ID.
85    pub fn get_project(&self, id: ProjectId) -> Option<Project> {
86        self.projects.read().get(&id).cloned()
87    }
88
89    /// Get a project by name.
90    pub fn get_project_by_name(&self, name: &str) -> Option<Project> {
91        let name_index = self.name_index.read();
92        let id = name_index.get(name)?;
93        self.projects.read().get(id).cloned()
94    }
95
96    /// Create a new project.
97    pub fn create_project(
98        &self,
99        name: String,
100        paths: Vec<PathBuf>,
101        tags: Vec<String>,
102        emoji: Option<String>,
103        description: Option<String>,
104        source: ProjectSource,
105    ) -> Result<Project> {
106        // Check for duplicate name
107        {
108            let name_index = self.name_index.read();
109            if name_index.contains_key(&name) {
110                return Err(ProjectManagerError::DuplicateName(name).into());
111            }
112        }
113
114        let mut project = Project::new(&name, source);
115        project.paths = paths;
116        project.tags = tags;
117        if let Some(emoji) = emoji {
118            project.emoji = emoji;
119        }
120        if let Some(description) = description {
121            project.description = description;
122        }
123
124        // Persist to SQLite
125        project_db::save_project(&self.db.conn(), &project)?;
126
127        // Update in-memory indices
128        {
129            let mut projects = self.projects.write();
130            let mut name_index = self.name_index.write();
131            name_index.insert(project.name.clone(), project.id);
132            projects.insert(project.id, project.clone());
133        }
134
135        // Publish event
136        if let Some(ref event_bus) = self.event_bus {
137            let _ = event_bus.publish(KernelEvent::ProjectCreated {
138                project_id: project.id,
139                name: project.name.clone(),
140                source: source.to_string(),
141            });
142        }
143
144        tracing::info!(name = %project.name, id = %project.id, "Project created");
145        Ok(project)
146    }
147
148    /// Update an existing project.
149    pub fn update_project(
150        &self,
151        id: ProjectId,
152        name: Option<String>,
153        paths: Option<Vec<PathBuf>>,
154        tags: Option<Vec<String>>,
155        emoji: Option<String>,
156        description: Option<String>,
157    ) -> Result<Project> {
158        let mut projects = self.projects.write();
159        let mut name_index = self.name_index.write();
160
161        let project = projects
162            .get_mut(&id)
163            .ok_or(ProjectManagerError::NotFound(id))?;
164
165        // If renaming, check for duplicate
166        if let Some(ref new_name) = name
167            && *new_name != project.name
168        {
169            if name_index.contains_key(new_name) {
170                return Err(ProjectManagerError::DuplicateName(new_name.clone()).into());
171            }
172            // Remove old name from index
173            name_index.remove(&project.name);
174            name_index.insert(new_name.clone(), id);
175            project.name = new_name.clone();
176        }
177
178        if let Some(paths) = paths {
179            project.paths = paths;
180        }
181        if let Some(tags) = tags {
182            project.tags = tags;
183        }
184        if let Some(emoji) = emoji {
185            project.emoji = emoji;
186        }
187        if let Some(description) = description {
188            project.description = description;
189        }
190
191        project.updated_at = Utc::now();
192
193        // Persist
194        let project_clone = project.clone();
195        drop(projects);
196        drop(name_index);
197        project_db::save_project(&self.db.conn(), &project_clone)?;
198
199        tracing::info!(name = %project_clone.name, id = %id, "Project updated");
200        Ok(project_clone)
201    }
202
203    /// Remove a project.
204    pub fn remove_project(&self, id: ProjectId) -> Result<()> {
205        {
206            let mut projects = self.projects.write();
207            let mut name_index = self.name_index.write();
208
209            let project = projects
210                .remove(&id)
211                .ok_or(ProjectManagerError::NotFound(id))?;
212            name_index.remove(&project.name);
213        }
214
215        // Remove from SQLite (cascades to project_memory via FK)
216        project_db::delete_project(&self.db.conn(), &id.to_string())?;
217
218        tracing::info!(id = %id, "Project removed");
219        Ok(())
220    }
221
222    /// Record that a project was used in a session.
223    pub fn touch(&self, id: ProjectId) {
224        if let Some(project) = self.projects.write().get_mut(&id) {
225            project.touch();
226            let project_clone = project.clone();
227            drop(self.projects.write());
228            let _ = project_db::save_project(&self.db.conn(), &project_clone);
229        }
230    }
231
232    /// Try to detect a project from a user message.
233    ///
234    /// Returns the matched ProjectId, or None.
235    pub fn detect(&self, message: &str) -> DetectionResult {
236        let projects = self.list_projects();
237        detect_project(message, &projects)
238    }
239
240    /// Link a memory to a project.
241    pub fn link_memory(&self, project_id: ProjectId, memory_id: &str) -> Result<()> {
242        {
243            let projects = self.projects.read();
244            if !projects.contains_key(&project_id) {
245                return Err(ProjectManagerError::NotFound(project_id).into());
246            }
247        }
248        project_db::link_project_memory(&self.db.conn(), &project_id.to_string(), memory_id)?;
249        Ok(())
250    }
251
252    /// Unlink a memory from a project.
253    pub fn unlink_memory(&self, project_id: ProjectId, memory_id: &str) -> Result<()> {
254        project_db::unlink_project_memory(&self.db.conn(), &project_id.to_string(), memory_id)?;
255        Ok(())
256    }
257
258    /// Get all memory IDs associated with a project.
259    pub fn get_project_memory_ids(&self, project_id: ProjectId) -> Result<Vec<String>> {
260        project_db::get_project_memory_ids(&self.db.conn(), &project_id.to_string())
261    }
262
263    /// Save (upsert) a project to SQLite directly.
264    ///
265    /// Used when fields like `memory_visible` need updating
266    /// outside the standard `update_project()` flow.
267    pub fn save_project(&self, project: &Project) -> Result<()> {
268        project_db::save_project(&self.db.conn(), project)?;
269
270        // Refresh in-memory indices
271        let mut projects = self.projects.write();
272        let mut name_index = self.name_index.write();
273        name_index.insert(project.name.clone(), project.id);
274        projects.insert(project.id, project.clone());
275
276        Ok(())
277    }
278
279    /// Update a Project's RFC-025 fields (mount_ids, instructions).
280    pub fn update_project_bundle(
281        &self,
282        id: ProjectId,
283        mount_ids: Option<Vec<crate::mount::MountId>>,
284        instructions: Option<String>,
285    ) -> Result<Project> {
286        let mut projects = self.projects.write();
287        let project = projects
288            .get_mut(&id)
289            .ok_or(ProjectManagerError::NotFound(id))?;
290
291        if let Some(mids) = mount_ids {
292            project.mount_ids = mids;
293        }
294        if let Some(instr) = instructions {
295            project.instructions = instr;
296        }
297        project.updated_at = Utc::now();
298
299        let project_clone = project.clone();
300        drop(projects);
301        project_db::save_project(&self.db.conn(), &project_clone)?;
302        Ok(project_clone)
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    // NOTE: Full integration tests require MemoryDatabase.
311    // These are unit tests for in-memory operations.
312
313    #[test]
314    fn test_project_manager_error_display() {
315        let id = ProjectId::new_v4();
316        let err = ProjectManagerError::NotFound(id);
317        assert!(err.to_string().contains("Project not found"));
318
319        let err = ProjectManagerError::DuplicateName("test".to_string());
320        assert!(err.to_string().contains("already exists"));
321    }
322}