1use std::collections::BTreeMap;
26
27use super::model::WorkflowState;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Mapped {
32 pub status: &'static str,
33 pub archive: bool,
37}
38
39pub fn normalize(name: &str) -> String {
42 let mut out = String::with_capacity(name.len());
43 let mut pending_sep = false;
44 for ch in name.chars() {
45 if ch.is_alphanumeric() {
46 if pending_sep && !out.is_empty() {
47 out.push('-');
48 }
49 pending_sep = false;
50 out.extend(ch.to_lowercase());
51 } else {
52 pending_sep = true;
53 }
54 }
55 out
56}
57
58pub fn to_cliban(state: &WorkflowState) -> Mapped {
60 let normalized = normalize(&state.name);
64 if cliban_core::schema::ISSUE_STATUSES.contains(&normalized.as_str()) {
65 return Mapped {
66 status: canonical(&normalized),
67 archive: false,
68 };
69 }
70 if let Some(status) = alias(&normalized) {
72 return Mapped {
73 status,
74 archive: false,
75 };
76 }
77 match state.kind.as_str() {
78 "started" => Mapped {
79 status: "in-progress",
80 archive: false,
81 },
82 "completed" => Mapped {
83 status: "done",
84 archive: false,
85 },
86 "canceled" | "cancelled" => Mapped {
87 status: "done",
88 archive: true,
89 },
90 _ => Mapped {
92 status: "backlog",
93 archive: false,
94 },
95 }
96}
97
98pub fn to_linear<'a>(
106 status: &str,
107 states: &'a [WorkflowState],
108 overrides: &BTreeMap<String, String>,
109) -> Option<&'a WorkflowState> {
110 if let Some(want) = overrides.get(status) {
111 let want_norm = normalize(want);
112 if let Some(found) = states.iter().find(|s| normalize(&s.name) == want_norm) {
113 return Some(found);
114 }
115 }
116
117 if let Some(found) = states.iter().find(|s| normalize(&s.name) == status) {
119 return Some(found);
120 }
121 if let Some(found) = states
122 .iter()
123 .find(|s| alias(&normalize(&s.name)) == Some(status))
124 {
125 return Some(found);
126 }
127
128 let wanted_kinds: &[&str] = match status {
131 "backlog" => &["backlog", "triage", "unstarted"],
132 "in-progress" | "blocked" | "in-review" => &["started"],
133 "done" => &["completed"],
134 _ => &[],
135 };
136 for kind in wanted_kinds {
137 let mut candidates: Vec<&WorkflowState> =
138 states.iter().filter(|s| s.kind == *kind).collect();
139 candidates.sort_by(|a, b| {
140 a.position
141 .partial_cmp(&b.position)
142 .unwrap_or(std::cmp::Ordering::Equal)
143 });
144 if let Some(first) = candidates.first() {
145 return Some(first);
146 }
147 }
148 None
149}
150
151fn canonical(normalized: &str) -> &'static str {
154 cliban_core::schema::ISSUE_STATUSES
155 .iter()
156 .copied()
157 .find(|s| *s == normalized)
158 .unwrap_or("backlog")
159}
160
161fn alias(normalized: &str) -> Option<&'static str> {
163 match normalized {
164 "todo" | "to-do" | "triage" | "unstarted" | "planned" => Some("backlog"),
165 "doing" | "started" | "wip" | "in-development" | "in-dev" => Some("in-progress"),
166 "review" | "code-review" | "in-code-review" | "reviewing" => Some("in-review"),
167 "on-hold" | "paused" | "stalled" | "waiting" => Some("blocked"),
168 "complete" | "completed" | "shipped" | "merged" | "closed" => Some("done"),
169 _ => None,
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn state(name: &str, kind: &str, position: f64) -> WorkflowState {
178 WorkflowState {
179 id: format!("id-{}", normalize(name)),
180 name: name.into(),
181 kind: kind.into(),
182 position,
183 }
184 }
185
186 fn team_states() -> Vec<WorkflowState> {
187 vec![
188 state("Triage", "triage", 0.0),
189 state("Backlog", "backlog", 1.0),
190 state("Todo", "unstarted", 2.0),
191 state("In Progress", "started", 3.0),
192 state("In Review", "started", 4.0),
193 state("Done", "completed", 5.0),
194 state("Canceled", "canceled", 6.0),
195 ]
196 }
197
198 #[test]
199 fn normalize_collapses_case_and_punctuation() {
200 assert_eq!(normalize("In Progress"), "in-progress");
201 assert_eq!(normalize("in-progress"), "in-progress");
202 assert_eq!(normalize("In Review!"), "in-review");
203 assert_eq!(normalize(" Done "), "done");
204 assert_eq!(normalize(""), "");
205 }
206
207 #[test]
208 fn name_beats_type_for_in_review() {
209 let mapped = to_cliban(&state("In Review", "started", 4.0));
212 assert_eq!(mapped.status, "in-review");
213 assert!(!mapped.archive);
214 }
215
216 #[test]
217 fn name_beats_type_for_blocked() {
218 let mapped = to_cliban(&state("Blocked", "started", 4.0));
219 assert_eq!(mapped.status, "blocked");
220 }
221
222 #[test]
223 fn type_carries_states_with_unrecognized_names() {
224 assert_eq!(
225 to_cliban(&state("Cooking", "started", 1.0)).status,
226 "in-progress"
227 );
228 assert_eq!(
229 to_cliban(&state("Icebox", "backlog", 1.0)).status,
230 "backlog"
231 );
232 assert_eq!(
233 to_cliban(&state("Shipped 🚀", "completed", 1.0)).status,
234 "done"
235 );
236 }
237
238 #[test]
239 fn canceled_becomes_done_and_archived() {
240 let mapped = to_cliban(&state("Canceled", "canceled", 6.0));
241 assert_eq!(mapped.status, "done");
242 assert!(mapped.archive, "cancelled work should leave the board");
243 }
244
245 #[test]
246 fn an_unknown_type_degrades_to_backlog() {
247 assert_eq!(
249 to_cliban(&state("Whatever", "quantum", 1.0)).status,
250 "backlog"
251 );
252 }
253
254 #[test]
255 fn to_linear_prefers_the_matching_name() {
256 let states = team_states();
257 let empty = BTreeMap::new();
258 let s = to_linear("in-review", &states, &empty).unwrap();
259 assert_eq!(s.name, "In Review");
260 }
261
262 #[test]
263 fn to_linear_falls_back_to_type_and_picks_the_leftmost() {
264 let states = vec![
266 state("Icebox", "backlog", 5.0),
267 state("Someday", "backlog", 2.0),
268 state("Doing", "started", 9.0),
269 ];
270 let s = to_linear("backlog", &states, &BTreeMap::new()).unwrap();
271 assert_eq!(s.name, "Someday", "position, not declaration order");
272 }
273
274 #[test]
275 fn blocked_without_a_column_lands_in_a_started_state() {
276 let states = vec![
279 state("In Progress", "started", 1.0),
280 state("Done", "completed", 2.0),
281 ];
282 let s = to_linear("blocked", &states, &BTreeMap::new()).unwrap();
283 assert_eq!(s.name, "In Progress");
284 }
285
286 #[test]
287 fn config_override_wins_over_inference() {
288 let states = team_states();
289 let mut overrides = BTreeMap::new();
290 overrides.insert("in-review".to_string(), "Done".to_string());
291 let s = to_linear("in-review", &states, &overrides).unwrap();
292 assert_eq!(s.name, "Done");
293 }
294
295 #[test]
296 fn a_stale_override_falls_back_rather_than_failing() {
297 let states = team_states();
298 let mut overrides = BTreeMap::new();
299 overrides.insert("in-review".to_string(), "Column We Deleted".to_string());
300 let s = to_linear("in-review", &states, &overrides).unwrap();
301 assert_eq!(s.name, "In Review", "inference still applies");
302 }
303
304 #[test]
305 fn to_linear_returns_none_when_nothing_fits() {
306 let states = vec![state("Done", "completed", 1.0)];
307 assert!(to_linear("backlog", &states, &BTreeMap::new()).is_none());
308 }
309
310 #[test]
311 fn every_cliban_status_resolves_against_a_conventional_team() {
312 let states = team_states();
313 for status in cliban_core::schema::ISSUE_STATUSES {
314 assert!(
315 to_linear(status, &states, &BTreeMap::new()).is_some(),
316 "{status} did not map"
317 );
318 }
319 }
320
321 #[test]
322 fn round_trip_is_stable_for_the_statuses_linear_can_express() {
323 let states = team_states();
324 for status in ["backlog", "in-progress", "in-review", "done"] {
325 let linear = to_linear(status, &states, &BTreeMap::new()).unwrap();
326 assert_eq!(to_cliban(linear).status, status, "{status} did not survive");
327 }
328 }
329}