Skip to main content

boarddown_schema/
workspace.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use ts_rs::TS;
4
5use crate::{BoardId, TaskId, StorageType, ConflictResolution, Status};
6
7#[derive(Debug, Clone, Serialize, Deserialize, TS)]
8#[ts(export)]
9pub struct WorkspaceConfig {
10    pub name: String,
11    pub id_scheme: Option<String>,
12    pub default_storage: StorageType,
13    pub storage: StorageConfig,
14    pub sync: SyncConfig,
15    pub hooks: HooksConfig,
16    pub schema: SchemaConfig,
17}
18
19impl Default for WorkspaceConfig {
20    fn default() -> Self {
21        Self {
22            name: String::new(),
23            id_scheme: None,
24            default_storage: StorageType::default(),
25            storage: StorageConfig::default(),
26            sync: SyncConfig::default(),
27            hooks: HooksConfig::default(),
28            schema: SchemaConfig::default(),
29        }
30    }
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
34#[ts(export)]
35pub struct StorageConfig {
36    #[serde(default)]
37    pub sqlite: SqliteStorageConfig,
38    #[serde(default)]
39    pub filesystem: FilesystemStorageConfig,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, TS)]
43#[ts(export)]
44pub struct SqliteStorageConfig {
45    #[serde(default = "default_sqlite_path")]
46    pub path: String,
47    #[serde(default)]
48    pub enable_ft_index: bool,
49}
50
51impl Default for SqliteStorageConfig {
52    fn default() -> Self {
53        Self {
54            path: default_sqlite_path(),
55            enable_ft_index: false,
56        }
57    }
58}
59
60fn default_sqlite_path() -> String {
61    ".boarddown/cache.db".to_string()
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, TS)]
65#[ts(export)]
66pub struct FilesystemStorageConfig {
67    #[serde(default = "default_true")]
68    pub watch: bool,
69    #[serde(default)]
70    pub git_auto_commit: bool,
71}
72
73impl Default for FilesystemStorageConfig {
74    fn default() -> Self {
75        Self {
76            watch: true,
77            git_auto_commit: false,
78        }
79    }
80}
81
82fn default_true() -> bool {
83    true
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
87#[ts(export)]
88pub struct SyncConfig {
89    #[serde(default)]
90    pub enabled: bool,
91    #[serde(default)]
92    pub provider: SyncProvider,
93    #[serde(default)]
94    pub conflict_resolution: ConflictResolution,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub url: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub auth: Option<String>,
99}
100
101#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, TS)]
102#[ts(export)]
103#[serde(rename_all = "lowercase")]
104pub enum SyncProvider {
105    #[default]
106    Crdt,
107    Git,
108    Custom,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
112#[ts(export)]
113pub struct HooksConfig {
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub on_task_move: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub on_board_save: Option<String>,
118}
119
120#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
121#[ts(export)]
122pub struct SchemaConfig {
123    #[serde(default)]
124    pub custom_fields: HashMap<String, CustomFieldSchema>,
125}
126
127impl SchemaConfig {
128    pub fn validate_field(&self, field_name: &str, value: &serde_json::Value) -> Result<(), ValidationError> {
129        let schema = self.custom_fields.get(field_name)
130            .ok_or_else(|| ValidationError::UnknownField(field_name.to_string()))?;
131        schema.validate(value)
132    }
133    
134    pub fn validate_metadata(&self, metadata: &crate::Metadata) -> Result<(), ValidationError> {
135        for (key, value) in metadata.iter() {
136            if let Some(_schema) = self.custom_fields.get(&key) {
137                self.validate_field(&key, &value)?;
138            }
139        }
140        Ok(())
141    }
142}
143
144#[derive(Debug, Clone, thiserror::Error)]
145pub enum ValidationError {
146    #[error("Unknown field: {0}")]
147    UnknownField(String),
148    #[error("Invalid type for field {field}: expected {expected}, got {actual}")]
149    InvalidType { field: String, expected: String, actual: String },
150    #[error("Value {value} not in allowed values for field {field}")]
151    InvalidEnumValue { field: String, value: String },
152    #[error("Value {value} out of range for field {field}: min={min:?}, max={max:?}")]
153    OutOfRange { field: String, value: i64, min: Option<i64>, max: Option<i64> },
154    #[error("Invalid date format for field {0}")]
155    InvalidDate(String),
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, TS)]
159#[ts(export)]
160pub struct CustomFieldSchema {
161    #[serde(rename = "type")]
162    pub field_type: CustomFieldType,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub values: Option<Vec<String>>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub min: Option<i64>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub max: Option<i64>,
169}
170
171impl CustomFieldSchema {
172    pub fn validate(&self, value: &serde_json::Value) -> Result<(), ValidationError> {
173        match self.field_type {
174            CustomFieldType::String => {
175                if !value.is_string() {
176                    return Err(ValidationError::InvalidType {
177                        field: "field".to_string(),
178                        expected: "string".to_string(),
179                        actual: value_type_name(value),
180                    });
181                }
182            }
183            CustomFieldType::Number => {
184                if let Some(n) = value.as_i64() {
185                    if let Some(min) = self.min {
186                        if n < min {
187                            return Err(ValidationError::OutOfRange {
188                                field: "field".to_string(),
189                                value: n,
190                                min: Some(min),
191                                max: self.max,
192                            });
193                        }
194                    }
195                    if let Some(max) = self.max {
196                        if n > max {
197                            return Err(ValidationError::OutOfRange {
198                                field: "field".to_string(),
199                                value: n,
200                                min: self.min,
201                                max: Some(max),
202                            });
203                        }
204                    }
205                } else {
206                    return Err(ValidationError::InvalidType {
207                        field: "field".to_string(),
208                        expected: "number".to_string(),
209                        actual: value_type_name(value),
210                    });
211                }
212            }
213            CustomFieldType::Boolean => {
214                if !value.is_boolean() {
215                    return Err(ValidationError::InvalidType {
216                        field: "field".to_string(),
217                        expected: "boolean".to_string(),
218                        actual: value_type_name(value),
219                    });
220                }
221            }
222            CustomFieldType::Enum => {
223                if let Some(s) = value.as_str() {
224                    if let Some(allowed) = &self.values {
225                        if !allowed.contains(&s.to_string()) {
226                            return Err(ValidationError::InvalidEnumValue {
227                                field: "field".to_string(),
228                                value: s.to_string(),
229                            });
230                        }
231                    }
232                } else {
233                    return Err(ValidationError::InvalidType {
234                        field: "field".to_string(),
235                        expected: "string".to_string(),
236                        actual: value_type_name(value),
237                    });
238                }
239            }
240            CustomFieldType::Date => {
241                if let Some(s) = value.as_str() {
242                    if chrono::DateTime::parse_from_rfc3339(s).is_err() {
243                        return Err(ValidationError::InvalidDate("field".to_string()));
244                    }
245                } else {
246                    return Err(ValidationError::InvalidType {
247                        field: "field".to_string(),
248                        expected: "date string".to_string(),
249                        actual: value_type_name(value),
250                    });
251                }
252            }
253        }
254        Ok(())
255    }
256}
257
258fn value_type_name(value: &serde_json::Value) -> String {
259    match value {
260        serde_json::Value::Null => "null".to_string(),
261        serde_json::Value::Bool(_) => "boolean".to_string(),
262        serde_json::Value::Number(_) => "number".to_string(),
263        serde_json::Value::String(_) => "string".to_string(),
264        serde_json::Value::Array(_) => "array".to_string(),
265        serde_json::Value::Object(_) => "object".to_string(),
266    }
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, TS)]
270#[ts(export)]
271#[serde(rename_all = "lowercase")]
272pub enum CustomFieldType {
273    String,
274    Number,
275    Boolean,
276    Enum,
277    Date,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, TS)]
281#[ts(export)]
282pub enum BoardEvent {
283    TaskCreated { board_id: BoardId, task_id: TaskId },
284    TaskUpdated { board_id: BoardId, task_id: TaskId },
285    TaskMoved { task_id: TaskId, from: Status, to: Status },
286    TaskDeleted { board_id: BoardId, task_id: TaskId },
287    ColumnAdded { board_id: BoardId, column: String },
288    BoardSaved { board_id: BoardId },
289    DependencyResolved { task_id: TaskId, dependency: TaskId },
290}