Skip to main content

oxios_kernel/project/
mod.rs

1//! Project module: work context management.
2//!
3//! Replaces the Space system with a project-centric model:
4//! - Projects are registered aliases for filesystem paths
5//! - Sessions reference projects (1 primary + N secondary)
6//! - Memories link to projects via a junction table (N:M)
7//!
8//! ## Structure
9//!
10//! - `mod.rs` — Project struct and ProjectSource enum (this file)
11//! - `manager.rs` — ProjectManager (CRUD, lookup, detection)
12//! - `detection.rs` — Detection logic (name/path/tag matching)
13
14pub mod conversation_buffer;
15pub mod detection;
16pub mod manager;
17pub mod project_db;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use std::path::{Path, PathBuf};
22use uuid::Uuid;
23
24// ── Re-exports ──────────────────────────────────────────────
25pub use conversation_buffer::{ConversationBuffer, ConversationTurn};
26pub use detection::{DetectionResult, detect_project, extract_path, find_by_id, find_by_name};
27
28pub use manager::{ProjectManager, ProjectManagerError};
29
30/// Unique identifier for a Project.
31pub type ProjectId = Uuid;
32
33/// How a Project was registered.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ProjectSource {
37    /// User explicitly created via UI/CLI.
38    Manual,
39    /// OS auto-detected from a path in the conversation.
40    AutoDetected,
41}
42
43impl std::fmt::Display for ProjectSource {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            ProjectSource::Manual => write!(f, "manual"),
47            ProjectSource::AutoDetected => write!(f, "auto_detected"),
48        }
49    }
50}
51
52/// A registered work context (code project, writing project, etc).
53///
54/// Projects are the primary unit of workspace context in Oxios.
55/// Sessions reference a primary project (for CWD) and optional secondary projects.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct Project {
58    /// Unique identifier.
59    pub id: ProjectId,
60    /// Human-readable name (unique, e.g. "oxios", "pi", "my-blog").
61    pub name: String,
62    /// Optional description for UI display.
63    pub description: String,
64    /// LEGACY RFC-025 migration read-source.
65    ///
66    /// Paths now live on Mounts (`mount_ids`); this field is retained solely
67    /// so the one-time `migrate_projects_to_mounts` boot step can read
68    /// pre-RFC-025 data. New code MUST NOT read it — resolve paths via
69    /// `mount_ids`. Removed from the struct + DB in a follow-up release.
70    pub paths: Vec<PathBuf>,
71    /// Tags for keyword matching (detection layer 3).
72    #[serde(default)]
73    pub tags: Vec<String>,
74    /// Display emoji for UI.
75    #[serde(default = "default_emoji")]
76    pub emoji: String,
77    /// How this project was registered.
78    pub source: ProjectSource,
79    /// Whether this project allows cross-project memory access.
80    #[serde(default = "default_true")]
81    pub memory_visible: bool,
82    /// When this project was created.
83    pub created_at: DateTime<Utc>,
84    /// When this project was last modified.
85    pub updated_at: DateTime<Utc>,
86    /// When this project was last active (used in a session).
87    pub last_active_at: DateTime<Utc>,
88
89    // ── RFC-025: Project as instruction/memory bundle ──
90    /// Mounts this Project references. Empty for non-code Projects or
91    /// Projects created before RFC-025 migration.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub mount_ids: Vec<crate::mount::MountId>,
94    /// Custom system-prompt instructions. Injected into `## Workspace Context`
95    /// when this Project is active.
96    #[serde(default, skip_serializing_if = "String::is_empty")]
97    pub instructions: String,
98}
99
100fn default_emoji() -> String {
101    "📦".to_string()
102}
103
104fn default_true() -> bool {
105    true
106}
107
108impl Project {
109    /// Create a new Project with the given name.
110    pub fn new(name: impl Into<String>, source: ProjectSource) -> Self {
111        let now = Utc::now();
112        Self {
113            id: ProjectId::new_v4(),
114            name: name.into(),
115            description: String::new(),
116            paths: Vec::new(),
117            tags: Vec::new(),
118            emoji: default_emoji(),
119            source,
120            memory_visible: true,
121            created_at: now,
122            updated_at: now,
123            last_active_at: now,
124            mount_ids: Vec::new(),
125            instructions: String::new(),
126        }
127    }
128
129    /// Create a Project from a filesystem path.
130    ///
131    /// Derives the name from the directory name.
132    pub fn from_path(path: &Path) -> Self {
133        let name = path
134            .file_name()
135            .and_then(|n| n.to_str())
136            .unwrap_or("unknown")
137            .to_string();
138        let mut project = Self::new(&name, ProjectSource::AutoDetected);
139        project.paths.push(path.to_path_buf());
140        project
141    }
142
143    /// Record that this project was used in a session.
144    pub fn touch(&mut self) {
145        self.last_active_at = Utc::now();
146        self.updated_at = Utc::now();
147    }
148
149    /// Add a filesystem path.
150    pub fn add_path(&mut self, path: PathBuf) {
151        if !self.paths.contains(&path) {
152            self.paths.push(path.clone());
153            self.updated_at = Utc::now();
154        }
155    }
156
157    /// Remove a filesystem path.
158    pub fn remove_path(&mut self, path: &PathBuf) -> bool {
159        if let Some(pos) = self.paths.iter().position(|p| p == path) {
160            self.paths.remove(pos);
161            self.updated_at = Utc::now();
162            true
163        } else {
164            false
165        }
166    }
167
168    /// Add a tag for keyword matching.
169    pub fn add_tag(&mut self, tag: impl Into<String>) {
170        let tag = tag.into();
171        if !self.tags.contains(&tag) {
172            self.tags.push(tag);
173            self.updated_at = Utc::now();
174        }
175    }
176
177    /// Whether this project has any filesystem paths.
178    pub fn has_paths(&self) -> bool {
179        !self.paths.is_empty()
180    }
181
182    /// Get the primary path (CWD source).
183    pub fn primary_path(&self) -> Option<&PathBuf> {
184        self.paths.first()
185    }
186
187    /// Get the display tag (e.g. "[🔧 oxios]").
188    pub fn tag(&self) -> String {
189        format!("[{} {}]", self.emoji, self.name)
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_project_new() {
199        let p = Project::new("oxios", ProjectSource::Manual);
200        assert_eq!(p.name, "oxios");
201        assert_eq!(p.source, ProjectSource::Manual);
202        assert!(p.paths.is_empty());
203        assert_eq!(p.emoji, "📦");
204    }
205
206    #[test]
207    fn test_project_from_path() {
208        let path = PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios");
209        let p = Project::from_path(&path);
210        assert_eq!(p.name, "oxios");
211        assert_eq!(p.source, ProjectSource::AutoDetected);
212        assert_eq!(p.paths, vec![path]);
213    }
214
215    #[test]
216    fn test_project_add_path() {
217        let mut p = Project::new("oxios", ProjectSource::Manual);
218        assert!(!p.has_paths());
219
220        p.add_path(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
221        assert!(p.has_paths());
222        assert_eq!(
223            p.primary_path(),
224            Some(&PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"))
225        );
226
227        // Duplicate path should not be added
228        p.add_path(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
229        assert_eq!(p.paths.len(), 1);
230    }
231
232    #[test]
233    fn test_project_tag() {
234        let mut p = Project::new("oxios", ProjectSource::Manual);
235        p.emoji = "🔧".to_string();
236        assert_eq!(p.tag(), "[🔧 oxios]");
237    }
238}