1use std::collections::{BTreeSet, HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use regex::{Captures, Regex};
10
11use crate::doc::Doc;
12use crate::refs;
13
14pub 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
25pub 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 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
84pub fn reconcile_blockers(docs: &mut [Doc]) {
91 let mut title: HashMap<String, String> = HashMap::new();
92 let mut present: HashSet<String> = HashSet::new();
93 let mut existing: HashMap<String, HashMap<&'static str, HashMap<String, String>>> =
95 HashMap::new();
96 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 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
172pub 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
183pub 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 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
227fn 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 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
274fn 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
315fn 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
334pub 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; }
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
373fn 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
386fn 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
397fn 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 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 .";
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}