Skip to main content

boarddown_core/
events.rs

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}