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