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::{detect_project, extract_path, find_by_id, find_by_name, DetectionResult};
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
86fn default_emoji() -> String {
87    "📦".to_string()
88}
89
90fn default_true() -> bool {
91    true
92}
93
94impl Project {
95    /// Create a new Project with the given name.
96    pub fn new(name: impl Into<String>, source: ProjectSource) -> Self {
97        let now = Utc::now();
98        Self {
99            id: ProjectId::new_v4(),
100            name: name.into(),
101            description: String::new(),
102            paths: Vec::new(),
103            tags: Vec::new(),
104            emoji: default_emoji(),
105            source,
106            memory_visible: true,
107            created_at: now,
108            updated_at: now,
109            last_active_at: now,
110        }
111    }
112
113    /// Create a Project from a filesystem path.
114    ///
115    /// Derives the name from the directory name.
116    pub fn from_path(path: &Path) -> Self {
117        let name = path
118            .file_name()
119            .and_then(|n| n.to_str())
120            .unwrap_or("unknown")
121            .to_string();
122        let mut project = Self::new(&name, ProjectSource::AutoDetected);
123        project.paths.push(path.to_path_buf());
124        project
125    }
126
127    /// Record that this project was used in a session.
128    pub fn touch(&mut self) {
129        self.last_active_at = Utc::now();
130        self.updated_at = Utc::now();
131    }
132
133    /// Add a filesystem path.
134    pub fn add_path(&mut self, path: PathBuf) {
135        if !self.paths.contains(&path) {
136            self.paths.push(path.clone());
137            self.updated_at = Utc::now();
138        }
139    }
140
141    /// Remove a filesystem path.
142    pub fn remove_path(&mut self, path: &PathBuf) -> bool {
143        if let Some(pos) = self.paths.iter().position(|p| p == path) {
144            self.paths.remove(pos);
145            self.updated_at = Utc::now();
146            true
147        } else {
148            false
149        }
150    }
151
152    /// Add a tag for keyword matching.
153    pub fn add_tag(&mut self, tag: impl Into<String>) {
154        let tag = tag.into();
155        if !self.tags.contains(&tag) {
156            self.tags.push(tag);
157            self.updated_at = Utc::now();
158        }
159    }
160
161    /// Whether this project has any filesystem paths.
162    pub fn has_paths(&self) -> bool {
163        !self.paths.is_empty()
164    }
165
166    /// Get the primary path (CWD source).
167    pub fn primary_path(&self) -> Option<&PathBuf> {
168        self.paths.first()
169    }
170
171    /// Get the display tag (e.g. "[🔧 oxios]").
172    pub fn tag(&self) -> String {
173        format!("[{} {}]", self.emoji, self.name)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_project_new() {
183        let p = Project::new("oxios", ProjectSource::Manual);
184        assert_eq!(p.name, "oxios");
185        assert_eq!(p.source, ProjectSource::Manual);
186        assert!(p.paths.is_empty());
187        assert_eq!(p.emoji, "📦");
188    }
189
190    #[test]
191    fn test_project_from_path() {
192        let path = PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios");
193        let p = Project::from_path(&path);
194        assert_eq!(p.name, "oxios");
195        assert_eq!(p.source, ProjectSource::AutoDetected);
196        assert_eq!(p.paths, vec![path]);
197    }
198
199    #[test]
200    fn test_project_add_path() {
201        let mut p = Project::new("oxios", ProjectSource::Manual);
202        assert!(!p.has_paths());
203
204        p.add_path(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
205        assert!(p.has_paths());
206        assert_eq!(
207            p.primary_path(),
208            Some(&PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"))
209        );
210
211        // Duplicate path should not be added
212        p.add_path(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
213        assert_eq!(p.paths.len(), 1);
214    }
215
216    #[test]
217    fn test_project_tag() {
218        let mut p = Project::new("oxios", ProjectSource::Manual);
219        p.emoji = "🔧".to_string();
220        assert_eq!(p.tag(), "[🔧 oxios]");
221    }
222}