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}