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