Skip to main content

devboy_clickup/
metadata.rs

1//! ClickUp provider metadata types for dynamic schema enrichment.
2
3use serde::{Deserialize, Serialize};
4
5/// Metadata for a ClickUp list, used for dynamic schema enrichment.
6///
7/// In cloud mode, this is loaded from the database (project.issueTrackerMetadata).
8/// In CLI mode, this can be fetched from the ClickUp API.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ClickUpMetadata {
11    #[serde(default)]
12    pub statuses: Vec<ClickUpStatus>,
13    /// Custom fields defined for the list.
14    #[serde(default, alias = "customFields")]
15    pub custom_fields: Vec<ClickUpCustomField>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ClickUpStatus {
20    pub name: String,
21    /// Status type: "open", "closed", "custom", etc.
22    #[serde(default)]
23    pub r#type: Option<String>,
24}
25
26/// ClickUp custom field definition.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ClickUpCustomField {
29    pub id: String,
30    /// Human-readable name.
31    pub name: String,
32    #[serde(alias = "type")]
33    pub field_type: ClickUpFieldType,
34    /// Whether this field is required.
35    #[serde(default)]
36    pub required: bool,
37    /// Options for dropdown/labels fields.
38    #[serde(default)]
39    pub options: Vec<ClickUpFieldOption>,
40}
41
42/// ClickUp custom field types with their transformation semantics.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44#[serde(rename_all = "snake_case")]
45pub enum ClickUpFieldType {
46    /// Single select dropdown → value is name, transforms to orderindex.
47    #[serde(alias = "drop_down")]
48    Dropdown,
49    /// Multi-select labels → value is name array, transforms to id array.
50    Labels,
51    /// Numeric value → pass-through.
52    Number,
53    /// Currency value → pass-through as number.
54    Currency,
55    /// Boolean → pass-through.
56    Checkbox,
57    /// Date → ISO 8601 or Unix timestamp ms.
58    Date,
59    /// Free text → pass-through.
60    Text,
61    /// Email → pass-through as string.
62    Email,
63    /// URL → pass-through as string.
64    Url,
65    /// Phone → pass-through as string.
66    Phone,
67    /// Catch-all for unknown/unsupported field types (automatic_progress, etc.)
68    #[serde(other)]
69    Unknown,
70}
71
72/// Option for dropdown/labels custom fields.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ClickUpFieldOption {
75    pub id: String,
76    /// Display name.
77    pub name: String,
78    /// Position in dropdown list (ClickUp-specific, used for dropdown value).
79    #[serde(default)]
80    pub orderindex: Option<u32>,
81}
82
83impl ClickUpCustomField {
84    /// Convert a human-readable value to ClickUp API format.
85    ///
86    /// - Dropdown: name → orderindex (position in options array)
87    /// - Labels: name array → id array
88    /// - Other types: pass-through
89    pub fn transform_value(&self, value: &serde_json::Value) -> serde_json::Value {
90        match self.field_type {
91            ClickUpFieldType::Dropdown => {
92                if let Some(name) = value.as_str() {
93                    // Find option by name and return orderindex
94                    for (idx, opt) in self.options.iter().enumerate() {
95                        if opt.name.eq_ignore_ascii_case(name) {
96                            return serde_json::json!(opt.orderindex.unwrap_or(idx as u32));
97                        }
98                    }
99                }
100                value.clone()
101            }
102            ClickUpFieldType::Labels => {
103                if let Some(names) = value.as_array() {
104                    let ids: Vec<serde_json::Value> = names
105                        .iter()
106                        .filter_map(|n| {
107                            let name = n.as_str()?;
108                            self.options
109                                .iter()
110                                .find(|o| o.name.eq_ignore_ascii_case(name))
111                                .map(|o| serde_json::json!(o.id))
112                        })
113                        .collect();
114                    return serde_json::json!(ids);
115                }
116                value.clone()
117            }
118            ClickUpFieldType::Checkbox => {
119                // Ensure boolean
120                if let Some(b) = value.as_bool() {
121                    serde_json::json!(b)
122                } else if let Some(s) = value.as_str() {
123                    serde_json::json!(s == "true" || s == "1")
124                } else {
125                    value.clone()
126                }
127            }
128            // Number, Currency, Date, Text, Email, Url, Phone — pass-through
129            _ => value.clone(),
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use serde_json::json;
138
139    fn sample_dropdown_field() -> ClickUpCustomField {
140        ClickUpCustomField {
141            id: "uuid-1".into(),
142            name: "Risk Level".into(),
143            field_type: ClickUpFieldType::Dropdown,
144            required: false,
145            options: vec![
146                ClickUpFieldOption {
147                    id: "opt-1".into(),
148                    name: "Low".into(),
149                    orderindex: Some(0),
150                },
151                ClickUpFieldOption {
152                    id: "opt-2".into(),
153                    name: "Medium".into(),
154                    orderindex: Some(1),
155                },
156                ClickUpFieldOption {
157                    id: "opt-3".into(),
158                    name: "High".into(),
159                    orderindex: Some(2),
160                },
161            ],
162        }
163    }
164
165    fn sample_labels_field() -> ClickUpCustomField {
166        ClickUpCustomField {
167            id: "uuid-2".into(),
168            name: "Tags".into(),
169            field_type: ClickUpFieldType::Labels,
170            required: false,
171            options: vec![
172                ClickUpFieldOption {
173                    id: "label-1".into(),
174                    name: "Frontend".into(),
175                    orderindex: None,
176                },
177                ClickUpFieldOption {
178                    id: "label-2".into(),
179                    name: "Backend".into(),
180                    orderindex: None,
181                },
182            ],
183        }
184    }
185
186    #[test]
187    fn test_dropdown_transform_by_name() {
188        let field = sample_dropdown_field();
189        assert_eq!(field.transform_value(&json!("Medium")), json!(1));
190        assert_eq!(field.transform_value(&json!("High")), json!(2));
191    }
192
193    #[test]
194    fn test_dropdown_case_insensitive() {
195        let field = sample_dropdown_field();
196        assert_eq!(field.transform_value(&json!("low")), json!(0));
197    }
198
199    #[test]
200    fn test_labels_transform_names_to_ids() {
201        let field = sample_labels_field();
202        let result = field.transform_value(&json!(["Frontend", "Backend"]));
203        assert_eq!(result, json!(["label-1", "label-2"]));
204    }
205
206    #[test]
207    fn test_checkbox_transform() {
208        let field = ClickUpCustomField {
209            id: "uuid-3".into(),
210            name: "Done".into(),
211            field_type: ClickUpFieldType::Checkbox,
212            required: false,
213            options: vec![],
214        };
215        assert_eq!(field.transform_value(&json!(true)), json!(true));
216        assert_eq!(field.transform_value(&json!("true")), json!(true));
217        assert_eq!(field.transform_value(&json!("false")), json!(false));
218    }
219
220    #[test]
221    fn test_number_passthrough() {
222        let field = ClickUpCustomField {
223            id: "uuid-4".into(),
224            name: "Story Points".into(),
225            field_type: ClickUpFieldType::Number,
226            required: false,
227            options: vec![],
228        };
229        assert_eq!(field.transform_value(&json!(5)), json!(5));
230    }
231
232    #[test]
233    fn test_metadata_deserialization() {
234        let json = json!({
235            "statuses": [
236                { "name": "To Do", "type": "open" },
237                { "name": "In Progress", "type": "custom" },
238                { "name": "Done", "type": "closed" },
239            ],
240            "custom_fields": [
241                {
242                    "id": "uuid-1",
243                    "name": "Story Points",
244                    "field_type": "number",
245                    "required": false,
246                    "options": []
247                }
248            ]
249        });
250        let meta: ClickUpMetadata = serde_json::from_value(json).unwrap();
251        assert_eq!(meta.statuses.len(), 3);
252        assert_eq!(meta.custom_fields.len(), 1);
253        assert_eq!(meta.custom_fields[0].field_type, ClickUpFieldType::Number);
254    }
255}