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/// Origin issue for a bounce or discovery.
46pub const DISCOVERED_FROM: &str = "DISCOVERED_FROM";
47/// Successor issue for a bounce.
48pub const PIVOTED_TO: &str = "PIVOTED_TO";
49/// The terminal that lost a sibling race.
50pub const SIBLING_TERMINAL: &str = "SIBLING_TERMINAL";
51
52/// Drawer keys vissue writes, in house order after `ID`.
53pub 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
69/// `(legacy, canonical)` pairs a rewrite folds.
70pub 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
78/// Read `canonical`, then any legacy alias.
79pub 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
96/// Write `canonical` and drop every alias of that key.
97pub 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
106/// Remove `canonical` and every alias of it.
107pub 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
116/// Fold aliases and merge a bare `:BLOCKER:` id list into `:BLOCKED_BY:`.
117///
118/// A real org-edna condition (`prev-sibling`, `ids(...)`, `headings`,
119/// ...) stays on `:BLOCKER:`. vissue never writes that key.
120///
121/// Returns how many keys moved or merged.
122pub 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
174/// Canonicalize keys, then move legal type/tags onto the heading.
175pub 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
188/// Blocker ids from `:BLOCKED_BY:`, aliases, and org-edna `ids(...)`.
189pub 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}