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, Default, 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
73fn default_true() -> bool {
74    true
75}
76
77#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
78#[ts(export)]
79pub struct SyncConfig {
80    #[serde(default)]
81    pub enabled: bool,
82    #[serde(default)]
83    pub provider: SyncProvider,
84    #[serde(default)]
85    pub conflict_resolution: ConflictResolution,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub url: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub auth: Option<String>,
90}
91
92#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, TS)]
93#[ts(export)]
94#[serde(rename_all = "lowercase")]
95pub enum SyncProvider {
96    #[default]
97    Crdt,
98    Git,
99    Custom,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
103#[ts(export)]
104pub struct HooksConfig {
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub on_task_move: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub on_board_save: Option<String>,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
112#[ts(export)]
113pub struct SchemaConfig {
114    #[serde(default)]
115    pub custom_fields: HashMap<String, CustomFieldSchema>,
116}
117
118impl SchemaConfig {
119    pub fn validate_field(&self, field_name: &str, value: &serde_json::Value) -> Result<(), ValidationError> {
120        let schema = self.custom_fields.get(field_name)
121            .ok_or_else(|| ValidationError::UnknownField(field_name.to_string()))?;
122        schema.validate(value)
123    }
124    
125    pub fn validate_metadata(&self, metadata: &crate::Metadata) -> Result<(), ValidationError> {
126        for (key, value) in metadata.iter() {
127            if let Some(_schema) = self.custom_fields.get(&key) {
128                self.validate_field(&key, &value)?;
129            }
130        }
131        Ok(())
132    }
133}
134
135#[derive(Debug, Clone, thiserror::Error)]
136pub enum ValidationError {
137    #[error("Unknown field: {0}")]
138    UnknownField(String),
139    #[error("Invalid type for field {field}: expected {expected}, got {actual}")]
140    InvalidType { field: String, expected: String, actual: String },
141    #[error("Value {value} not in allowed values for field {field}")]
142    InvalidEnumValue { field: String, value: String },
143    #[error("Value {value} out of range for field {field}: min={min:?}, max={max:?}")]
144    OutOfRange { field: String, value: i64, min: Option<i64>, max: Option<i64> },
145    #[error("Invalid date format for field {0}")]
146    InvalidDate(String),
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, TS)]
150#[ts(export)]
151pub struct CustomFieldSchema {
152    #[serde(rename = "type")]
153    pub field_type: CustomFieldType,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub values: Option<Vec<String>>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub min: Option<i64>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub max: Option<i64>,
160}
161
162impl CustomFieldSchema {
163    pub fn validate(&self, value: &serde_json::Value) -> Result<(), ValidationError> {
164        match self.field_type {
165            CustomFieldType::String => {
166                if !value.is_string() {
167                    return Err(ValidationError::InvalidType {
168                        field: "field".to_string(),
169                        expected: "string".to_string(),
170                        actual: value_type_name(value),
171                    });
172                }
173            }
174            CustomFieldType::Number => {
175                if let Some(n) = value.as_i64() {
176                    if let Some(min) = self.min {
177                        if n < min {
178                            return Err(ValidationError::OutOfRange {
179                                field: "field".to_string(),
180                                value: n,
181                                min: Some(min),
182                                max: self.max,
183                            });
184                        }
185                    }
186                    if let Some(max) = self.max {
187                        if n > max {
188                            return Err(ValidationError::OutOfRange {
189                                field: "field".to_string(),
190                                value: n,
191                                min: self.min,
192                                max: Some(max),
193                            });
194                        }
195                    }
196                } else {
197                    return Err(ValidationError::InvalidType {
198                        field: "field".to_string(),
199                        expected: "number".to_string(),
200                        actual: value_type_name(value),
201                    });
202                }
203            }
204            CustomFieldType::Boolean => {
205                if !value.is_boolean() {
206                    return Err(ValidationError::InvalidType {
207                        field: "field".to_string(),
208                        expected: "boolean".to_string(),
209                        actual: value_type_name(value),
210                    });
211                }
212            }
213            CustomFieldType::Enum => {
214                if let Some(s) = value.as_str() {
215                    if let Some(allowed) = &self.values {
216                        if !allowed.contains(&s.to_string()) {
217                            return Err(ValidationError::InvalidEnumValue {
218                                field: "field".to_string(),
219                                value: s.to_string(),
220                            });
221                        }
222                    }
223                } else {
224                    return Err(ValidationError::InvalidType {
225                        field: "field".to_string(),
226                        expected: "string".to_string(),
227                        actual: value_type_name(value),
228                    });
229                }
230            }
231            CustomFieldType::Date => {
232                if let Some(s) = value.as_str() {
233                    if chrono::DateTime::parse_from_rfc3339(s).is_err() {
234                        return Err(ValidationError::InvalidDate("field".to_string()));
235                    }
236                } else {
237                    return Err(ValidationError::InvalidType {
238                        field: "field".to_string(),
239                        expected: "date string".to_string(),
240                        actual: value_type_name(value),
241                    });
242                }
243            }
244        }
245        Ok(())
246    }
247}
248
249fn value_type_name(value: &serde_json::Value) -> String {
250    match value {
251        serde_json::Value::Null => "null".to_string(),
252        serde_json::Value::Bool(_) => "boolean".to_string(),
253        serde_json::Value::Number(_) => "number".to_string(),
254        serde_json::Value::String(_) => "string".to_string(),
255        serde_json::Value::Array(_) => "array".to_string(),
256        serde_json::Value::Object(_) => "object".to_string(),
257    }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, TS)]
261#[ts(export)]
262#[serde(rename_all = "lowercase")]
263pub enum CustomFieldType {
264    String,
265    Number,
266    Boolean,
267    Enum,
268    Date,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, TS)]
272#[ts(export)]
273pub enum BoardEvent {
274    TaskCreated { board_id: BoardId, task_id: TaskId },
275    TaskUpdated { board_id: BoardId, task_id: TaskId },
276    TaskMoved { task_id: TaskId, from: Status, to: Status },
277    TaskDeleted { board_id: BoardId, task_id: TaskId },
278    ColumnAdded { board_id: BoardId, column: String },
279    BoardSaved { board_id: BoardId },
280    DependencyResolved { task_id: TaskId, dependency: TaskId },
281}