use std::collections::BTreeMap;
use crate::org::{
is_edna_blocker, is_org_tag_char, settle_heading_classifiers as settle_tags, split_id_list,
};
pub const ID: &str = "ID";
pub const CREATED: &str = "CREATED";
pub const EFFORT: &str = "Effort";
pub const CATEGORY: &str = "CATEGORY";
pub const CUSTOM_ID: &str = "CUSTOM_ID";
pub const EDNA_BLOCKER: &str = "BLOCKER";
pub const EDNA_TRIGGER: &str = "TRIGGER";
pub const PARENT: &str = "PARENT";
pub const BLOCKED_BY: &str = "BLOCKED_BY";
pub const TYPE: &str = "TYPE";
pub const TAGS: &str = "VISSUE_TAGS";
pub const CLAIMED_BY: &str = "CLAIMED_BY";
pub const CLAIMED_AT: &str = "CLAIMED_AT";
pub const FILES: &str = "FILES";
pub const VERIFY: &str = "VERIFY";
pub const DISCOVERED_FROM: &str = "DISCOVERED_FROM";
pub const PIVOTED_TO: &str = "PIVOTED_TO";
pub const SIBLING_TERMINAL: &str = "SIBLING_TERMINAL";
pub const CANONICAL_ORDER: &[&str] = &[
CREATED,
TYPE,
PARENT,
BLOCKED_BY,
TAGS,
CLAIMED_BY,
CLAIMED_AT,
FILES,
VERIFY,
DISCOVERED_FROM,
PIVOTED_TO,
SIBLING_TERMINAL,
EFFORT,
];
pub const ALIASES: &[(&str, &str)] = &[
("BLOCKEDBY", BLOCKED_BY),
("TAGS", TAGS),
("DISCOVERED", DISCOVERED_FROM),
("SUPERSEDED_BY", PIVOTED_TO),
("EFFORT", EFFORT),
];
pub fn get<'a>(properties: &'a BTreeMap<String, String>, canonical: &str) -> Option<&'a str> {
if let Some(value) = properties.get(canonical)
&& !value.trim().is_empty()
{
return Some(value.as_str());
}
for (alias, dest) in ALIASES {
if *dest == canonical
&& let Some(value) = properties.get(*alias)
&& !value.trim().is_empty()
{
return Some(value.as_str());
}
}
None
}
pub fn insert(properties: &mut BTreeMap<String, String>, canonical: &str, value: String) {
for (alias, dest) in ALIASES {
if *dest == canonical {
properties.remove(*alias);
}
}
properties.insert(canonical.to_string(), value);
}
pub fn remove(properties: &mut BTreeMap<String, String>, canonical: &str) {
properties.remove(canonical);
for (alias, dest) in ALIASES {
if *dest == canonical {
properties.remove(*alias);
}
}
}
pub fn canonicalize(properties: &mut BTreeMap<String, String>) -> usize {
let mut moved = 0usize;
for (alias, dest) in ALIASES {
let Some(value) = properties.remove(*alias) else {
continue;
};
moved += 1;
if *dest == BLOCKED_BY || *dest == DISCOVERED_FROM || *dest == PIVOTED_TO {
merge_id_valued(properties, dest, &value);
} else if *dest == TAGS {
let existing = properties.remove(TAGS).unwrap_or_default();
let mut parts: Vec<String> = Vec::new();
for item in existing.split([',', ':']).chain(value.split([',', ':'])) {
let item = item.trim();
if !item.is_empty() && !parts.iter().any(|seen| seen == item) {
parts.push(item.to_string());
}
}
if !parts.is_empty() {
properties.insert(TAGS.to_string(), parts.join(","));
}
} else {
properties.entry((*dest).to_string()).or_insert(value);
}
}
if let Some(raw) = properties.get(EDNA_BLOCKER).cloned()
&& !is_edna_blocker(&raw)
{
merge_id_valued(properties, BLOCKED_BY, &raw);
properties.remove(EDNA_BLOCKER);
moved += 1;
}
moved
}
fn merge_id_valued(properties: &mut BTreeMap<String, String>, dest: &str, extra: &str) {
let mut ids = properties
.get(dest)
.map(|s| split_id_list(s))
.unwrap_or_default();
for id in split_id_list(extra) {
if !ids.iter().any(|seen| seen == &id) {
ids.push(id);
}
}
if ids.is_empty() {
properties.remove(dest);
} else {
properties.insert(dest.to_string(), ids.join(" "));
}
}
pub fn settle(org_tags: &mut Vec<String>, properties: &mut BTreeMap<String, String>) -> usize {
let moved = canonicalize(properties);
if let Some(kind) = get(properties, TYPE).map(str::to_string)
&& kind.chars().all(is_org_tag_char)
&& !kind.is_empty()
&& !org_tags.iter().any(|seen| seen == &kind)
{
org_tags.push(kind);
}
settle_tags(org_tags, properties);
moved
}
pub fn blocker_ids(properties: &BTreeMap<String, String>) -> Vec<String> {
crate::org::blocker_ids_from_properties(properties)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonicalize_folds_typos_and_leaves_edna() {
let mut props = BTreeMap::new();
props.insert("BLOCKEDBY".into(), "a-1".into());
props.insert(EDNA_BLOCKER.into(), "a-2".into());
props.insert("TYPE".into(), "bug".into());
let moved = canonicalize(&mut props);
assert!(moved >= 1, "{props:?}");
assert_eq!(get(&props, BLOCKED_BY), Some("a-1 a-2"));
assert!(!props.contains_key(EDNA_BLOCKER), "{props:?}");
assert_eq!(get(&props, TYPE), Some("bug"));
assert!(props.contains_key("TYPE"));
let mut edna = BTreeMap::new();
edna.insert(BLOCKED_BY.into(), "a-1".into());
edna.insert(EDNA_BLOCKER.into(), "prev-sibling".into());
canonicalize(&mut edna);
assert_eq!(
edna.get(EDNA_BLOCKER).map(String::as_str),
Some("prev-sibling")
);
assert_eq!(get(&edna, BLOCKED_BY), Some("a-1"));
let mut minted = BTreeMap::new();
minted.insert(BLOCKED_BY.into(), "a-1".into());
minted.insert(EDNA_BLOCKER.into(), "ids(a-1)".into());
canonicalize(&mut minted);
assert_eq!(
minted.get(EDNA_BLOCKER).map(String::as_str),
Some("ids(a-1)")
);
assert_eq!(get(&minted, BLOCKED_BY), Some("a-1"));
}
#[test]
fn get_reads_legacy_then_canonical() {
let mut props = BTreeMap::new();
props.insert("BLOCKEDBY".into(), "old".into());
assert_eq!(get(&props, BLOCKED_BY), Some("old"));
insert(&mut props, BLOCKED_BY, "new".into());
assert_eq!(get(&props, BLOCKED_BY), Some("new"));
assert!(!props.contains_key("BLOCKEDBY"));
assert!(
!props.contains_key(EDNA_BLOCKER),
"writing BLOCKED_BY must not mint BLOCKER: {props:?}"
);
}
}