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}