1use serde_norway::{Mapping, Value};
12
13use crate::frontmatter::Frontmatter;
14
15pub const FIELD: &str = "references";
17
18pub const BLOCKED_BY: &str = "blocked_by";
22pub const BLOCKS: &str = "blocks";
23
24pub const RELATION_FIELDS: [&str; 3] = [FIELD, BLOCKED_BY, BLOCKS];
29
30pub fn id_number(id: &str) -> u64 {
33 id.rsplit_once('-')
34 .and_then(|(_, n)| n.parse().ok())
35 .unwrap_or(u64::MAX)
36}
37
38pub fn is_struck(value: &str) -> bool {
40 let t = value.trim();
41 t.len() >= 4 && t.starts_with("~~") && t.ends_with("~~")
42}
43
44pub fn strike(title: &str) -> String {
46 format!("~~{}~~", title.trim())
47}
48
49pub 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
59pub fn parse(fm: &Frontmatter) -> Vec<(String, String)> {
63 parse_in(fm, FIELD)
64}
65
66pub fn set(fm: &mut Frontmatter, refs: &[(String, String)]) {
69 set_in(fm, FIELD, refs)
70}
71
72pub 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
91pub 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
107pub 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
123pub 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
134pub 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
144pub 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}