Skip to main content

gonzalo_ticket/
mapping.rs

1//! Per-connection field/state mapping policy (ADR 0010).
2//!
3//! The signal that carries a ticket's status is configured **per connection**,
4//! not fixed per provider — GitLab free encodes workflow in `workflow::` scoped
5//! labels while Premium uses a native status field; Asana uses a `completed`
6//! flag, a section, or a custom field depending on the workspace. A
7//! [`StateMapping`] declares which signal a connection reads and how its raw
8//! values translate onto the normalized [`StateCategory`]. The connector
9//! extracts the raw value per the signal; the mapping is pure translation, so it
10//! is trivially testable in isolation.
11
12use gonzalo_domain::StateCategory;
13use std::collections::BTreeMap;
14
15/// Where a connection reads a ticket's status from.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum StateSignal {
18    /// The platform's intrinsic open/closed (+ reason) field.
19    IntrinsicState,
20    /// A categorized native status field (Jira / Linear / GitLab-Premium / ADO).
21    NativeStatus,
22    /// A scoped-label namespace, e.g. GitLab `workflow::`.
23    ScopedLabel { prefix: String },
24    /// A board section / column (Asana, Trello).
25    Section,
26    /// A custom field used as status, addressed by field id.
27    CustomField { id: String },
28    /// A boolean completed flag (Asana).
29    Completed,
30}
31
32/// Resolves a provider's raw status value to a normalized [`StateCategory`].
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct StateMapping {
35    /// Where the connector reads the raw status value from.
36    pub signal: StateSignal,
37    /// Raw value (status name / label suffix / section name) → category.
38    pub by_value: BTreeMap<String, StateCategory>,
39    /// Category used when no `by_value` entry matches the raw value.
40    pub default: StateCategory,
41}
42
43/// Failure resolving a normalized category back to a board column for write-back.
44#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45pub enum ReverseError {
46    /// No column maps to this category and no override was given.
47    #[error("no column maps to category {0:?}")]
48    Unmapped(StateCategory),
49    /// More than one column maps to this category; an explicit override is required.
50    #[error(
51        "category {0:?} is ambiguous across columns {1:?}; set an explicit set_targets override"
52    )]
53    Ambiguous(StateCategory, Vec<String>),
54}
55
56impl StateMapping {
57    /// Translate a raw status value to a normalized category, falling back to
58    /// [`StateMapping::default`] when nothing matches.
59    ///
60    /// Matching prefers an exact key, then falls back to a case-insensitive
61    /// match — a board's status/column names are unique within one field, so a
62    /// case-only variant (e.g. config `"In Progress"` vs board `"In progress"`)
63    /// can never collide with a genuinely different column.
64    pub fn category_of(&self, raw_value: &str) -> StateCategory {
65        if let Some(cat) = self.by_value.get(raw_value) {
66            return *cat;
67        }
68        self.by_value
69            .iter()
70            .find(|(k, _)| k.eq_ignore_ascii_case(raw_value))
71            .map(|(_, cat)| *cat)
72            .unwrap_or(self.default)
73    }
74
75    /// Resolve a normalized `category` back to the board column name to write.
76    ///
77    /// `overrides` (category→column) win outright. Otherwise the column is the
78    /// unique `by_value` key whose category equals `category`. The `default`
79    /// category is a read-time fallback only and is never a write target.
80    pub fn column_for(
81        &self,
82        category: StateCategory,
83        overrides: &BTreeMap<StateCategory, String>,
84    ) -> Result<String, ReverseError> {
85        if let Some(col) = overrides.get(&category) {
86            return Ok(col.clone());
87        }
88        let mut matches: Vec<String> = self
89            .by_value
90            .iter()
91            .filter(|(_, c)| **c == category)
92            .map(|(k, _)| k.clone())
93            .collect();
94        matches.sort(); // deterministic order for the ambiguity message
95        match matches.len() {
96            0 => Err(ReverseError::Unmapped(category)),
97            1 => Ok(matches.swap_remove(0)),
98            _ => Err(ReverseError::Ambiguous(category, matches)),
99        }
100    }
101}
102
103/// Maps canonical ticket fields onto a provider's arbitrary field ids, for
104/// schemaless platforms (Monday / Airtable) where even title, assignee, and
105/// status are user-named columns. Unset entries fall back to the connector's
106/// built-in field knowledge.
107#[derive(Debug, Clone, Default, PartialEq, Eq)]
108pub struct FieldMapping {
109    pub title: Option<String>,
110    pub assignee: Option<String>,
111    pub priority: Option<String>,
112    /// Provider field id whose value carries status (paired with a
113    /// [`StateMapping`] whose signal is [`StateSignal::CustomField`]).
114    pub status: Option<String>,
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn gitlab_free_mapping() -> StateMapping {
122        // GitLab free: workflow encoded in `workflow::` scoped labels; the
123        // connector strips the prefix and hands us the suffix.
124        let mut by_value = BTreeMap::new();
125        by_value.insert("in review".into(), StateCategory::InProgress);
126        by_value.insert("development".into(), StateCategory::InProgress);
127        by_value.insert("blocked".into(), StateCategory::Pending);
128        StateMapping {
129            signal: StateSignal::ScopedLabel {
130                prefix: "workflow::".into(),
131            },
132            by_value,
133            default: StateCategory::Open,
134        }
135    }
136
137    #[test]
138    fn maps_known_raw_value_to_category() {
139        let m = gitlab_free_mapping();
140        assert_eq!(m.category_of("in review"), StateCategory::InProgress);
141        assert_eq!(m.category_of("blocked"), StateCategory::Pending);
142    }
143
144    #[test]
145    fn unmapped_raw_value_falls_back_to_default() {
146        let m = gitlab_free_mapping();
147        assert_eq!(m.category_of("something-bespoke"), StateCategory::Open);
148    }
149
150    #[test]
151    fn matches_case_insensitively_when_no_exact_key() {
152        // Config key "In Progress" should still resolve a board column reported
153        // as "In progress" (the real caliban-ai #1 casing). Exact match wins
154        // when present; case-only variants fall through to the case-insensitive
155        // pass rather than the default.
156        let mut by_value = BTreeMap::new();
157        by_value.insert("In Progress".into(), StateCategory::InProgress);
158        by_value.insert("Done".into(), StateCategory::Done);
159        let m = StateMapping {
160            signal: StateSignal::NativeStatus,
161            by_value,
162            default: StateCategory::Open,
163        };
164        assert_eq!(m.category_of("In Progress"), StateCategory::InProgress); // exact
165        assert_eq!(m.category_of("In progress"), StateCategory::InProgress); // case-insensitive
166        assert_eq!(m.category_of("DONE"), StateCategory::Done);
167        assert_eq!(m.category_of("Backlog"), StateCategory::Open); // truly unmapped → default
168    }
169
170    #[test]
171    fn asana_completed_signal_maps_both_booleans() {
172        // Asana: status is a `completed` bool; connector passes "true"/"false".
173        let mut by_value = BTreeMap::new();
174        by_value.insert("true".into(), StateCategory::Done);
175        by_value.insert("false".into(), StateCategory::Open);
176        let m = StateMapping {
177            signal: StateSignal::Completed,
178            by_value,
179            default: StateCategory::Open,
180        };
181        assert_eq!(m.category_of("true"), StateCategory::Done);
182        assert_eq!(m.category_of("false"), StateCategory::Open);
183    }
184
185    fn board_mapping() -> StateMapping {
186        // 1:1 columns like the caliban-ai board.
187        let mut by_value = BTreeMap::new();
188        by_value.insert("Backlog".into(), StateCategory::Backlog);
189        by_value.insert("In progress".into(), StateCategory::InProgress);
190        by_value.insert("Done".into(), StateCategory::Done);
191        StateMapping {
192            signal: StateSignal::NativeStatus,
193            by_value,
194            default: StateCategory::Open,
195        }
196    }
197
198    #[test]
199    fn column_for_inverts_unique_mapping() {
200        let m = board_mapping();
201        let none: BTreeMap<StateCategory, String> = BTreeMap::new();
202        assert_eq!(
203            m.column_for(StateCategory::InProgress, &none).unwrap(),
204            "In progress"
205        );
206        assert_eq!(m.column_for(StateCategory::Done, &none).unwrap(), "Done");
207    }
208
209    #[test]
210    fn column_for_unmapped_category_errors() {
211        let m = board_mapping();
212        let none: BTreeMap<StateCategory, String> = BTreeMap::new();
213        assert_eq!(
214            m.column_for(StateCategory::Pending, &none),
215            Err(ReverseError::Unmapped(StateCategory::Pending))
216        );
217
218        // An override short-circuits before the Unmapped path: a category with
219        // no by_value column can still resolve to a column via set_targets.
220        let mut overrides = BTreeMap::new();
221        overrides.insert(StateCategory::Pending, "Blocked".to_string());
222        assert_eq!(
223            m.column_for(StateCategory::Pending, &overrides).unwrap(),
224            "Blocked"
225        );
226    }
227
228    #[test]
229    fn column_for_override_wins_and_resolves_ambiguity() {
230        // Two columns share the Done category → ambiguous without an override.
231        let mut by_value = BTreeMap::new();
232        by_value.insert("Shipped".into(), StateCategory::Done);
233        by_value.insert("Done".into(), StateCategory::Done);
234        let m = StateMapping {
235            signal: StateSignal::NativeStatus,
236            by_value,
237            default: StateCategory::Open,
238        };
239
240        let none: BTreeMap<StateCategory, String> = BTreeMap::new();
241        match m.column_for(StateCategory::Done, &none) {
242            Err(ReverseError::Ambiguous(StateCategory::Done, cols)) => {
243                assert_eq!(cols, vec!["Done".to_string(), "Shipped".to_string()]);
244            }
245            other => panic!("expected Ambiguous, got {other:?}"),
246        }
247
248        let mut overrides = BTreeMap::new();
249        overrides.insert(StateCategory::Done, "Shipped".to_string());
250        assert_eq!(
251            m.column_for(StateCategory::Done, &overrides).unwrap(),
252            "Shipped"
253        );
254    }
255}