Skip to main content

opys_engine/
links.rs

1//! Cross-reference auto-sync: keep every doc's `references` map bidirectional
2//! and title-fresh, and rewrite bare `FEAT-XXXX`/`WI-XXXX` mentions in body
3//! prose into readable markdown links. Run by `sync` after every mutating
4//! command.
5
6use std::collections::{BTreeSet, HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use regex::{Captures, Regex};
10
11use crate::doc::Doc;
12use crate::refs;
13
14/// Build the bare-ID matcher whose prefix alternation comes from the live
15/// configured type prefixes, so new types are linkified automatically.
16pub fn ref_re(prefixes: &[String]) -> Regex {
17    let alt = prefixes
18        .iter()
19        .map(|p| regex::escape(p))
20        .collect::<Vec<_>>()
21        .join("|");
22    Regex::new(&format!(r"\b(?:{alt})-[0-9]+\b")).unwrap()
23}
24
25/// Reconcile every doc's `references` map: make links bidirectional between
26/// present docs and refresh each value to the referenced doc's current title.
27/// A reference whose target is absent (a closed work item's struck tombstone,
28/// or a dangling id) keeps its existing value untouched.
29pub fn reconcile(docs: &mut [Doc]) {
30    let mut title: HashMap<String, String> = HashMap::new();
31    let mut existing: HashMap<String, HashMap<String, String>> = HashMap::new();
32    let mut out: HashMap<String, BTreeSet<String>> = HashMap::new();
33    let mut present: HashSet<String> = HashSet::new();
34
35    for (id, t, fm) in docs.iter().map(|d| (d.id(), &d.title, &d.frontmatter)) {
36        let Some(id) = id else { continue };
37        present.insert(id.to_string());
38        title.insert(id.to_string(), t.clone());
39        let mut ex = HashMap::new();
40        let set = out.entry(id.to_string()).or_default();
41        for (tid, val) in refs::parse(fm) {
42            set.insert(tid.clone());
43            ex.insert(tid, val);
44        }
45        existing.insert(id.to_string(), ex);
46    }
47
48    // Make edges bidirectional between present docs.
49    let mut desired = out.clone();
50    for (src, targets) in &out {
51        for t in targets {
52            if present.contains(t) {
53                desired.entry(t.clone()).or_default().insert(src.clone());
54            }
55        }
56    }
57
58    let compute = |id: &str| -> Vec<(String, String)> {
59        desired
60            .get(id)
61            .cloned()
62            .unwrap_or_default()
63            .into_iter()
64            .map(|tid| {
65                let value = title.get(&tid).cloned().unwrap_or_else(|| {
66                    existing
67                        .get(id)
68                        .and_then(|m| m.get(&tid))
69                        .cloned()
70                        .unwrap_or_default()
71                });
72                (tid, value)
73            })
74            .collect()
75    };
76
77    for d in docs.iter_mut() {
78        if let Some(id) = d.id().map(str::to_string) {
79            refs::set(&mut d.frontmatter, &compute(&id));
80        }
81    }
82}
83
84/// Reconcile the directional blocker relation: `blocked_by` on one doc and
85/// `blocks` on the other are kept as inverses, with titles refreshed from the
86/// referenced doc. An edge is asserted if *either* endpoint records it (union
87/// semantics, like [`reconcile`]); removal is therefore done on both sides by
88/// the `unblock` command. Edges whose other endpoint is absent (a struck
89/// tombstone or a dangling id) keep their existing value untouched.
90pub fn reconcile_blockers(docs: &mut [Doc]) {
91    let mut title: HashMap<String, String> = HashMap::new();
92    let mut present: HashSet<String> = HashSet::new();
93    // existing[id][field] = (target -> stored value), for the absent-target fallback.
94    let mut existing: HashMap<String, HashMap<&'static str, HashMap<String, String>>> =
95        HashMap::new();
96    // Canonical directed edges (blocker, blocked), unioned from both fields.
97    let mut edges: HashSet<(String, String)> = HashSet::new();
98
99    for (id, t, fm) in docs.iter().map(|d| (d.id(), &d.title, &d.frontmatter)) {
100        let Some(id) = id else { continue };
101        present.insert(id.to_string());
102        title.insert(id.to_string(), t.clone());
103        let mut by_field = HashMap::new();
104        let mut bb = HashMap::new();
105        for (b, val) in refs::parse_in(fm, refs::BLOCKED_BY) {
106            edges.insert((b.clone(), id.to_string()));
107            bb.insert(b, val);
108        }
109        by_field.insert(refs::BLOCKED_BY, bb);
110        let mut bk = HashMap::new();
111        for (a, val) in refs::parse_in(fm, refs::BLOCKS) {
112            edges.insert((id.to_string(), a.clone()));
113            bk.insert(a, val);
114        }
115        by_field.insert(refs::BLOCKS, bk);
116        existing.insert(id.to_string(), by_field);
117    }
118
119    // Materialize each edge on whichever present doc owns that side.
120    let mut desired_bb: HashMap<String, BTreeSet<String>> = HashMap::new();
121    let mut desired_bk: HashMap<String, BTreeSet<String>> = HashMap::new();
122    for (blocker, blocked) in &edges {
123        if present.contains(blocked) {
124            desired_bb
125                .entry(blocked.clone())
126                .or_default()
127                .insert(blocker.clone());
128        }
129        if present.contains(blocker) {
130            desired_bk
131                .entry(blocker.clone())
132                .or_default()
133                .insert(blocked.clone());
134        }
135    }
136
137    let compute = |id: &str, field: &'static str, want: &HashMap<String, BTreeSet<String>>| {
138        want.get(id)
139            .cloned()
140            .unwrap_or_default()
141            .into_iter()
142            .map(|tid| {
143                let value = title.get(&tid).cloned().unwrap_or_else(|| {
144                    existing
145                        .get(id)
146                        .and_then(|m| m.get(field))
147                        .and_then(|m| m.get(&tid))
148                        .cloned()
149                        .unwrap_or_default()
150                });
151                (tid, value)
152            })
153            .collect::<Vec<_>>()
154    };
155
156    for d in docs.iter_mut() {
157        if let Some(id) = d.id().map(str::to_string) {
158            refs::set_in(
159                &mut d.frontmatter,
160                refs::BLOCKED_BY,
161                &compute(&id, refs::BLOCKED_BY, &desired_bb),
162            );
163            refs::set_in(
164                &mut d.frontmatter,
165                refs::BLOCKS,
166                &compute(&id, refs::BLOCKS, &desired_bk),
167            );
168        }
169    }
170}
171
172/// Map of every present doc's ID to (title, file path), for linkifying bodies.
173pub fn build_index(docs: &[Doc]) -> HashMap<String, (String, PathBuf)> {
174    let mut idx = HashMap::new();
175    for d in docs {
176        if let Some(id) = d.id() {
177            idx.insert(id.to_string(), (d.title.clone(), d.path.clone()));
178        }
179    }
180    idx
181}
182
183/// Rewrite bare `FEAT-XXXX`/`WI-XXXX` mentions (and refresh existing opys
184/// links) in body prose into `[ID — Title](relpath)`. Idempotent; references
185/// inside fenced code blocks, inline code spans, or existing markdown link
186/// syntax (label or destination) are left untouched, and a mention whose
187/// target is absent from `index` is left as-is.
188pub fn linkify(
189    body: &str,
190    current_dir: &Path,
191    index: &HashMap<String, (String, PathBuf)>,
192    re: &Regex,
193) -> String {
194    let mut out = String::new();
195    let mut in_fence = false;
196    for (i, line) in body.split('\n').enumerate() {
197        if i > 0 {
198            out.push('\n');
199        }
200        let trimmed = line.trim_start();
201        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
202            in_fence = !in_fence;
203            out.push_str(line);
204            continue;
205        }
206        if in_fence {
207            out.push_str(line);
208            continue;
209        }
210        // Split on backticks: even segments are prose, odd are inline code.
211        let mut code = false;
212        for (j, seg) in line.split('`').enumerate() {
213            if j > 0 {
214                out.push('`');
215            }
216            if code {
217                out.push_str(seg);
218            } else {
219                out.push_str(&linkify_prose(seg, current_dir, index, re));
220            }
221            code = !code;
222        }
223    }
224    out
225}
226
227/// Linkify one prose segment (no code spans/fences). Existing markdown links
228/// are parsed with bracket-depth counting — labels may themselves contain
229/// `[…]` — and their interior is never re-matched, so a link is never nested
230/// inside another. A link whose label starts with a live ID is refreshed to
231/// the current title and path; every other link passes through verbatim.
232fn linkify_prose(
233    seg: &str,
234    current_dir: &Path,
235    index: &HashMap<String, (String, PathBuf)>,
236    re: &Regex,
237) -> String {
238    let mut out = String::new();
239    let mut pos = 0;
240    while let Some(off) = seg[pos..].find('[') {
241        let start = pos + off;
242        match parse_link(seg, start) {
243            Some((label, end)) => {
244                out.push_str(&replace_bare(&seg[pos..start], current_dir, index, re));
245                let is_image = seg[..start].ends_with('!');
246                let refreshed = if is_image {
247                    None
248                } else {
249                    re.find(label)
250                        .filter(|m| m.start() == 0)
251                        .and_then(|m| index.get(m.as_str()).map(|t| (m.as_str(), t)))
252                        .map(|(id, (title, path))| {
253                            format!("[{id} — {title}]({})", relpath(current_dir, path))
254                        })
255                };
256                match refreshed {
257                    Some(link) => out.push_str(&link),
258                    None => out.push_str(&seg[start..end]),
259                }
260                pos = end;
261            }
262            None => {
263                // A `[` that opens no link (e.g. a checkbox `[ ]`) is prose.
264                out.push_str(&replace_bare(&seg[pos..start], current_dir, index, re));
265                out.push('[');
266                pos = start + 1;
267            }
268        }
269    }
270    out.push_str(&replace_bare(&seg[pos..], current_dir, index, re));
271    out
272}
273
274/// Parse a markdown link `[label](dest)` starting at `start` (a `[`). Returns
275/// the label and the index one past the closing `)`. Brackets in the label and
276/// parens in the destination are matched by depth, so bracketed titles round-trip.
277fn parse_link(s: &str, start: usize) -> Option<(&str, usize)> {
278    let rest = &s[start..];
279    let mut depth = 0usize;
280    let mut label_end = None;
281    for (i, ch) in rest.char_indices() {
282        match ch {
283            '[' => depth += 1,
284            ']' => {
285                depth -= 1;
286                if depth == 0 {
287                    label_end = Some(i);
288                    break;
289                }
290            }
291            _ => {}
292        }
293    }
294    let label_end = label_end?;
295    let after = &rest[label_end + 1..];
296    if !after.starts_with('(') {
297        return None;
298    }
299    let mut pdepth = 0usize;
300    for (i, ch) in after.char_indices() {
301        match ch {
302            '(' => pdepth += 1,
303            ')' => {
304                pdepth -= 1;
305                if pdepth == 0 {
306                    return Some((&rest[1..label_end], start + label_end + 1 + i + 1));
307                }
308            }
309            _ => {}
310        }
311    }
312    None
313}
314
315/// Replace bare ID mentions in link-free prose with fresh markdown links.
316fn replace_bare(
317    seg: &str,
318    current_dir: &Path,
319    index: &HashMap<String, (String, PathBuf)>,
320    re: &Regex,
321) -> String {
322    re.replace_all(seg, |c: &Captures| {
323        let id = &c[0];
324        match index.get(id) {
325            Some((title, path)) => {
326                format!("[{id} — {title}]({})", relpath(current_dir, path))
327            }
328            None => id.to_string(),
329        }
330    })
331    .into_owned()
332}
333
334/// Scan a body for nested markdown links — a complete `[…](…)` inside another
335/// link's label. That is invalid markdown and, in an opys inventory, the
336/// footprint of linkify corruption; `verify` flags each occurrence. Fenced code
337/// blocks and inline code spans are skipped, like in [`linkify`]. Returns one
338/// truncated snippet per offending link.
339pub fn nested_links(body: &str) -> Vec<String> {
340    let mut found = Vec::new();
341    let mut in_fence = false;
342    for line in body.split('\n') {
343        let trimmed = line.trim_start();
344        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
345            in_fence = !in_fence;
346            continue;
347        }
348        if in_fence {
349            continue;
350        }
351        for (j, seg) in line.split('`').enumerate() {
352            if j % 2 == 1 {
353                continue; // inline code span
354            }
355            let mut pos = 0;
356            while let Some(off) = seg[pos..].find('[') {
357                let start = pos + off;
358                match parse_link(seg, start) {
359                    Some((label, end)) => {
360                        if contains_link(label) {
361                            found.push(snippet(&seg[start..end]));
362                        }
363                        pos = end;
364                    }
365                    None => pos = start + 1,
366                }
367            }
368        }
369    }
370    found
371}
372
373/// Whether `s` contains a complete markdown link anywhere.
374fn contains_link(s: &str) -> bool {
375    let mut pos = 0;
376    while let Some(off) = s[pos..].find('[') {
377        let start = pos + off;
378        if parse_link(s, start).is_some() {
379            return true;
380        }
381        pos = start + 1;
382    }
383    false
384}
385
386/// First characters of `s`, with an ellipsis when truncated.
387fn snippet(s: &str) -> String {
388    const MAX: usize = 60;
389    if s.chars().count() <= MAX {
390        s.to_string()
391    } else {
392        let cut: String = s.chars().take(MAX).collect();
393        format!("{cut}…")
394    }
395}
396
397/// Relative path from a directory to a target file, using `/` separators.
398fn relpath(from_dir: &Path, to: &Path) -> String {
399    let from: Vec<_> = from_dir.components().collect();
400    let to_c: Vec<_> = to.components().collect();
401    let mut i = 0;
402    while i < from.len() && i < to_c.len() && from[i] == to_c[i] {
403        i += 1;
404    }
405    let mut parts: Vec<String> = Vec::new();
406    for _ in i..from.len() {
407        parts.push("..".to_string());
408    }
409    for c in &to_c[i..] {
410        parts.push(c.as_os_str().to_string_lossy().into_owned());
411    }
412    if parts.is_empty() {
413        ".".to_string()
414    } else {
415        parts.join("/")
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    fn idx() -> HashMap<String, (String, PathBuf)> {
424        let mut m = HashMap::new();
425        m.insert(
426            "FEAT-0001".to_string(),
427            (
428                "Auth login".to_string(),
429                PathBuf::from("/p/features/FEAT-0001.md"),
430            ),
431        );
432        m
433    }
434
435    #[test]
436    fn linkifies_bare_id_and_is_idempotent() {
437        let dir = Path::new("/p/work-items");
438        let re = ref_re(&["FEAT".to_string()]);
439        let once = linkify("See FEAT-0001 for context.", dir, &idx(), &re);
440        assert_eq!(
441            once,
442            "See [FEAT-0001 — Auth login](../features/FEAT-0001.md) for context."
443        );
444        let twice = linkify(&once, dir, &idx(), &re);
445        assert_eq!(twice, once);
446    }
447
448    #[test]
449    fn skips_code_spans_and_unknown_ids() {
450        let dir = Path::new("/p/work-items");
451        let body = "Inline `FEAT-0001` stays, FEAT-9999 unknown stays.";
452        assert_eq!(
453            linkify(body, dir, &idx(), &ref_re(&["FEAT".to_string()])),
454            body
455        );
456    }
457
458    #[test]
459    fn bracketed_link_labels_are_not_relinkified() {
460        // Regression: a label containing `[…]` (e.g. a title like
461        // "[Light] / [Dark] dual-variant support") used to be re-matched by
462        // the bare-ID branch, nesting the link deeper on every sync.
463        let dir = Path::new("/p/work-items");
464        let re = ref_re(&["FEAT".to_string()]);
465        let mut m = HashMap::new();
466        m.insert(
467            "FEAT-0315".to_string(),
468            (
469                "[Light] / [Dark] dual-variant support".to_string(),
470                PathBuf::from("/p/features/FEAT-0315.md"),
471            ),
472        );
473        let once = linkify("See FEAT-0315.", dir, &m, &re);
474        assert_eq!(
475            once,
476            "See [FEAT-0315 — [Light] / [Dark] dual-variant support](../features/FEAT-0315.md)."
477        );
478        let twice = linkify(&once, dir, &m, &re);
479        assert_eq!(twice, once);
480    }
481
482    #[test]
483    fn refreshes_stale_link_title_and_path() {
484        let dir = Path::new("/p/work-items");
485        let re = ref_re(&["FEAT".to_string()]);
486        let stale = "See [FEAT-0001 — Old name](old/FEAT-0001.md).";
487        assert_eq!(
488            linkify(stale, dir, &idx(), &re),
489            "See [FEAT-0001 — Auth login](../features/FEAT-0001.md)."
490        );
491    }
492
493    #[test]
494    fn ids_inside_foreign_link_syntax_stay_untouched() {
495        let dir = Path::new("/p/work-items");
496        let re = ref_re(&["FEAT".to_string()]);
497        let body = "See [notes on FEAT-0001](notes.md) and ![FEAT-0001 shot](FEAT-0001.png).";
498        assert_eq!(linkify(body, dir, &idx(), &re), body);
499    }
500
501    #[test]
502    fn checkbox_lines_still_linkify() {
503        let dir = Path::new("/p/work-items");
504        let re = ref_re(&["FEAT".to_string()]);
505        assert_eq!(
506            linkify("- [ ] Cover FEAT-0001", dir, &idx(), &re),
507            "- [ ] Cover [FEAT-0001 — Auth login](../features/FEAT-0001.md)"
508        );
509    }
510
511    #[test]
512    fn nested_links_are_detected() {
513        let body = "Ok [FEAT-0001 — Auth login](../features/FEAT-0001.md) here.\n\
514                    Bad [[FEAT-0315 — [Light] mode](FEAT-0315.md) — extra](FEAT-0315.md) there.";
515        let found = nested_links(body);
516        assert_eq!(found.len(), 1);
517        assert!(found[0].starts_with("[[FEAT-0315"), "snippet: {}", found[0]);
518    }
519
520    #[test]
521    fn nested_links_skip_code_and_bracketed_labels() {
522        let body = "`[[a](b)](c)` inline code\n\
523                    ```\n[[a](b)](c)\n```\n\
524                    [FEAT-0315 — [Light] / [Dark] support](FEAT-0315.md) plain brackets";
525        assert!(nested_links(body).is_empty());
526    }
527
528    #[test]
529    fn skips_fenced_blocks() {
530        let dir = Path::new("/p/work-items");
531        let body = "```\nFEAT-0001\n```";
532        assert_eq!(
533            linkify(body, dir, &idx(), &ref_re(&["FEAT".to_string()])),
534            body
535        );
536    }
537}