Skip to main content

opys_engine/
refs.rs

1//! The uniform `references` field: an ID->title map linking a feature or work
2//! item to other features and work items.
3//!
4//! Both document families share one `references` mapping (keys are
5//! `FEAT-NNNN` / `<TYPE>-NNNN`, values are the referenced doc's title). Prefixes
6//! are self-describing, so a single field captures links in either direction.
7//! Entries are always serialized sorted by item number. A *closed* work item
8//! leaves a tombstone: its title value is struck through (`~~title~~`), which
9//! both marks it done and reserves its ID against reuse.
10
11use serde_norway::{Mapping, Value};
12
13use crate::frontmatter::Frontmatter;
14
15/// The reserved frontmatter key holding the ID->title reference map.
16pub const FIELD: &str = "references";
17
18/// The directional blocker-relation map fields: `blocked_by` lists the items
19/// blocking this one, `blocks` lists the items this one blocks. Maintained as
20/// inverses of each other by [`crate::links::reconcile_blockers`].
21pub const BLOCKED_BY: &str = "blocked_by";
22pub const BLOCKS: &str = "blocks";
23
24/// Every ID->title relation map field, in serialization order. Each behaves
25/// identically for resolution, tombstones, and ID reservation; only
26/// reconciliation differs (`references` is symmetric, `blocked_by`/`blocks`
27/// are inverses).
28pub const RELATION_FIELDS: [&str; 3] = [FIELD, BLOCKED_BY, BLOCKS];
29
30/// Numeric part of a `PREFIX-NNNN` id, for deterministic ordering. Ids that do
31/// not parse sort last.
32pub fn id_number(id: &str) -> u64 {
33    id.rsplit_once('-')
34        .and_then(|(_, n)| n.parse().ok())
35        .unwrap_or(u64::MAX)
36}
37
38/// Whether a reference value is a struck-through (closed) tombstone.
39pub fn is_struck(value: &str) -> bool {
40    let t = value.trim();
41    t.len() >= 4 && t.starts_with("~~") && t.ends_with("~~")
42}
43
44/// Wrap a title as a struck-through tombstone value.
45pub fn strike(title: &str) -> String {
46    format!("~~{}~~", title.trim())
47}
48
49/// The underlying title of a reference value, with any strikethrough removed.
50pub fn unstrike(value: &str) -> &str {
51    let t = value.trim();
52    if is_struck(t) {
53        t[2..t.len() - 2].trim()
54    } else {
55        t
56    }
57}
58
59/// Read the `references` map as `(id, raw_value)` pairs sorted by item number.
60/// `raw_value` retains any strikethrough so callers can distinguish a closed
61/// tombstone from a live link.
62pub fn parse(fm: &Frontmatter) -> Vec<(String, String)> {
63    parse_in(fm, FIELD)
64}
65
66/// Replace the `references` map, sorted by item number. An empty list removes
67/// the field entirely (keeping frontmatter minimal).
68pub fn set(fm: &mut Frontmatter, refs: &[(String, String)]) {
69    set_in(fm, FIELD, refs)
70}
71
72/// Read an arbitrary ID->title relation map (`references`, `blocked_by`,
73/// `blocks`) as `(id, raw_value)` pairs sorted by item number.
74pub fn parse_in(fm: &Frontmatter, field: &str) -> Vec<(String, String)> {
75    let Some(Value::Mapping(m)) = fm.get(field) else {
76        return Vec::new();
77    };
78    let mut out: Vec<(String, String)> = m
79        .iter()
80        .filter_map(|(k, v)| {
81            Some((
82                k.as_str()?.to_string(),
83                v.as_str().unwrap_or("").to_string(),
84            ))
85        })
86        .collect();
87    out.sort_by_key(|e| id_number(&e.0));
88    out
89}
90
91/// Replace an arbitrary relation map, sorted by item number. An empty list
92/// removes the field entirely (keeping frontmatter minimal).
93pub fn set_in(fm: &mut Frontmatter, field: &str, refs: &[(String, String)]) {
94    if refs.is_empty() {
95        fm.remove(field);
96        return;
97    }
98    let mut sorted = refs.to_vec();
99    sorted.sort_by_key(|e| id_number(&e.0));
100    let mut m = Mapping::new();
101    for (id, title) in sorted {
102        m.insert(Value::String(id), Value::String(title));
103    }
104    fm.insert(field, Value::Mapping(m));
105}
106
107/// Insert or update `id`'s entry in a relation map. Returns whether the map
108/// changed.
109pub fn add_to_map(fm: &mut Frontmatter, field: &str, id: &str, title: &str) -> bool {
110    let mut entries = parse_in(fm, field);
111    if let Some(e) = entries.iter_mut().find(|(i, _)| i == id) {
112        if e.1 == title {
113            return false;
114        }
115        e.1 = title.to_string();
116    } else {
117        entries.push((id.to_string(), title.to_string()));
118    }
119    set_in(fm, field, &entries);
120    true
121}
122
123/// Remove `id`'s entry from a relation map. Returns whether anything was removed.
124pub fn remove_from_map(fm: &mut Frontmatter, field: &str, id: &str) -> bool {
125    let entries = parse_in(fm, field);
126    let kept: Vec<_> = entries.iter().filter(|(i, _)| i != id).cloned().collect();
127    if kept.len() == entries.len() {
128        return false;
129    }
130    set_in(fm, field, &kept);
131    true
132}
133
134/// Ids in the `references` map carrying the given prefix (e.g. `FEAT` or `BUG`).
135pub fn ids_with_prefix(fm: &Frontmatter, prefix: &str) -> Vec<String> {
136    let needle = format!("{prefix}-");
137    parse(fm)
138        .into_iter()
139        .map(|(id, _)| id)
140        .filter(|id| id.starts_with(&needle))
141        .collect()
142}
143
144/// Every id appearing in any relation map (`references`, `blocked_by`,
145/// `blocks`), regardless of prefix — used to compute the global ID sequence so
146/// a closed item appearing in any map keeps its number reserved against reuse.
147pub fn all_relation_ids(fm: &Frontmatter) -> Vec<String> {
148    RELATION_FIELDS
149        .iter()
150        .flat_map(|field| parse_in(fm, field))
151        .map(|(id, _)| id)
152        .collect()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn strike_round_trips() {
161        assert!(is_struck("~~done~~"));
162        assert!(!is_struck("live"));
163        assert_eq!(unstrike("~~done~~"), "done");
164        assert_eq!(unstrike("live"), "live");
165        assert_eq!(strike("Wire login"), "~~Wire login~~");
166    }
167
168    #[test]
169    fn set_sorts_by_number_and_round_trips() {
170        let mut fm = Frontmatter::new();
171        set(
172            &mut fm,
173            &[
174                ("WI-0010".into(), "Ten".into()),
175                ("FEAT-0002".into(), "Two".into()),
176            ],
177        );
178        let parsed = parse(&fm);
179        assert_eq!(parsed[0].0, "FEAT-0002");
180        assert_eq!(parsed[1].0, "WI-0010");
181    }
182
183    #[test]
184    fn empty_set_removes_field() {
185        let mut fm = Frontmatter::new();
186        set(&mut fm, &[("WI-0001".into(), "x".into())]);
187        assert!(fm.contains_key(FIELD));
188        set(&mut fm, &[]);
189        assert!(!fm.contains_key(FIELD));
190    }
191}