1use gonzalo_domain::StateCategory;
13use std::collections::BTreeMap;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum StateSignal {
18 IntrinsicState,
20 NativeStatus,
22 ScopedLabel { prefix: String },
24 Section,
26 CustomField { id: String },
28 Completed,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct StateMapping {
35 pub signal: StateSignal,
37 pub by_value: BTreeMap<String, StateCategory>,
39 pub default: StateCategory,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45pub enum ReverseError {
46 #[error("no column maps to category {0:?}")]
48 Unmapped(StateCategory),
49 #[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 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 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(); 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#[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 pub status: Option<String>,
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 fn gitlab_free_mapping() -> StateMapping {
122 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 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); assert_eq!(m.category_of("In progress"), StateCategory::InProgress); assert_eq!(m.category_of("DONE"), StateCategory::Done);
167 assert_eq!(m.category_of("Backlog"), StateCategory::Open); }
169
170 #[test]
171 fn asana_completed_signal_maps_both_booleans() {
172 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 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 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 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}