1use std::sync::Arc;
2use tokio::sync::{broadcast, RwLock};
3use boarddown_schema::BoardEvent;
4
5pub type EventSender = broadcast::Sender<BoardEvent>;
6pub type EventReceiver = broadcast::Receiver<BoardEvent>;
7
8#[derive(Debug, Clone)]
9pub struct EventBus {
10 sender: EventSender,
11}
12
13impl EventBus {
14 pub fn new() -> Self {
15 let (sender, _) = broadcast::channel(1024);
16 Self { sender }
17 }
18
19 pub fn subscribe(&self) -> EventReceiver {
20 self.sender.subscribe()
21 }
22
23 pub fn publish(&self, event: BoardEvent) -> Result<(), crate::Error> {
24 self.sender.send(event)
25 .map(|_| ())
26 .map_err(|_| crate::Error::ChannelSend)
27 }
28
29 pub fn sender(&self) -> EventSender {
30 self.sender.clone()
31 }
32}
33
34impl Default for EventBus {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40#[derive(Clone)]
41pub struct Workspace {
42 inner: Arc<RwLock<WorkspaceInner>>,
43}
44
45struct WorkspaceInner {
46 path: String,
47 storage: Option<Arc<dyn crate::Storage>>,
48 event_bus: EventBus,
49 config: Option<boarddown_schema::WorkspaceConfig>,
50}
51
52impl Workspace {
53 pub fn new(path: impl Into<String>) -> Self {
54 Self {
55 inner: Arc::new(RwLock::new(WorkspaceInner {
56 path: path.into(),
57 storage: None,
58 event_bus: EventBus::new(),
59 config: None,
60 })),
61 }
62 }
63
64 pub fn with_storage(self, storage: Arc<dyn crate::Storage>) -> Self {
65 let mut inner = self.inner.blocking_write();
66 inner.storage = Some(storage);
67 drop(inner);
68 self
69 }
70
71 pub async fn open(path: impl Into<String>) -> Result<Self, crate::Error> {
72 let path_str = path.into();
73 let ws = Self::new(&path_str);
74
75 {
76 let mut inner = ws.inner.write().await;
77
78 let config_path = format!("{}/.boarddown/config.toml", path_str);
79 if tokio::fs::try_exists(&config_path).await.unwrap_or(false) {
80 let config_content = tokio::fs::read_to_string(&config_path).await
81 .map_err(|e| crate::Error::Config(format!("Failed to read config: {}", e)))?;
82 inner.config = Some(parse_workspace_config(&config_content)?);
83 }
84 }
85
86 Ok(ws)
87 }
88
89 pub async fn path(&self) -> String {
90 self.inner.read().await.path.clone()
91 }
92
93 pub async fn config(&self) -> Option<boarddown_schema::WorkspaceConfig> {
94 self.inner.read().await.config.clone()
95 }
96
97 pub fn subscribe(&self) -> EventReceiver {
98 self.inner.blocking_read().event_bus.subscribe()
99 }
100
101 pub async fn get_board(&self, id: &str) -> Result<BoardRef, crate::Error> {
102 let inner = self.inner.read().await;
103 let storage = inner.storage.as_ref()
104 .ok_or_else(|| crate::Error::Storage("No storage configured".to_string()))?;
105
106 let board_id = boarddown_schema::BoardId(id.to_string());
107 let board = storage.load_board(&board_id).await?;
108
109 Ok(BoardRef {
110 board: Arc::new(RwLock::new(board)),
111 storage: storage.clone(),
112 event_bus: inner.event_bus.clone(),
113 })
114 }
115
116 pub async fn list_boards(&self) -> Result<Vec<String>, crate::Error> {
117 let inner = self.inner.read().await;
118 let storage = inner.storage.as_ref()
119 .ok_or_else(|| crate::Error::Storage("No storage configured".to_string()))?;
120
121 let boards = storage.list_boards().await?;
122 Ok(boards.into_iter().map(|id| id.0).collect())
123 }
124
125 pub async fn create_board(&self, id: &str, title: &str) -> Result<BoardRef, crate::Error> {
126 let inner = self.inner.read().await;
127 let storage = inner.storage.as_ref()
128 .ok_or_else(|| crate::Error::Storage("No storage configured".to_string()))?;
129
130 let board = boarddown_schema::Board::with_default_columns(id, title);
131 storage.save_board(&board).await?;
132
133 inner.event_bus.publish(BoardEvent::BoardSaved {
134 board_id: board.id.clone(),
135 })?;
136
137 Ok(BoardRef {
138 board: Arc::new(RwLock::new(board)),
139 storage: storage.clone(),
140 event_bus: inner.event_bus.clone(),
141 })
142 }
143
144 pub async fn delete_board(&self, id: &str) -> Result<(), crate::Error> {
145 let inner = self.inner.read().await;
146 let storage = inner.storage.as_ref()
147 .ok_or_else(|| crate::Error::Storage("No storage configured".to_string()))?;
148
149 storage.delete_board(&boarddown_schema::BoardId(id.to_string())).await
150 }
151
152 pub async fn save_task(&self, board_id: &str, task: &boarddown_schema::Task) -> Result<(), crate::Error> {
153 let inner = self.inner.read().await;
154 let storage = inner.storage.as_ref()
155 .ok_or_else(|| crate::Error::Storage("No storage configured".to_string()))?;
156
157 let board_id = boarddown_schema::BoardId(board_id.to_string());
158 storage.save_task(&board_id, task).await?;
159
160 inner.event_bus.publish(BoardEvent::TaskUpdated {
161 board_id,
162 task_id: task.id.clone(),
163 })?;
164
165 Ok(())
166 }
167
168 pub async fn transaction<F, T>(&self, _board_id: &str, f: F) -> Result<T, crate::Error>
169 where
170 F: std::future::Future<Output = Result<T, crate::Error>> + Send,
171 {
172 let result = f.await?;
173 Ok(result)
174 }
175
176 pub async fn builder() -> WorkspaceBuilder {
177 WorkspaceBuilder::default()
178 }
179}
180
181#[derive(Clone)]
182pub struct BoardRef {
183 board: Arc<RwLock<boarddown_schema::Board>>,
184 storage: Arc<dyn crate::Storage>,
185 event_bus: EventBus,
186}
187
188impl BoardRef {
189 pub async fn id(&self) -> boarddown_schema::BoardId {
190 self.board.read().await.id.clone()
191 }
192
193 pub async fn title(&self) -> String {
194 self.board.read().await.title.clone()
195 }
196
197 pub async fn columns(&self) -> Vec<boarddown_schema::Column> {
198 self.board.read().await.columns.clone()
199 }
200
201 pub async fn tasks(&self) -> Vec<boarddown_schema::Task> {
202 self.board.read().await.tasks.values().cloned().collect()
203 }
204
205 pub async fn get_task(&self, id: &boarddown_schema::TaskId) -> Option<boarddown_schema::Task> {
206 self.board.read().await.tasks.get(id).cloned()
207 }
208
209 pub async fn create_task(&self, options: TaskCreateOptions) -> Result<boarddown_schema::Task, crate::Error> {
210 let mut board = self.board.write().await;
211
212 let prefix = board.metadata.id_prefix.clone().unwrap_or_else(|| board.id.0.clone());
213 let seq = board.tasks.len() as u64 + 1;
214 let task_id = boarddown_schema::TaskId::new(&prefix, seq);
215
216 let column = options.column.unwrap_or_else(|| {
217 board.columns.first().map(|c| c.name.clone()).unwrap_or_else(|| "Todo".to_string())
218 });
219
220 let now = chrono::Utc::now();
221 let task = boarddown_schema::Task {
222 id: task_id.clone(),
223 title: options.title,
224 status: boarddown_schema::Status::Todo,
225 column: boarddown_schema::ColumnRef::Name(column),
226 metadata: options.metadata.unwrap_or_default(),
227 dependencies: Vec::new(),
228 references: Vec::new(),
229 created_at: now,
230 updated_at: now,
231 };
232
233 board.tasks.insert(task_id.clone(), task.clone());
234 board.updated_at = chrono::Utc::now();
235
236 let board_id = board.id.clone();
237 drop(board);
238
239 self.storage.save_board(&self.board.read().await.clone()).await?;
240
241 self.event_bus.publish(BoardEvent::TaskCreated {
242 board_id,
243 task_id: task.id.clone(),
244 })?;
245
246 Ok(task)
247 }
248
249 pub async fn move_task(&self, task_id: &boarddown_schema::TaskId, to_column: &str) -> Result<(), crate::Error> {
250 let mut board = self.board.write().await;
251
252 let task = board.tasks.get_mut(task_id)
253 .ok_or_else(|| crate::Error::TaskNotFound(task_id.to_string()))?;
254
255 let from_status = task.status;
256 task.column = boarddown_schema::ColumnRef::Name(to_column.to_string());
257
258 if to_column == "Done" {
259 task.status = boarddown_schema::Status::Done;
260 }
261 task.updated_at = chrono::Utc::now();
262
263 let to_status = task.status;
264 let board_id = board.id.clone();
265 board.updated_at = chrono::Utc::now();
266
267 let mut resolved_deps: Vec<boarddown_schema::TaskId> = Vec::new();
268 if to_status == boarddown_schema::Status::Done {
269 for other_task in board.tasks.values() {
270 if other_task.dependencies.contains(task_id) {
271 resolved_deps.push(other_task.id.clone());
272 }
273 }
274 }
275
276 drop(board);
277
278 self.storage.save_board(&self.board.read().await.clone()).await?;
279
280 self.event_bus.publish(BoardEvent::TaskMoved {
281 task_id: task_id.clone(),
282 from: from_status,
283 to: to_status,
284 })?;
285
286 for dep_task_id in resolved_deps {
287 self.event_bus.publish(BoardEvent::DependencyResolved {
288 task_id: dep_task_id,
289 dependency: task_id.clone(),
290 })?;
291 }
292
293 Ok(())
294 }
295
296 pub async fn delete_task(&self, task_id: &boarddown_schema::TaskId) -> Result<(), crate::Error> {
297 let mut board = self.board.write().await;
298
299 board.tasks.remove(task_id)
300 .ok_or_else(|| crate::Error::TaskNotFound(task_id.to_string()))?;
301
302 let board_id = board.id.clone();
303 board.updated_at = chrono::Utc::now();
304
305 drop(board);
306
307 self.storage.save_board(&self.board.read().await.clone()).await?;
308
309 self.event_bus.publish(BoardEvent::TaskDeleted {
310 board_id,
311 task_id: task_id.clone(),
312 })?;
313
314 Ok(())
315 }
316
317 pub fn query(&self) -> crate::QueryBuilder {
318 let board = self.board.blocking_read().clone();
319 crate::QueryBuilder::new().with_board(board)
320 }
321
322 pub async fn refresh(&self) -> Result<(), crate::Error> {
323 let board_id = self.board.read().await.id.clone();
324 let fresh = self.storage.load_board(&board_id).await?;
325
326 let mut board = self.board.write().await;
327 *board = fresh;
328
329 Ok(())
330 }
331
332 pub async fn save(&self) -> Result<(), crate::Error> {
333 let board = self.board.read().await.clone();
334 self.storage.save_board(&board).await?;
335
336 self.event_bus.publish(BoardEvent::BoardSaved {
337 board_id: board.id.clone(),
338 })?;
339
340 Ok(())
341 }
342
343 pub async fn add_column(&self, name: &str) -> Result<(), crate::Error> {
344 let mut board = self.board.write().await;
345 let column = boarddown_schema::Column::new(name);
346 board.add_column(column);
347 let board_id = board.id.clone();
348 board.updated_at = chrono::Utc::now();
349
350 drop(board);
351
352 self.storage.save_board(&self.board.read().await.clone()).await?;
353
354 self.event_bus.publish(BoardEvent::ColumnAdded {
355 board_id,
356 column: name.to_string(),
357 })?;
358
359 Ok(())
360 }
361
362 pub fn on_change(&self) -> EventReceiver {
363 self.event_bus.subscribe()
364 }
365}
366
367#[derive(Debug, Clone, Default)]
368pub struct TaskCreateOptions {
369 pub title: String,
370 pub column: Option<String>,
371 pub metadata: Option<boarddown_schema::Metadata>,
372}
373
374#[derive(Clone, Default)]
375pub struct WorkspaceBuilder {
376 path: Option<String>,
377 storage: Option<Arc<dyn crate::Storage>>,
378}
379
380impl WorkspaceBuilder {
381 pub fn path(mut self, path: impl Into<String>) -> Self {
382 self.path = Some(path.into());
383 self
384 }
385
386 pub fn storage(mut self, storage: Arc<dyn crate::Storage>) -> Self {
387 self.storage = Some(storage);
388 self
389 }
390
391 pub async fn build(self) -> Result<Workspace, crate::Error> {
392 let path = self.path.ok_or_else(|| crate::Error::Config("Path is required".to_string()))?;
393 let ws = Workspace::open(&path).await?;
394
395 if let Some(storage) = self.storage {
396 let mut inner = ws.inner.write().await;
397 inner.storage = Some(storage);
398 }
399
400 Ok(ws)
401 }
402}
403
404fn parse_workspace_config(content: &str) -> Result<boarddown_schema::WorkspaceConfig, crate::Error> {
405 let config: toml::Value = toml::from_str(content)
406 .map_err(|e| crate::Error::Config(format!("Invalid TOML: {}", e)))?;
407
408 let workspace = config.get("workspace");
409
410 let name = workspace
411 .and_then(|w| w.get("name"))
412 .and_then(|n| n.as_str())
413 .unwrap_or("Workspace")
414 .to_string();
415
416 let id_scheme = workspace
417 .and_then(|w| w.get("id-scheme"))
418 .and_then(|n| n.as_str())
419 .map(String::from);
420
421 let default_storage = workspace
422 .and_then(|w| w.get("default-storage"))
423 .and_then(|n| n.as_str())
424 .map(|s| match s {
425 "sqlite" => boarddown_schema::StorageType::Sqlite,
426 "hybrid" => boarddown_schema::StorageType::Hybrid,
427 _ => boarddown_schema::StorageType::Filesystem,
428 })
429 .unwrap_or_default();
430
431 let sync = config.get("sync");
432 let sync_enabled = sync.and_then(|s| s.get("enabled")).and_then(|e| e.as_bool()).unwrap_or(false);
433
434 Ok(boarddown_schema::WorkspaceConfig {
435 name,
436 id_scheme,
437 default_storage,
438 storage: boarddown_schema::StorageConfig::default(),
439 sync: boarddown_schema::SyncConfig {
440 enabled: sync_enabled,
441 ..Default::default()
442 },
443 hooks: boarddown_schema::HooksConfig::default(),
444 schema: boarddown_schema::SchemaConfig::default(),
445 })
446}