1use std::collections::BTreeMap;
8
9use crate::org::{
10 is_edna_blocker, is_org_tag_char, settle_heading_classifiers as settle_tags, split_id_list,
11};
12
13pub const ID: &str = "ID";
15pub const CREATED: &str = "CREATED";
17pub const EFFORT: &str = "Effort";
19pub const CATEGORY: &str = "CATEGORY";
21pub const CUSTOM_ID: &str = "CUSTOM_ID";
23
24pub const EDNA_BLOCKER: &str = "BLOCKER";
26pub const EDNA_TRIGGER: &str = "TRIGGER";
28
29pub const PARENT: &str = "PARENT";
31pub const BLOCKED_BY: &str = "BLOCKED_BY";
33pub const TYPE: &str = "TYPE";
35pub const TAGS: &str = "VISSUE_TAGS";
37pub const CLAIMED_BY: &str = "CLAIMED_BY";
39pub const CLAIMED_AT: &str = "CLAIMED_AT";
41pub const FILES: &str = "FILES";
43pub const VERIFY: &str = "VERIFY";
45pub const DEEDS: &str = "DEEDS";
47pub const DISCOVERED_FROM: &str = "DISCOVERED_FROM";
49pub const PIVOTED_TO: &str = "PIVOTED_TO";
51pub const SIBLING_TERMINAL: &str = "SIBLING_TERMINAL";
53
54pub const CANONICAL_ORDER: &[&str] = &[
56 CREATED,
57 TYPE,
58 PARENT,
59 BLOCKED_BY,
60 TAGS,
61 CLAIMED_BY,
62 CLAIMED_AT,
63 FILES,
64 VERIFY,
65 DEEDS,
66 DISCOVERED_FROM,
67 PIVOTED_TO,
68 SIBLING_TERMINAL,
69 EFFORT,
70];
71
72pub const ALIASES: &[(&str, &str)] = &[
74 ("BLOCKEDBY", BLOCKED_BY),
75 ("TAGS", TAGS),
76 ("DISCOVERED", DISCOVERED_FROM),
77 ("SUPERSEDED_BY", PIVOTED_TO),
78 ("EFFORT", EFFORT),
79];
80
81pub fn get<'a>(properties: &'a BTreeMap<String, String>, canonical: &str) -> Option<&'a str> {
83 if let Some(value) = properties.get(canonical)
84 && !value.trim().is_empty()
85 {
86 return Some(value.as_str());
87 }
88 for (alias, dest) in ALIASES {
89 if *dest == canonical
90 && let Some(value) = properties.get(*alias)
91 && !value.trim().is_empty()
92 {
93 return Some(value.as_str());
94 }
95 }
96 None
97}
98
99pub fn insert(properties: &mut BTreeMap<String, String>, canonical: &str, value: String) {
101 for (alias, dest) in ALIASES {
102 if *dest == canonical {
103 properties.remove(*alias);
104 }
105 }
106 properties.insert(canonical.to_string(), value);
107}
108
109pub fn remove(properties: &mut BTreeMap<String, String>, canonical: &str) {
111 properties.remove(canonical);
112 for (alias, dest) in ALIASES {
113 if *dest == canonical {
114 properties.remove(*alias);
115 }
116 }
117}
118
119pub fn canonicalize(properties: &mut BTreeMap<String, String>) -> usize {
126 let mut moved = 0usize;
127 for (alias, dest) in ALIASES {
128 let Some(value) = properties.remove(*alias) else {
129 continue;
130 };
131 moved += 1;
132 if *dest == BLOCKED_BY || *dest == DISCOVERED_FROM || *dest == PIVOTED_TO {
133 merge_id_valued(properties, dest, &value);
134 } else if *dest == TAGS {
135 let existing = properties.remove(TAGS).unwrap_or_default();
136 let mut parts: Vec<String> = Vec::new();
137 for item in existing.split([',', ':']).chain(value.split([',', ':'])) {
138 let item = item.trim();
139 if !item.is_empty() && !parts.iter().any(|seen| seen == item) {
140 parts.push(item.to_string());
141 }
142 }
143 if !parts.is_empty() {
144 properties.insert(TAGS.to_string(), parts.join(","));
145 }
146 } else {
147 properties.entry((*dest).to_string()).or_insert(value);
148 }
149 }
150 if let Some(raw) = properties.get(EDNA_BLOCKER).cloned()
151 && !is_edna_blocker(&raw)
152 {
153 merge_id_valued(properties, BLOCKED_BY, &raw);
154 properties.remove(EDNA_BLOCKER);
155 moved += 1;
156 }
157 moved
158}
159
160fn merge_id_valued(properties: &mut BTreeMap<String, String>, dest: &str, extra: &str) {
161 let mut ids = properties
162 .get(dest)
163 .map(|s| split_id_list(s))
164 .unwrap_or_default();
165 for id in split_id_list(extra) {
166 if !ids.iter().any(|seen| seen == &id) {
167 ids.push(id);
168 }
169 }
170 if ids.is_empty() {
171 properties.remove(dest);
172 } else {
173 properties.insert(dest.to_string(), ids.join(" "));
174 }
175}
176
177pub fn settle(org_tags: &mut Vec<String>, properties: &mut BTreeMap<String, String>) -> usize {
179 let moved = canonicalize(properties);
180 if let Some(kind) = get(properties, TYPE).map(str::to_string)
181 && kind.chars().all(is_org_tag_char)
182 && !kind.is_empty()
183 && !org_tags.iter().any(|seen| seen == &kind)
184 {
185 org_tags.push(kind);
186 }
187 settle_tags(org_tags, properties);
188 moved
189}
190
191pub fn blocker_ids(properties: &BTreeMap<String, String>) -> Vec<String> {
193 crate::org::blocker_ids_from_properties(properties)
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn canonicalize_folds_typos_and_leaves_edna() {
202 let mut props = BTreeMap::new();
203 props.insert("BLOCKEDBY".into(), "a-1".into());
204 props.insert(EDNA_BLOCKER.into(), "a-2".into());
205 props.insert("TYPE".into(), "bug".into());
206 let moved = canonicalize(&mut props);
207 assert!(moved >= 1, "{props:?}");
208 assert_eq!(get(&props, BLOCKED_BY), Some("a-1 a-2"));
209 assert!(!props.contains_key(EDNA_BLOCKER), "{props:?}");
210 assert_eq!(get(&props, TYPE), Some("bug"));
211 assert!(props.contains_key("TYPE"));
212
213 let mut edna = BTreeMap::new();
214 edna.insert(BLOCKED_BY.into(), "a-1".into());
215 edna.insert(EDNA_BLOCKER.into(), "prev-sibling".into());
216 canonicalize(&mut edna);
217 assert_eq!(
218 edna.get(EDNA_BLOCKER).map(String::as_str),
219 Some("prev-sibling")
220 );
221 assert_eq!(get(&edna, BLOCKED_BY), Some("a-1"));
222
223 let mut minted = BTreeMap::new();
224 minted.insert(BLOCKED_BY.into(), "a-1".into());
225 minted.insert(EDNA_BLOCKER.into(), "ids(a-1)".into());
226 canonicalize(&mut minted);
227 assert_eq!(
228 minted.get(EDNA_BLOCKER).map(String::as_str),
229 Some("ids(a-1)")
230 );
231 assert_eq!(get(&minted, BLOCKED_BY), Some("a-1"));
232 }
233
234 #[test]
235 fn get_reads_legacy_then_canonical() {
236 let mut props = BTreeMap::new();
237 props.insert("BLOCKEDBY".into(), "old".into());
238 assert_eq!(get(&props, BLOCKED_BY), Some("old"));
239 insert(&mut props, BLOCKED_BY, "new".into());
240 assert_eq!(get(&props, BLOCKED_BY), Some("new"));
241 assert!(!props.contains_key("BLOCKEDBY"));
242 assert!(
243 !props.contains_key(EDNA_BLOCKER),
244 "writing BLOCKED_BY must not mint BLOCKER: {props:?}"
245 );
246 }
247}