use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use regex::{Captures, Regex};
use crate::doc::Doc;
use crate::refs;
pub fn ref_re(prefixes: &[String]) -> Regex {
let alt = prefixes
.iter()
.map(|p| regex::escape(p))
.collect::<Vec<_>>()
.join("|");
Regex::new(&format!(r"\b(?:{alt})-[0-9]+\b")).unwrap()
}
pub fn reconcile(docs: &mut [Doc]) {
let mut title: HashMap<String, String> = HashMap::new();
let mut existing: HashMap<String, HashMap<String, String>> = HashMap::new();
let mut out: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut present: HashSet<String> = HashSet::new();
for (id, t, fm) in docs.iter().map(|d| (d.id(), &d.title, &d.frontmatter)) {
let Some(id) = id else { continue };
present.insert(id.to_string());
title.insert(id.to_string(), t.clone());
let mut ex = HashMap::new();
let set = out.entry(id.to_string()).or_default();
for (tid, val) in refs::parse(fm) {
set.insert(tid.clone());
ex.insert(tid, val);
}
existing.insert(id.to_string(), ex);
}
let mut desired = out.clone();
for (src, targets) in &out {
for t in targets {
if present.contains(t) {
desired.entry(t.clone()).or_default().insert(src.clone());
}
}
}
let compute = |id: &str| -> Vec<(String, String)> {
desired
.get(id)
.cloned()
.unwrap_or_default()
.into_iter()
.map(|tid| {
let value = title.get(&tid).cloned().unwrap_or_else(|| {
existing
.get(id)
.and_then(|m| m.get(&tid))
.cloned()
.unwrap_or_default()
});
(tid, value)
})
.collect()
};
for d in docs.iter_mut() {
if let Some(id) = d.id().map(str::to_string) {
refs::set(&mut d.frontmatter, &compute(&id));
}
}
}
pub fn reconcile_blockers(docs: &mut [Doc]) {
let mut title: HashMap<String, String> = HashMap::new();
let mut present: HashSet<String> = HashSet::new();
let mut existing: HashMap<String, HashMap<&'static str, HashMap<String, String>>> =
HashMap::new();
let mut edges: HashSet<(String, String)> = HashSet::new();
for (id, t, fm) in docs.iter().map(|d| (d.id(), &d.title, &d.frontmatter)) {
let Some(id) = id else { continue };
present.insert(id.to_string());
title.insert(id.to_string(), t.clone());
let mut by_field = HashMap::new();
let mut bb = HashMap::new();
for (b, val) in refs::parse_in(fm, refs::BLOCKED_BY) {
edges.insert((b.clone(), id.to_string()));
bb.insert(b, val);
}
by_field.insert(refs::BLOCKED_BY, bb);
let mut bk = HashMap::new();
for (a, val) in refs::parse_in(fm, refs::BLOCKS) {
edges.insert((id.to_string(), a.clone()));
bk.insert(a, val);
}
by_field.insert(refs::BLOCKS, bk);
existing.insert(id.to_string(), by_field);
}
let mut desired_bb: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut desired_bk: HashMap<String, BTreeSet<String>> = HashMap::new();
for (blocker, blocked) in &edges {
if present.contains(blocked) {
desired_bb
.entry(blocked.clone())
.or_default()
.insert(blocker.clone());
}
if present.contains(blocker) {
desired_bk
.entry(blocker.clone())
.or_default()
.insert(blocked.clone());
}
}
let compute = |id: &str, field: &'static str, want: &HashMap<String, BTreeSet<String>>| {
want.get(id)
.cloned()
.unwrap_or_default()
.into_iter()
.map(|tid| {
let value = title.get(&tid).cloned().unwrap_or_else(|| {
existing
.get(id)
.and_then(|m| m.get(field))
.and_then(|m| m.get(&tid))
.cloned()
.unwrap_or_default()
});
(tid, value)
})
.collect::<Vec<_>>()
};
for d in docs.iter_mut() {
if let Some(id) = d.id().map(str::to_string) {
refs::set_in(
&mut d.frontmatter,
refs::BLOCKED_BY,
&compute(&id, refs::BLOCKED_BY, &desired_bb),
);
refs::set_in(
&mut d.frontmatter,
refs::BLOCKS,
&compute(&id, refs::BLOCKS, &desired_bk),
);
}
}
}
pub fn build_index(docs: &[Doc]) -> HashMap<String, (String, PathBuf)> {
let mut idx = HashMap::new();
for d in docs {
if let Some(id) = d.id() {
idx.insert(id.to_string(), (d.title.clone(), d.path.clone()));
}
}
idx
}
pub fn linkify(
body: &str,
current_dir: &Path,
index: &HashMap<String, (String, PathBuf)>,
re: &Regex,
) -> String {
let mut out = String::new();
let mut in_fence = false;
for (i, line) in body.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
out.push_str(line);
continue;
}
if in_fence {
out.push_str(line);
continue;
}
let mut code = false;
for (j, seg) in line.split('`').enumerate() {
if j > 0 {
out.push('`');
}
if code {
out.push_str(seg);
} else {
out.push_str(&linkify_prose(seg, current_dir, index, re));
}
code = !code;
}
}
out
}
fn linkify_prose(
seg: &str,
current_dir: &Path,
index: &HashMap<String, (String, PathBuf)>,
re: &Regex,
) -> String {
let mut out = String::new();
let mut pos = 0;
while let Some(off) = seg[pos..].find('[') {
let start = pos + off;
match parse_link(seg, start) {
Some((label, end)) => {
out.push_str(&replace_bare(&seg[pos..start], current_dir, index, re));
let is_image = seg[..start].ends_with('!');
let refreshed = if is_image {
None
} else {
re.find(label)
.filter(|m| m.start() == 0)
.and_then(|m| index.get(m.as_str()).map(|t| (m.as_str(), t)))
.map(|(id, (title, path))| {
format!("[{id} — {title}]({})", relpath(current_dir, path))
})
};
match refreshed {
Some(link) => out.push_str(&link),
None => out.push_str(&seg[start..end]),
}
pos = end;
}
None => {
out.push_str(&replace_bare(&seg[pos..start], current_dir, index, re));
out.push('[');
pos = start + 1;
}
}
}
out.push_str(&replace_bare(&seg[pos..], current_dir, index, re));
out
}
fn parse_link(s: &str, start: usize) -> Option<(&str, usize)> {
let rest = &s[start..];
let mut depth = 0usize;
let mut label_end = None;
for (i, ch) in rest.char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
label_end = Some(i);
break;
}
}
_ => {}
}
}
let label_end = label_end?;
let after = &rest[label_end + 1..];
if !after.starts_with('(') {
return None;
}
let mut pdepth = 0usize;
for (i, ch) in after.char_indices() {
match ch {
'(' => pdepth += 1,
')' => {
pdepth -= 1;
if pdepth == 0 {
return Some((&rest[1..label_end], start + label_end + 1 + i + 1));
}
}
_ => {}
}
}
None
}
fn replace_bare(
seg: &str,
current_dir: &Path,
index: &HashMap<String, (String, PathBuf)>,
re: &Regex,
) -> String {
re.replace_all(seg, |c: &Captures| {
let id = &c[0];
match index.get(id) {
Some((title, path)) => {
format!("[{id} — {title}]({})", relpath(current_dir, path))
}
None => id.to_string(),
}
})
.into_owned()
}
pub fn nested_links(body: &str) -> Vec<String> {
let mut found = Vec::new();
let mut in_fence = false;
for line in body.split('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
for (j, seg) in line.split('`').enumerate() {
if j % 2 == 1 {
continue; }
let mut pos = 0;
while let Some(off) = seg[pos..].find('[') {
let start = pos + off;
match parse_link(seg, start) {
Some((label, end)) => {
if contains_link(label) {
found.push(snippet(&seg[start..end]));
}
pos = end;
}
None => pos = start + 1,
}
}
}
}
found
}
fn contains_link(s: &str) -> bool {
let mut pos = 0;
while let Some(off) = s[pos..].find('[') {
let start = pos + off;
if parse_link(s, start).is_some() {
return true;
}
pos = start + 1;
}
false
}
fn snippet(s: &str) -> String {
const MAX: usize = 60;
if s.chars().count() <= MAX {
s.to_string()
} else {
let cut: String = s.chars().take(MAX).collect();
format!("{cut}…")
}
}
fn relpath(from_dir: &Path, to: &Path) -> String {
let from: Vec<_> = from_dir.components().collect();
let to_c: Vec<_> = to.components().collect();
let mut i = 0;
while i < from.len() && i < to_c.len() && from[i] == to_c[i] {
i += 1;
}
let mut parts: Vec<String> = Vec::new();
for _ in i..from.len() {
parts.push("..".to_string());
}
for c in &to_c[i..] {
parts.push(c.as_os_str().to_string_lossy().into_owned());
}
if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn idx() -> HashMap<String, (String, PathBuf)> {
let mut m = HashMap::new();
m.insert(
"FEAT-0001".to_string(),
(
"Auth login".to_string(),
PathBuf::from("/p/features/FEAT-0001.md"),
),
);
m
}
#[test]
fn linkifies_bare_id_and_is_idempotent() {
let dir = Path::new("/p/work-items");
let re = ref_re(&["FEAT".to_string()]);
let once = linkify("See FEAT-0001 for context.", dir, &idx(), &re);
assert_eq!(
once,
"See [FEAT-0001 — Auth login](../features/FEAT-0001.md) for context."
);
let twice = linkify(&once, dir, &idx(), &re);
assert_eq!(twice, once);
}
#[test]
fn skips_code_spans_and_unknown_ids() {
let dir = Path::new("/p/work-items");
let body = "Inline `FEAT-0001` stays, FEAT-9999 unknown stays.";
assert_eq!(
linkify(body, dir, &idx(), &ref_re(&["FEAT".to_string()])),
body
);
}
#[test]
fn bracketed_link_labels_are_not_relinkified() {
let dir = Path::new("/p/work-items");
let re = ref_re(&["FEAT".to_string()]);
let mut m = HashMap::new();
m.insert(
"FEAT-0315".to_string(),
(
"[Light] / [Dark] dual-variant support".to_string(),
PathBuf::from("/p/features/FEAT-0315.md"),
),
);
let once = linkify("See FEAT-0315.", dir, &m, &re);
assert_eq!(
once,
"See [FEAT-0315 — [Light] / [Dark] dual-variant support](../features/FEAT-0315.md)."
);
let twice = linkify(&once, dir, &m, &re);
assert_eq!(twice, once);
}
#[test]
fn refreshes_stale_link_title_and_path() {
let dir = Path::new("/p/work-items");
let re = ref_re(&["FEAT".to_string()]);
let stale = "See [FEAT-0001 — Old name](old/FEAT-0001.md).";
assert_eq!(
linkify(stale, dir, &idx(), &re),
"See [FEAT-0001 — Auth login](../features/FEAT-0001.md)."
);
}
#[test]
fn ids_inside_foreign_link_syntax_stay_untouched() {
let dir = Path::new("/p/work-items");
let re = ref_re(&["FEAT".to_string()]);
let body = "See [notes on FEAT-0001](notes.md) and .";
assert_eq!(linkify(body, dir, &idx(), &re), body);
}
#[test]
fn checkbox_lines_still_linkify() {
let dir = Path::new("/p/work-items");
let re = ref_re(&["FEAT".to_string()]);
assert_eq!(
linkify("- [ ] Cover FEAT-0001", dir, &idx(), &re),
"- [ ] Cover [FEAT-0001 — Auth login](../features/FEAT-0001.md)"
);
}
#[test]
fn nested_links_are_detected() {
let body = "Ok [FEAT-0001 — Auth login](../features/FEAT-0001.md) here.\n\
Bad [[FEAT-0315 — [Light] mode](FEAT-0315.md) — extra](FEAT-0315.md) there.";
let found = nested_links(body);
assert_eq!(found.len(), 1);
assert!(found[0].starts_with("[[FEAT-0315"), "snippet: {}", found[0]);
}
#[test]
fn nested_links_skip_code_and_bracketed_labels() {
let body = "`[[a](b)](c)` inline code\n\
```\n[[a](b)](c)\n```\n\
[FEAT-0315 — [Light] / [Dark] support](FEAT-0315.md) plain brackets";
assert!(nested_links(body).is_empty());
}
#[test]
fn skips_fenced_blocks() {
let dir = Path::new("/p/work-items");
let body = "```\nFEAT-0001\n```";
assert_eq!(
linkify(body, dir, &idx(), &ref_re(&["FEAT".to_string()])),
body
);
}
}