Skip to main content

vissue_core/
props.rs

1//! Property names as Org and ELPA already spell them.
2//!
3//! vissue does not mint a parallel namespace. It writes the key the other
4//! tool reads, and it reads the aliases a hand-edited tracker already has.
5//! [`canonicalize`] is what a rewrite and `normalize` run.
6
7use 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
13/// Org-id. Shared with Emacs.
14pub const ID: &str = "ID";
15/// Capture / org-expiry stamp. Shared meaning, shared name.
16pub const CREATED: &str = "CREATED";
17/// Org effort estimate. `org-effort-property` defaults to this spelling.
18pub const EFFORT: &str = "Effort";
19/// Per-heading agenda category. File-level `#+CATEGORY:` is the default.
20pub const CATEGORY: &str = "CATEGORY";
21/// org-id export alias.
22pub const CUSTOM_ID: &str = "CUSTOM_ID";
23
24/// GNU ELPA org-edna condition. Also org-depend in org-contrib.
25pub const EDNA_BLOCKER: &str = "BLOCKER";
26/// GNU ELPA org-edna / org-gtd action.
27pub const EDNA_TRIGGER: &str = "TRIGGER";
28
29/// Parent in the vissue tree.
30pub const PARENT: &str = "PARENT";
31/// Partial-order blockers. Read-compatible with org-edna `:BLOCKER:`.
32pub const BLOCKED_BY: &str = "BLOCKED_BY";
33/// Tracker type (`bug`, `feature`, …). Also a heading tag when legal.
34pub const TYPE: &str = "TYPE";
35/// Tags Org's character class rejects. Not `TAGS`: Org reserves that name.
36pub const TAGS: &str = "VISSUE_TAGS";
37/// Identity holding a STARTED issue.
38pub const CLAIMED_BY: &str = "CLAIMED_BY";
39/// When that claim was taken.
40pub const CLAIMED_AT: &str = "CLAIMED_AT";
41/// Paths this issue touches.
42pub const FILES: &str = "FILES";
43/// How to know the issue is done.
44pub const VERIFY: &str = "VERIFY";
45/// Deed accessions this issue's work produced, as deedar mints them.
46pub const DEEDS: &str = "DEEDS";
47/// Origin issue for a bounce or discovery.
48pub const DISCOVERED_FROM: &str = "DISCOVERED_FROM";
49/// Successor issue for a bounce.
50pub const PIVOTED_TO: &str = "PIVOTED_TO";
51/// The terminal that lost a sibling race.
52pub const SIBLING_TERMINAL: &str = "SIBLING_TERMINAL";
53
54/// Drawer keys vissue writes, in house order after `ID`.
55pub 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
72/// `(legacy, canonical)` pairs a rewrite folds.
73pub 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
81/// Read `canonical`, then any legacy alias.
82pub 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
99/// Write `canonical` and drop every alias of that key.
100pub 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
109/// Remove `canonical` and every alias of it.
110pub 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
119/// Fold aliases and merge a bare `:BLOCKER:` id list into `:BLOCKED_BY:`.
120///
121/// A real org-edna condition (`prev-sibling`, `ids(...)`, `headings`,
122/// ...) stays on `:BLOCKER:`. vissue never writes that key.
123///
124/// Returns how many keys moved or merged.
125pub 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
177/// Canonicalize keys, then move legal type/tags onto the heading.
178pub 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
191/// Blocker ids from `:BLOCKED_BY:`, aliases, and org-edna `ids(...)`.
192pub 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}