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    /// Filesystem paths associated with this project.
65    /// Empty for non-code projects (e.g. "travel planning").
66    pub paths: Vec<PathBuf>,
67    /// Tags for keyword matching (detection layer 3).
68    #[serde(default)]
69    pub tags: Vec<String>,
70    /// Display emoji for UI.
71    #[serde(default = "default_emoji")]
72    pub emoji: String,
73    /// How this project was registered.
74    pub source: ProjectSource,
75    /// Whether this project allows cross-project memory access.
76    #[serde(default = "default_true")]
77    pub memory_visible: bool,
78    /// When this project was created.
79    pub created_at: DateTime<Utc>,
80    /// When this project was last modified.
81    pub updated_at: DateTime<Utc>,
82    /// When this project was last active (used in a session).
83    pub last_active_at: DateTime<Utc>,
84
85    // ── RFC-025: Project as instruction/memory bundle ──
86    /// Mounts this Project references. Empty for non-code Projects or
87    /// Projects created before RFC-025 migration.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub mount_ids: Vec<crate::mount::MountId>,
90    /// Custom system-prompt instructions. Injected into `## Workspace Context`
91    /// when this Project is active.
92    #[serde(default, skip_serializing_if = "String::is_empty")]
93    pub instructions: String,
94}
95
96fn default_emoji() -> String {
97    "📦".to_string()
98}
99
100fn default_true() -> bool {
101    true
102}
103
104impl Project {
105    /// Create a new Project with the given name.
106    pub fn new(name: impl Into<String>, source: ProjectSource) -> Self {
107        let now = Utc::now();
108        Self {
109            id: ProjectId::new_v4(),
110            name: name.into(),
111            description: String::new(),
112            paths: Vec::new(),
113            tags: Vec::new(),
114            emoji: default_emoji(),
115            source,
116            memory_visible: true,
117            created_at: now,
118            updated_at: now,
119            last_active_at: now,
120            mount_ids: Vec::new(),
121            instructions: String::new(),
122        }
123    }
124
125    /// Create a Project from a filesystem path.
126    ///
127    /// Derives the name from the directory name.
128    pub fn from_path(path: &Path) -> Self {
129        let name = path
130            .file_name()
131            .and_then(|n| n.to_str())
132            .unwrap_or("unknown")
133            .to_string();
134        let mut project = Self::new(&name, ProjectSource::AutoDetected);
135        project.paths.push(path.to_path_buf());
136        project
137    }
138
139    /// Record that this project was used in a session.
140    pub fn touch(&mut self) {
141        self.last_active_at = Utc::now();
142        self.updated_at = Utc::now();
143    }
144
145    /// Add a filesystem path.
146    pub fn add_path(&mut self, path: PathBuf) {
147        if !self.paths.contains(&path) {
148            self.paths.push(path.clone());
149            self.updated_at = Utc::now();
150        }
151    }
152
153    /// Remove a filesystem path.
154    pub fn remove_path(&mut self, path: &PathBuf) -> bool {
155        if let Some(pos) = self.paths.iter().position(|p| p == path) {
156            self.paths.remove(pos);
157            self.updated_at = Utc::now();
158            true
159        } else {
160            false
161        }
162    }
163
164    /// Add a tag for keyword matching.
165    pub fn add_tag(&mut self, tag: impl Into<String>) {
166        let tag = tag.into();
167        if !self.tags.contains(&tag) {
168            self.tags.push(tag);
169            self.updated_at = Utc::now();
170        }
171    }
172
173    /// Whether this project has any filesystem paths.
174    pub fn has_paths(&self) -> bool {
175        !self.paths.is_empty()
176    }
177
178    /// Get the primary path (CWD source).
179    pub fn primary_path(&self) -> Option<&PathBuf> {
180        self.paths.first()
181    }
182
183    /// Get the display tag (e.g. "[🔧 oxios]").
184    pub fn tag(&self) -> String {
185        format!("[{} {}]", self.emoji, self.name)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_project_new() {
195        let p = Project::new("oxios", ProjectSource::Manual);
196        assert_eq!(p.name, "oxios");
197        assert_eq!(p.source, ProjectSource::Manual);
198        assert!(p.paths.is_empty());
199        assert_eq!(p.emoji, "📦");
200    }
201
202    #[test]
203    fn test_project_from_path() {
204        let path = PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios");
205        let p = Project::from_path(&path);
206        assert_eq!(p.name, "oxios");
207        assert_eq!(p.source, ProjectSource::AutoDetected);
208        assert_eq!(p.paths, vec![path]);
209    }
210
211    #[test]
212    fn test_project_add_path() {
213        let mut p = Project::new("oxios", ProjectSource::Manual);
214        assert!(!p.has_paths());
215
216        p.add_path(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
217        assert!(p.has_paths());
218        assert_eq!(
219            p.primary_path(),
220            Some(&PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"))
221        );
222
223        // Duplicate path should not be added
224        p.add_path(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
225        assert_eq!(p.paths.len(), 1);
226    }
227
228    #[test]
229    fn test_project_tag() {
230        let mut p = Project::new("oxios", ProjectSource::Manual);
231        p.emoji = "🔧".to_string();
232        assert_eq!(p.tag(), "[🔧 oxios]");
233    }
234}