1pub mod conversation_buffer;
9pub mod detection;
10pub mod manager;
11pub mod space_bridge;
12
13pub use conversation_buffer::{ConversationBuffer, ConversationTurn};
14pub use detection::{extract_filesystem_path, match_keywords, PathMatcher};
15pub use manager::{SpaceManager, SpaceManagerError};
16pub use space_bridge::{CrossRefEntry, MemoryFlow, SpaceBridge};
17
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::path::{Path, PathBuf};
21use uuid::Uuid;
22
23pub type SpaceId = Uuid;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum SpaceSource {
30 AutoResource,
32 AutoTopic,
34 Manual,
36}
37
38impl std::fmt::Display for SpaceSource {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 SpaceSource::AutoResource => write!(f, "auto_resource"),
42 SpaceSource::AutoTopic => write!(f, "auto_topic"),
43 SpaceSource::Manual => write!(f, "manual"),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Space {
54 pub id: SpaceId,
56 pub name: String,
61 pub source: SpaceSource,
63 pub paths: Vec<PathBuf>,
67 pub workspace_dir: PathBuf,
70 #[serde(default)]
72 pub tags: Vec<String>,
73 #[serde(default)]
75 pub active: bool,
76 pub created_at: DateTime<Utc>,
78 pub last_active_at: DateTime<Utc>,
80 #[serde(default)]
82 pub interaction_count: u64,
83 #[serde(default = "default_true")]
86 pub memory_visible: bool,
87}
88
89fn default_true() -> bool {
90 true
91}
92
93impl Space {
94 pub fn new(name: impl Into<String>, source: SpaceSource) -> Self {
96 let now = Utc::now();
97 Self {
98 id: SpaceId::new_v4(),
99 name: name.into(),
100 source,
101 paths: Vec::new(),
102 workspace_dir: PathBuf::new(),
103 tags: Vec::new(),
104 active: false,
105 created_at: now,
106 last_active_at: now,
107 interaction_count: 0,
108 memory_visible: true,
109 }
110 }
111
112 pub fn from_path(path: &Path) -> Self {
114 let name = path
115 .file_name()
116 .and_then(|n| n.to_str())
117 .unwrap_or("unknown")
118 .to_string();
119 let mut space = Self::new(&name, SpaceSource::AutoResource);
120 space.paths.push(path.to_path_buf());
121 space
122 }
123
124 pub fn from_topic(topic: &str) -> Self {
126 Self::new(topic, SpaceSource::AutoTopic)
127 }
128
129 pub fn touch(&mut self) {
131 self.last_active_at = Utc::now();
132 self.interaction_count += 1;
133 }
134
135 pub fn activate(&mut self) {
137 self.active = true;
138 }
139
140 pub fn deactivate(&mut self) {
142 self.active = false;
143 }
144
145 pub fn is_named(&self) -> bool {
147 !self.name.is_empty()
148 }
149
150 pub fn is_default(&self) -> bool {
152 self.name.is_empty()
153 }
154
155 pub fn emoji(&self) -> &'static str {
157 if self.name.is_empty() {
158 "⚪"
159 } else {
160 match self.name.to_lowercase().as_str() {
162 "oxios" | "dev" | "개발" => "🔧",
163 "일상" | "daily" | "生活" => "🏠",
164 "blog" | "블로그" => "📝",
165 "docs" | "문서" => "📄",
166 "study" | "공부" | "학습" => "📚",
167 "cook" | "요리" | "recipe" | "레시피" => "🍳",
168 "work" | "업무" => "💼",
169 _ => "📦",
170 }
171 }
172 }
173
174 pub fn add_tag(&mut self, tag: impl Into<String>) {
176 let tag = tag.into();
177 if !self.tags.contains(&tag) {
178 self.tags.push(tag);
179 }
180 }
181}
182
183#[allow(missing_docs)]
184pub static DEFAULT_SPACE_ID: std::sync::OnceLock<uuid::Uuid> = std::sync::OnceLock::new();
185
186pub fn default_space_id() -> SpaceId {
188 *DEFAULT_SPACE_ID
189 .get_or_init(|| uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap())
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn test_space_new() {
198 let s = Space::new("oxios", SpaceSource::AutoResource);
199 assert_eq!(s.name, "oxios");
200 assert_eq!(s.source, SpaceSource::AutoResource);
201 assert!(!s.active);
202 assert_eq!(s.interaction_count, 0);
203 }
204
205 #[test]
206 fn test_space_from_path() {
207 let path = PathBuf::from("/projects/oxios");
208 let s = Space::from_path(&path);
209 assert_eq!(s.name, "oxios");
210 assert_eq!(s.source, SpaceSource::AutoResource);
211 assert_eq!(s.paths, vec![path]);
212 }
213
214 #[test]
215 fn test_space_touch() {
216 let mut s = Space::new("test", SpaceSource::Manual);
217 assert_eq!(s.interaction_count, 0);
218 s.touch();
219 assert_eq!(s.interaction_count, 1);
220 }
221
222 #[test]
223 fn test_space_emoji() {
224 let s = Space::new("", SpaceSource::Manual);
225 assert_eq!(s.emoji(), "⚪");
226
227 let s = Space::new("oxios", SpaceSource::AutoResource);
228 assert_eq!(s.emoji(), "🔧");
229
230 let s = Space::new("일상", SpaceSource::AutoTopic);
231 assert_eq!(s.emoji(), "🏠");
232
233 let s = Space::new("random", SpaceSource::Manual);
234 assert_eq!(s.emoji(), "📦");
235 }
236
237 #[test]
238 fn test_space_default() {
239 let s = Space::new("", SpaceSource::Manual);
240 assert!(s.is_default());
241 assert!(!s.is_named());
242
243 let s = Space::new("oxios", SpaceSource::AutoResource);
244 assert!(!s.is_default());
245 assert!(s.is_named());
246 }
247}