1use std::collections::{BTreeMap, BTreeSet};
8use std::sync::Arc;
9
10use crate::diagnostic::Diagnostic;
11use crate::model::{DocPath, Facet, Id, Lifecycle, Record, Status};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Relation {
15 DependsOn,
16 Supports,
17 Related,
18 Link,
19}
20
21#[derive(Clone, Debug)]
23pub struct Cite {
24 pub target: Id,
25 pub relation: Relation,
26}
27
28pub struct Graph {
29 records: Vec<Arc<Record>>,
30 by_id: BTreeMap<Id, usize>,
31 adopted_successor: BTreeMap<Id, Id>,
33 pending: BTreeMap<Id, Vec<Id>>,
35 ambiguous: BTreeSet<Id>,
39}
40
41fn record_is_adopted(r: &Record) -> bool {
44 match &r.facet {
45 Facet::Decision(d) => matches!(
46 d.status,
47 Status::Accepted | Status::Superseded | Status::Deprecated
48 ),
49 _ => r.lifecycle != Lifecycle::Draft,
50 }
51}
52
53impl Graph {
54 pub fn build(records: Vec<Arc<Record>>) -> (Graph, Vec<Diagnostic>) {
55 let mut by_id = BTreeMap::new();
56 let mut adopted_successor: BTreeMap<Id, Id> = BTreeMap::new();
57 let mut pending: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
58 let mut ambiguous: BTreeSet<Id> = BTreeSet::new();
59 let mut diags = Vec::new();
60
61 for (i, r) in records.iter().enumerate() {
63 if let Some(id) = &r.id {
64 if by_id.insert(id.clone(), i).is_some() {
65 diags.push(Diagnostic::error(
66 "DUP",
67 &r.path,
68 format!("duplicate id {}", id.0),
69 ));
70 }
71 }
72 }
73
74 let mut adopted_cands: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
80 for r in records.iter() {
81 let Some(src) = &r.id else { continue };
82 let adopted = record_is_adopted(r);
83 for tgt in &r.edges.supersedes {
84 let valid = match by_id.get(tgt) {
85 None => {
86 diags.push(Diagnostic::warn(
87 "SUPERSEDE",
88 &r.path,
89 format!("{} supersedes unknown id {}", src.0, tgt.0),
90 ));
91 false
92 }
93 Some(&ti) => {
94 let tkind = records[ti].kind;
95 if tkind != r.kind {
96 diags.push(Diagnostic::error(
97 "KIND",
98 &r.path,
99 format!(
100 "{} ({:?}) supersedes {} of different kind ({:?})",
101 src.0, r.kind, tgt.0, tkind
102 ),
103 ));
104 false
105 } else {
106 true
107 }
108 }
109 };
110 if !valid {
111 continue;
112 }
113
114 if adopted {
115 let v = adopted_cands.entry(tgt.clone()).or_default();
116 if !v.contains(src) {
117 v.push(src.clone());
118 }
119 } else {
120 let v = pending.entry(tgt.clone()).or_default();
121 if !v.contains(src) {
122 v.push(src.clone());
123 }
124 }
125 }
126 }
127
128 for (tgt, srcs) in &adopted_cands {
132 if srcs.len() == 1 {
133 adopted_successor.insert(tgt.clone(), srcs[0].clone());
134 } else {
135 ambiguous.insert(tgt.clone());
136 let mut names: Vec<&str> = srcs.iter().map(|i| i.0.as_str()).collect();
137 names.sort_unstable();
138 let path = by_id
139 .get(tgt)
140 .map(|&i| records[i].path.clone())
141 .unwrap_or_else(|| DocPath(tgt.0.clone()));
142 diags.push(Diagnostic::error(
143 "SUPERSEDE",
144 &path,
145 format!("{} has multiple adopted successors: {}", tgt.0, names.join(", ")),
146 ));
147 }
148 }
149
150 let mut cyclic: BTreeSet<Id> = BTreeSet::new();
152 for start in adopted_successor.keys() {
153 if cyclic.contains(start) {
154 continue;
155 }
156 let mut seen: Vec<Id> = Vec::new();
157 let mut cur = start.clone();
158 loop {
159 if cyclic.contains(&cur) {
160 break; }
162 if let Some(pos) = seen.iter().position(|x| *x == cur) {
163 for id in &seen[pos..] {
164 cyclic.insert(id.clone());
165 }
166 let mut chain: Vec<String> =
167 seen[pos..].iter().map(|i| i.0.clone()).collect();
168 chain.push(cur.0.clone());
169 let path = by_id
170 .get(&cur)
171 .map(|&i| records[i].path.clone())
172 .unwrap_or_else(|| DocPath(cur.0.clone()));
173 diags.push(Diagnostic::error(
174 "CYCLE",
175 &path,
176 format!("supersession cycle: {}", chain.join(" → ")),
177 ));
178 break;
179 }
180 seen.push(cur.clone());
181 match adopted_successor.get(&cur) {
182 Some(next) => cur = next.clone(),
183 None => break,
184 }
185 }
186 }
187
188 (
189 Graph {
190 records,
191 by_id,
192 adopted_successor,
193 pending,
194 ambiguous,
195 },
196 diags,
197 )
198 }
199
200 pub fn records(&self) -> impl Iterator<Item = &Record> {
201 self.records.iter().map(|a| a.as_ref())
202 }
203
204 pub fn get(&self, id: &Id) -> Option<&Record> {
205 self.by_id.get(id).map(|&i| self.records[i].as_ref())
206 }
207
208 pub fn is_superseded(&self, id: &Id) -> bool {
214 self.adopted_successor.contains_key(id) || self.ambiguous.contains(id)
215 }
216
217 pub fn effective_successor(&self, id: &Id) -> Option<&Id> {
223 if self.ambiguous.contains(id) {
224 return None;
225 }
226 let mut seen: BTreeSet<Id> = BTreeSet::new();
227 seen.insert(id.clone());
228 let mut cur = self.adopted_successor.get(id)?;
229 loop {
230 if !seen.insert(cur.clone()) {
231 return None; }
233 if self.ambiguous.contains(cur) {
234 return None; }
236 match self.adopted_successor.get(cur) {
237 Some(next) => cur = next,
238 None => return Some(cur),
239 }
240 }
241 }
242
243 pub fn pending_successors(&self, id: &Id) -> &[Id] {
246 self.pending.get(id).map(|v| v.as_slice()).unwrap_or(&[])
247 }
248
249 pub fn structured_citations(&self, from: &Id) -> Vec<Cite> {
251 let Some(r) = self.get(from) else {
252 return Vec::new();
253 };
254 let mut v = Vec::new();
255 for t in &r.edges.depends_on {
256 v.push(Cite {
257 target: t.clone(),
258 relation: Relation::DependsOn,
259 });
260 }
261 for t in &r.edges.supports {
262 v.push(Cite {
263 target: t.clone(),
264 relation: Relation::Supports,
265 });
266 }
267 for t in &r.edges.related {
268 v.push(Cite {
269 target: t.clone(),
270 relation: Relation::Related,
271 });
272 }
273 for t in &r.body.link_refs {
274 v.push(Cite {
275 target: t.clone(),
276 relation: Relation::Link,
277 });
278 }
279 v
280 }
281
282 pub fn bare_mentions(&self, from: &Id) -> Vec<Id> {
284 self.get(from)
285 .map(|r| r.body.bare_mentions.clone())
286 .unwrap_or_default()
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::diagnostic::Severity;
294 use crate::model::*;
295
296 fn id(s: &str) -> Id {
297 Id(s.into())
298 }
299
300 fn decision(name: &str, status: Status, supersedes: &[&str]) -> Record {
302 let mut r = Record::minimal(
303 Some(id(name)),
304 DocPath(format!("{name}.md")),
305 Kind::Decision,
306 Lifecycle::Current,
307 Authority::Normative,
308 Facet::Decision(DecisionFacet {
309 status,
310 date: Date("2026-06-21".into()),
311 implementation: None,
312 fork: None,
313 realized_by: vec![],
314 }),
315 );
316 r.edges.supersedes = supersedes.iter().map(|s| id(s)).collect();
317 r
318 }
319
320 fn build(records: Vec<Record>) -> (Graph, Vec<Diagnostic>) {
321 Graph::build(records.into_iter().map(Arc::new).collect())
322 }
323
324 fn codes(diags: &[Diagnostic]) -> Vec<&str> {
325 diags.iter().map(|d| d.code.as_str()).collect()
326 }
327
328 #[test]
329 fn supersession_resolves() {
330 let d1 = decision("D1", Status::Superseded, &[]);
331 let d2 = decision("D2", Status::Accepted, &["D1"]);
332 let (g, diags) = build(vec![d1, d2]);
333 assert!(diags.is_empty(), "{diags:?}");
334 assert!(g.is_superseded(&id("D1")));
335 assert_eq!(g.effective_successor(&id("D1")), Some(&id("D2")));
336 assert!(!g.is_superseded(&id("D2")));
337 }
338
339 #[test]
340 fn effective_successor_chases_to_terminal() {
341 let d1 = decision("D1", Status::Superseded, &[]);
342 let d2 = decision("D2", Status::Superseded, &["D1"]);
343 let d3 = decision("D3", Status::Accepted, &["D2"]);
344 let (g, diags) = build(vec![d1, d2, d3]);
345 assert!(diags.is_empty(), "{diags:?}");
346 assert_eq!(g.effective_successor(&id("D1")), Some(&id("D3")));
347 assert_eq!(g.effective_successor(&id("D2")), Some(&id("D3")));
348 }
349
350 #[test]
351 fn proposed_successor_is_pending_not_effective() {
352 let d1 = decision("D1", Status::Accepted, &[]);
353 let d2 = decision("D2", Status::Proposed, &["D1"]); let (g, diags) = build(vec![d1, d2]);
355 assert!(diags.is_empty(), "{diags:?}");
356 assert!(!g.is_superseded(&id("D1")));
357 assert_eq!(g.effective_successor(&id("D1")), None);
358 assert_eq!(g.pending_successors(&id("D1")), &[id("D2")]);
359 }
360
361 #[test]
362 fn two_adopted_successors_conflict() {
363 let d1 = decision("D1", Status::Superseded, &[]);
364 let d2 = decision("D2", Status::Accepted, &["D1"]);
365 let d3 = decision("D3", Status::Accepted, &["D1"]);
366 let (g, diags) = build(vec![d1, d2, d3]);
367 assert!(codes(&diags).contains(&"SUPERSEDE"), "{diags:?}");
368 assert!(g.is_superseded(&id("D1")));
370 assert_eq!(g.effective_successor(&id("D1")), None);
371 }
372
373 #[test]
374 fn ambiguity_is_order_independent() {
375 let d1 = decision("D1", Status::Superseded, &[]);
377 let d2 = decision("D2", Status::Accepted, &["D1"]);
378 let d3 = decision("D3", Status::Accepted, &["D1"]);
379 let (g, _) = build(vec![d3, d2, d1]);
380 assert_eq!(g.effective_successor(&id("D1")), None);
381 }
382
383 #[test]
384 fn ambiguity_is_not_transitively_resolvable() {
385 let a = decision("A", Status::Superseded, &[]);
388 let b = decision("B", Status::Superseded, &["A"]);
389 let c = decision("C", Status::Accepted, &["B"]);
390 let d = decision("D", Status::Accepted, &["B"]);
391 let (g, diags) = build(vec![a, b, c, d]);
392
393 assert!(g.is_superseded(&id("A")), "A is superseded by B");
394 assert_eq!(
395 g.effective_successor(&id("A")),
396 None,
397 "A's chain runs through the ambiguous B, so no terminal is safe"
398 );
399 let sup = diags
401 .iter()
402 .find(|x| x.code == "SUPERSEDE")
403 .expect("ambiguity should be reported");
404 assert_eq!(sup.path, DocPath("B.md".into()));
405 }
406
407 #[test]
408 fn kind_must_be_preserved() {
409 let d1 = decision("D1", Status::Superseded, &[]);
410 let mut a1 = Record::minimal(
411 Some(id("A1")),
412 DocPath("a1.md".into()),
413 Kind::Axiom,
414 Lifecycle::Current,
415 Authority::Axiomatic,
416 Facet::Axiom,
417 );
418 a1.edges.supersedes = vec![id("D1")];
419 let (g, diags) = build(vec![d1, a1]);
420 assert!(codes(&diags).contains(&"KIND"), "{diags:?}");
421 assert!(!g.is_superseded(&id("D1")));
423 assert_eq!(g.effective_successor(&id("D1")), None);
424 }
425
426 #[test]
427 fn supersession_cycle_detected() {
428 let d1 = decision("D1", Status::Accepted, &["D2"]);
429 let d2 = decision("D2", Status::Accepted, &["D1"]);
430 let (g, diags) = build(vec![d1, d2]);
431 let cycle: Vec<_> = diags.iter().filter(|d| d.code == "CYCLE").collect();
432 assert_eq!(cycle.len(), 1, "exactly one cycle report: {diags:?}");
433 assert_eq!(g.effective_successor(&id("D1")), None);
435 assert_eq!(g.effective_successor(&id("D2")), None);
436 }
437
438 #[test]
439 fn cycle_reported_once_through_tails() {
440 let a = decision("A", Status::Accepted, &[]);
443 let b = decision("B", Status::Accepted, &["A", "C"]);
444 let c = decision("C", Status::Accepted, &["B", "E"]);
445 let e = decision("E", Status::Accepted, &[]);
446 let (_g, diags) = build(vec![a, b, c, e]);
447 let cycle: Vec<_> = diags.iter().filter(|d| d.code == "CYCLE").collect();
448 assert_eq!(cycle.len(), 1, "one report despite multiple tails: {diags:?}");
449 }
450
451 #[test]
452 fn dangling_supersedes_warns_and_drops_edge() {
453 let d2 = decision("D2", Status::Accepted, &["D1"]); let (g, diags) = build(vec![d2]);
455 assert!(codes(&diags).contains(&"SUPERSEDE"), "{diags:?}");
456 assert_eq!(diags[0].severity, Severity::Warning);
457 assert!(!g.is_superseded(&id("D1")));
459 }
460
461 #[test]
462 fn duplicate_id_flagged() {
463 let a = decision("D1", Status::Accepted, &[]);
464 let mut b = decision("D1", Status::Accepted, &[]);
465 b.path = DocPath("b.md".into());
466 let (_g, diags) = build(vec![a, b]);
467 assert_eq!(codes(&diags), vec!["DUP"]);
468 }
469}