Skip to main content

oxios_kernel/kernel_handle/
project_api.rs

1//! Project API — Project management system calls (RFC-011).
2//!
3//! Provides API endpoints for:
4//! - Listing and querying Projects
5//! - CRUD operations on Projects
6//! - Project-Memory association (link/unlink)
7
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use anyhow::{Context, Result};
12use chrono::Utc;
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::project::{Project, ProjectManager, ProjectSource};
17
18/// Serialized Project info for API responses.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[allow(missing_docs)]
21pub struct ProjectInfo {
22    pub id: String,
23    pub name: String,
24    pub description: String,
25    pub source: String,
26    pub paths: Vec<String>,
27    pub tags: Vec<String>,
28    pub emoji: String,
29    pub memory_visible: bool,
30    /// RFC-025: Mounts this Project references.
31    pub mount_ids: Vec<String>,
32    /// RFC-025: Custom instructions injected into the system prompt.
33    pub instructions: String,
34    pub created_at: String,
35    pub updated_at: String,
36    pub last_active_at: String,
37}
38
39impl From<&Project> for ProjectInfo {
40    fn from(project: &Project) -> Self {
41        Self {
42            id: project.id.to_string(),
43            name: project.name.clone(),
44            description: project.description.clone(),
45            source: project.source.to_string(),
46            paths: project
47                .paths
48                .iter()
49                .map(|p| p.to_string_lossy().to_string())
50                .collect(),
51            tags: project.tags.clone(),
52            emoji: project.emoji.clone(),
53            memory_visible: project.memory_visible,
54            mount_ids: project.mount_ids.iter().map(|m| m.to_string()).collect(),
55            instructions: project.instructions.clone(),
56            created_at: project.created_at.to_rfc3339(),
57            updated_at: project.updated_at.to_rfc3339(),
58            last_active_at: project.last_active_at.to_rfc3339(),
59        }
60    }
61}
62
63/// Project system calls.
64///
65/// All methods return `Result` for operations that can fail,
66/// and `Option` for lookup operations.
67#[allow(dead_code)]
68pub struct ProjectApi {
69    /// Project manager for Project lifecycle.
70    pub(crate) project_manager: Arc<ProjectManager>,
71}
72
73impl ProjectApi {
74    /// Create a new ProjectApi.
75    pub fn new(project_manager: Arc<ProjectManager>) -> Self {
76        Self { project_manager }
77    }
78
79    /// List all Projects.
80    pub fn list_projects(&self) -> Vec<ProjectInfo> {
81        self.project_manager
82            .list_projects()
83            .iter()
84            .map(ProjectInfo::from)
85            .collect()
86    }
87
88    /// Get Project details by ID.
89    pub fn get_project(&self, id: &str) -> Option<ProjectInfo> {
90        let project_id = Uuid::parse_str(id).ok()?;
91        self.project_manager
92            .get_project(project_id)
93            .as_ref()
94            .map(ProjectInfo::from)
95    }
96
97    /// Create a new project.
98    pub fn create_project(
99        &self,
100        name: String,
101        paths: Vec<String>,
102        tags: Vec<String>,
103        emoji: Option<String>,
104        description: Option<String>,
105    ) -> Result<ProjectInfo> {
106        let paths: Vec<PathBuf> = paths.into_iter().map(PathBuf::from).collect();
107        let project = self.project_manager.create_project(
108            name,
109            paths,
110            tags,
111            emoji,
112            description,
113            ProjectSource::Manual,
114        )?;
115        Ok(ProjectInfo::from(&project))
116    }
117
118    /// Update a project. Only non-None fields are changed.
119    #[allow(clippy::too_many_arguments)]
120    pub fn update_project(
121        &self,
122        id: &str,
123        name: Option<String>,
124        paths: Option<Vec<String>>,
125        tags: Option<Vec<String>>,
126        emoji: Option<String>,
127        description: Option<String>,
128        memory_visible: Option<bool>,
129    ) -> Result<ProjectInfo> {
130        let project_id = Uuid::parse_str(id).context("Invalid project ID")?;
131        let paths = paths.map(|v| v.into_iter().map(PathBuf::from).collect());
132
133        let mut project = self.project_manager.update_project(
134            project_id,
135            name,
136            paths,
137            tags,
138            emoji,
139            description,
140        )?;
141
142        // memory_visible requires separate save (not part of update_project signature)
143        if let Some(visible) = memory_visible {
144            project.memory_visible = visible;
145            project.updated_at = Utc::now();
146            self.project_manager.save_project(&project)?;
147        }
148
149        Ok(ProjectInfo::from(&project))
150    }
151
152    /// Remove a project.
153    pub fn remove_project(&self, id: &str) -> Result<()> {
154        let project_id = Uuid::parse_str(id).context("Invalid project ID")?;
155        self.project_manager.remove_project(project_id)
156    }
157
158    /// Link a memory to a project.
159    pub fn link_memory(&self, project_id: &str, memory_id: &str) -> Result<()> {
160        let pid = Uuid::parse_str(project_id).context("Invalid project ID")?;
161        self.project_manager.link_memory(pid, memory_id)
162    }
163
164    /// Unlink a memory from a project.
165    pub fn unlink_memory(&self, project_id: &str, memory_id: &str) -> Result<()> {
166        let pid = Uuid::parse_str(project_id).context("Invalid project ID")?;
167        self.project_manager.unlink_memory(pid, memory_id)
168    }
169
170    /// Get all memory IDs linked to a project.
171    pub fn get_project_memory_ids(&self, project_id: &str) -> Result<Vec<String>> {
172        let pid = Uuid::parse_str(project_id).context("Invalid project ID")?;
173        self.project_manager.get_project_memory_ids(pid)
174    }
175
176    /// Update a Project's RFC-025 bundle fields (mount_ids, instructions).
177    pub fn update_project_bundle(
178        &self,
179        id: &str,
180        mount_ids: Option<Vec<String>>,
181        instructions: Option<String>,
182    ) -> Result<ProjectInfo> {
183        let pid = Uuid::parse_str(id).context("Invalid project ID")?;
184        // Reject invalid UUIDs rather than silently dropping them. Collecting
185        // all the bad IDs lets the caller see every offender in one error.
186        let mount_ids = match mount_ids {
187            Some(ids) => {
188                let mut parsed = Vec::with_capacity(ids.len());
189                let mut bad = Vec::new();
190                for s in ids {
191                    match uuid::Uuid::parse_str(&s) {
192                        Ok(u) => parsed.push(u),
193                        Err(_) => bad.push(s),
194                    }
195                }
196                if !bad.is_empty() {
197                    anyhow::bail!("Invalid mount ID(s): {}", bad.join(", "));
198                }
199                Some(parsed)
200            }
201            None => None,
202        };
203        let project = self
204            .project_manager
205            .update_project_bundle(pid, mount_ids, instructions)?;
206        Ok(ProjectInfo::from(&project))
207    }
208}