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::{detect_project, DetectionResult, Project, ProjectId, ProjectSource};
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        let mut projects = HashMap::new();
56        let mut name_index = HashMap::new();
57
58        // Load existing projects from SQLite
59        let rows = project_db::list_projects(&db.conn())?;
60        for project in rows {
61            name_index.insert(project.name.clone(), project.id);
62            projects.insert(project.id, project);
63        }
64
65        tracing::info!(count = projects.len(), "ProjectManager initialized");
66
67        Ok(Self {
68            projects: RwLock::new(projects),
69            name_index: RwLock::new(name_index),
70            db,
71            event_bus,
72        })
73    }
74
75    /// List all projects.
76    pub fn list_projects(&self) -> Vec<Project> {
77        self.projects.read().values().cloned().collect()
78    }
79
80    /// Get a project by ID.
81    pub fn get_project(&self, id: ProjectId) -> Option<Project> {
82        self.projects.read().get(&id).cloned()
83    }
84
85    /// Get a project by name.
86    pub fn get_project_by_name(&self, name: &str) -> Option<Project> {
87        let name_index = self.name_index.read();
88        let id = name_index.get(name)?;
89        self.projects.read().get(id).cloned()
90    }
91
92    /// Create a new project.
93    pub fn create_project(
94        &self,
95        name: String,
96        paths: Vec<PathBuf>,
97        tags: Vec<String>,
98        emoji: Option<String>,
99        description: Option<String>,
100        source: ProjectSource,
101    ) -> Result<Project> {
102        // Check for duplicate name
103        {
104            let name_index = self.name_index.read();
105            if name_index.contains_key(&name) {
106                return Err(ProjectManagerError::DuplicateName(name).into());
107            }
108        }
109
110        let mut project = Project::new(&name, source);
111        project.paths = paths;
112        project.tags = tags;
113        if let Some(emoji) = emoji {
114            project.emoji = emoji;
115        }
116        if let Some(description) = description {
117            project.description = description;
118        }
119
120        // Persist to SQLite
121        project_db::save_project(&self.db.conn(), &project)?;
122
123        // Update in-memory indices
124        {
125            let mut projects = self.projects.write();
126            let mut name_index = self.name_index.write();
127            name_index.insert(project.name.clone(), project.id);
128            projects.insert(project.id, project.clone());
129        }
130
131        // Publish event
132        if let Some(ref event_bus) = self.event_bus {
133            let _ = event_bus.publish(KernelEvent::ProjectCreated {
134                project_id: project.id,
135                name: project.name.clone(),
136                source: source.to_string(),
137            });
138        }
139
140        tracing::info!(name = %project.name, id = %project.id, "Project created");
141        Ok(project)
142    }
143
144    /// Update an existing project.
145    pub fn update_project(
146        &self,
147        id: ProjectId,
148        name: Option<String>,
149        paths: Option<Vec<PathBuf>>,
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            if *new_name != project.name {
164                if name_index.contains_key(new_name) {
165                    return Err(ProjectManagerError::DuplicateName(new_name.clone()).into());
166                }
167                // Remove old name from index
168                name_index.remove(&project.name);
169                name_index.insert(new_name.clone(), id);
170                project.name = new_name.clone();
171            }
172        }
173
174        if let Some(paths) = paths {
175            project.paths = paths;
176        }
177        if let Some(tags) = tags {
178            project.tags = tags;
179        }
180        if let Some(emoji) = emoji {
181            project.emoji = emoji;
182        }
183        if let Some(description) = description {
184            project.description = description;
185        }
186
187        project.updated_at = Utc::now();
188
189        // Persist
190        let project_clone = project.clone();
191        drop(projects);
192        drop(name_index);
193        project_db::save_project(&self.db.conn(), &project_clone)?;
194
195        tracing::info!(name = %project_clone.name, id = %id, "Project updated");
196        Ok(project_clone)
197    }
198
199    /// Remove a project.
200    pub fn remove_project(&self, id: ProjectId) -> Result<()> {
201        {
202            let mut projects = self.projects.write();
203            let mut name_index = self.name_index.write();
204
205            let project = projects
206                .remove(&id)
207                .ok_or(ProjectManagerError::NotFound(id))?;
208            name_index.remove(&project.name);
209        }
210
211        // Remove from SQLite (cascades to project_memory via FK)
212        project_db::delete_project(&self.db.conn(), &id.to_string())?;
213
214        tracing::info!(id = %id, "Project removed");
215        Ok(())
216    }
217
218    /// Record that a project was used in a session.
219    pub fn touch(&self, id: ProjectId) {
220        if let Some(project) = self.projects.write().get_mut(&id) {
221            project.touch();
222            let project_clone = project.clone();
223            drop(self.projects.write());
224            let _ = project_db::save_project(&self.db.conn(), &project_clone);
225        }
226    }
227
228    /// Try to detect a project from a user message.
229    ///
230    /// Returns the matched ProjectId, or None.
231    pub fn detect(&self, message: &str) -> DetectionResult {
232        let projects = self.list_projects();
233        detect_project(message, &projects)
234    }
235
236    /// Link a memory to a project.
237    pub fn link_memory(&self, project_id: ProjectId, memory_id: &str) -> Result<()> {
238        {
239            let projects = self.projects.read();
240            if !projects.contains_key(&project_id) {
241                return Err(ProjectManagerError::NotFound(project_id).into());
242            }
243        }
244        project_db::link_project_memory(&self.db.conn(), &project_id.to_string(), memory_id)?;
245        Ok(())
246    }
247
248    /// Unlink a memory from a project.
249    pub fn unlink_memory(&self, project_id: ProjectId, memory_id: &str) -> Result<()> {
250        project_db::unlink_project_memory(&self.db.conn(), &project_id.to_string(), memory_id)?;
251        Ok(())
252    }
253
254    /// Get all memory IDs associated with a project.
255    pub fn get_project_memory_ids(&self, project_id: ProjectId) -> Result<Vec<String>> {
256        project_db::get_project_memory_ids(&self.db.conn(), &project_id.to_string())
257    }
258
259    /// Save (upsert) a project to SQLite directly.
260    ///
261    /// Used when fields like `memory_visible` need updating
262    /// outside the standard `update_project()` flow.
263    pub fn save_project(&self, project: &Project) -> Result<()> {
264        project_db::save_project(&self.db.conn(), project)?;
265
266        // Refresh in-memory indices
267        let mut projects = self.projects.write();
268        let mut name_index = self.name_index.write();
269        name_index.insert(project.name.clone(), project.id);
270        projects.insert(project.id, project.clone());
271
272        Ok(())
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    // NOTE: Full integration tests require MemoryDatabase.
281    // These are unit tests for in-memory operations.
282
283    #[test]
284    fn test_project_manager_error_display() {
285        let id = ProjectId::new_v4();
286        let err = ProjectManagerError::NotFound(id);
287        assert!(err.to_string().contains("Project not found"));
288
289        let err = ProjectManagerError::DuplicateName("test".to_string());
290        assert!(err.to_string().contains("already exists"));
291    }
292}