Skip to main content

asana_cli/models/
custom_field.rs

1//! Custom field metadata and helper types.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeMap;
6
7/// Supported custom field value kinds surfaced by Asana.
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10pub enum CustomFieldType {
11    /// Plain text.
12    #[default]
13    Text,
14    /// Numeric value.
15    Number,
16    /// Single enum option.
17    Enum,
18    /// Multiple enum options.
19    MultiEnum,
20    /// Date (or date range).
21    Date,
22    /// People reference field.
23    People,
24    /// Percentage field.
25    Percent,
26    /// Currency amount field.
27    Currency,
28    /// Catch-all for newer types not explicitly handled.
29    #[serde(other)]
30    Unknown,
31}
32
33/// Enumeration option metadata.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub struct CustomFieldEnumOption {
37    /// Globally unique identifier.
38    pub gid: String,
39    /// Option display name.
40    pub name: String,
41    /// Optional colour slug.
42    #[serde(default)]
43    pub color: Option<String>,
44    /// Whether the option is enabled.
45    #[serde(default)]
46    pub enabled: Option<bool>,
47    /// Resource type marker.
48    #[serde(default)]
49    pub resource_type: Option<String>,
50}
51
52/// Date-based custom field payload.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "snake_case")]
55pub struct CustomFieldDateValue {
56    /// Single date value (YYYY-MM-DD).
57    #[serde(default)]
58    pub date: Option<String>,
59    /// Start date for ranges.
60    #[serde(default)]
61    pub start_on: Option<String>,
62    /// Due date for ranges.
63    #[serde(default)]
64    pub due_on: Option<String>,
65}
66
67/// Fully hydrated custom field value record returned on tasks.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69#[serde(rename_all = "snake_case")]
70pub struct CustomField {
71    /// Globally unique identifier.
72    pub gid: String,
73    /// Human readable name.
74    pub name: String,
75    /// Resource type marker.
76    #[serde(default)]
77    pub resource_type: Option<String>,
78    /// Field type.
79    #[serde(rename = "type")]
80    pub field_type: CustomFieldType,
81    /// Optional description/tooltip.
82    #[serde(default)]
83    pub description: Option<String>,
84    /// Whether the field is enabled.
85    #[serde(default)]
86    pub enabled: Option<bool>,
87    /// Formatted value shown in the UI.
88    #[serde(default)]
89    pub display_value: Option<String>,
90    /// Raw text value for text fields.
91    #[serde(default)]
92    pub text_value: Option<String>,
93    /// Numeric value for number fields.
94    #[serde(default)]
95    pub number_value: Option<f64>,
96    /// Percent value for percentage fields.
97    #[serde(default)]
98    pub percent_value: Option<f64>,
99    /// ISO currency code when applicable.
100    #[serde(default)]
101    pub currency_code: Option<String>,
102    /// Selected enum option.
103    #[serde(default)]
104    pub enum_value: Option<CustomFieldEnumOption>,
105    /// Selected enum options for multi-select fields.
106    #[serde(default)]
107    pub multi_enum_values: Vec<CustomFieldEnumOption>,
108    /// Date payload for date fields.
109    #[serde(default)]
110    pub date_value: Option<CustomFieldDateValue>,
111    /// People references for people fields.
112    #[serde(default)]
113    pub people_value: Vec<String>,
114    /// Additional metadata not explicitly modelled.
115    #[serde(flatten)]
116    pub extra: BTreeMap<String, Value>,
117}
118
119/// Input values accepted when creating or updating custom fields.
120#[derive(Debug, Clone)]
121pub enum CustomFieldValue {
122    /// String-based value.
123    Text(String),
124    /// Floating point number.
125    Number(f64),
126    /// Boolean value.
127    Bool(bool),
128    /// Enum option gid.
129    EnumOption(String),
130    /// Multiple enum option gids.
131    MultiEnum(Vec<String>),
132    /// Date (YYYY-MM-DD).
133    Date(String),
134    /// Date range definition.
135    DateRange {
136        /// Start date (YYYY-MM-DD).
137        start_on: Option<String>,
138        /// Due date (YYYY-MM-DD).
139        due_on: Option<String>,
140    },
141    /// Raw JSON payload for advanced scenarios.
142    Json(Value),
143}
144
145impl CustomFieldValue {
146    /// Convert the custom field value into a JSON representation that matches the API contract.
147    #[must_use]
148    pub fn into_value(self) -> Value {
149        match self {
150            Self::Text(value) => Value::String(value),
151            Self::Number(value) => {
152                serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number)
153            }
154            Self::Bool(value) => Value::Bool(value),
155            Self::EnumOption(gid) => Value::String(gid),
156            Self::MultiEnum(gids) => Value::Array(gids.into_iter().map(Value::String).collect()),
157            Self::Date(date) => Value::String(date),
158            Self::DateRange { start_on, due_on } => {
159                let mut map = serde_json::Map::new();
160                if let Some(start_on) = start_on {
161                    map.insert("start_on".into(), Value::String(start_on));
162                }
163                if let Some(due_on) = due_on {
164                    map.insert("due_on".into(), Value::String(due_on));
165                }
166                Value::Object(map)
167            }
168            Self::Json(value) => value,
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn converts_text_value() {
179        let value = CustomFieldValue::Text("hello".into()).into_value();
180        assert_eq!(value, Value::String("hello".into()));
181    }
182
183    #[test]
184    fn converts_multi_enum_value() {
185        let value = CustomFieldValue::MultiEnum(vec!["111".into(), "222".into()]).into_value();
186        assert_eq!(
187            value,
188            Value::Array(vec![
189                Value::String("111".into()),
190                Value::String("222".into())
191            ])
192        );
193    }
194
195    #[test]
196    fn converts_date_range() {
197        let value = CustomFieldValue::DateRange {
198            start_on: Some("2024-01-01".into()),
199            due_on: None,
200        }
201        .into_value();
202        assert!(
203            value
204                .as_object()
205                .and_then(|map| map.get("start_on"))
206                .is_some()
207        );
208    }
209}