Skip to main content

fur_cli/schema/
lineage.rs

1//! Conversation lineage, derived from `.fur/threads/`.
2//!
3//! # Assertions, not records
4//!
5//! `parents` and `children` are independent claims. A holds "B is my child"
6//! and B holds "A is my parent"; either can exist without the other, because
7//! one of them may have been imported from a registry without the other. The
8//! traversal graph is therefore the *union* of every claim in the project, and
9//! an edge asserted from one side only is a real edge.
10//!
11//! The raw claims are kept alongside the union, because the union deliberately
12//! erases which side made the claim — and that is exactly what asymmetry
13//! detection needs.
14//!
15//! # Cycles are data, not errors
16//!
17//! Nothing here rejects a cycle. `fur link` refuses to create one locally, but
18//! two people can independently publish A→B and B→A and the cycle only exists
19//! once both land in one archive. Every walk carries a visited set — which it
20//! needs for diamonds regardless — so a node reached twice is emitted once and
21//! flagged, never expanded again.
22
23use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
24use std::fs;
25use std::path::Path;
26
27use serde_json::Value;
28
29/// One row of a rendered lineage tree.
30pub struct TreeEntry {
31    pub id: String,
32    /// Nesting level; 0 is a root.
33    pub depth: usize,
34    /// Already shown higher up — the second arm of a diamond. Not expanded.
35    pub repeat: bool,
36    /// Has parents, none of which are in this project. Sits at the margin
37    /// because there is nothing local to nest it under.
38    pub orphan_parent: bool,
39}
40
41pub struct Lineage {
42    titles: BTreeMap<String, String>,
43    parents: BTreeMap<String, BTreeSet<String>>,
44    children: BTreeMap<String, BTreeSet<String>>,
45    claimed_parents: BTreeMap<String, BTreeSet<String>>,
46    claimed_children: BTreeMap<String, BTreeSet<String>>,
47}
48
49// `title`, `asymmetric` and `dangling` are the reporting surface: `fur doctor`
50// and the visual export read them, the conversation table does not.
51#[allow(dead_code)]
52impl Lineage {
53    /// Build from `.fur/threads/`, reading the directory rather than the index
54    /// so a stale `index.json` cannot hide a conversation.
55    pub fn load(fur_dir: &Path) -> Result<Lineage, String> {
56        let threads = fur_dir.join("threads");
57        let entries = fs::read_dir(&threads)
58            .map_err(|e| format!("cannot read {}: {}", threads.display(), e))?;
59
60        let mut lineage = Lineage {
61            titles: BTreeMap::new(),
62            parents: BTreeMap::new(),
63            children: BTreeMap::new(),
64            claimed_parents: BTreeMap::new(),
65            claimed_children: BTreeMap::new(),
66        };
67
68        for entry in entries.flatten() {
69            let path = entry.path();
70            if path.extension().and_then(|e| e.to_str()) != Some("json") {
71                continue;
72            }
73
74            let Some(content) = crate::security::io::read_text_file(&path) else {
75                continue;
76            };
77            let Ok(convo) = serde_json::from_str::<Value>(&content) else {
78                continue;
79            };
80            let Some(id) = convo["id"].as_str() else {
81                continue;
82            };
83
84            lineage.titles.insert(
85                id.to_string(),
86                convo["title"].as_str().unwrap_or("Untitled").to_string(),
87            );
88
89            for parent in string_list(&convo, "parents") {
90                lineage
91                    .claimed_parents
92                    .entry(id.to_string())
93                    .or_default()
94                    .insert(parent.clone());
95                lineage.add_edge(&parent, id);
96            }
97            for child in string_list(&convo, "children") {
98                lineage
99                    .claimed_children
100                    .entry(id.to_string())
101                    .or_default()
102                    .insert(child.clone());
103                lineage.add_edge(id, &child);
104            }
105        }
106
107        Ok(lineage)
108    }
109
110    /// Record one edge in both directions, so a claim from either end is
111    /// visible from both.
112    fn add_edge(&mut self, parent: &str, child: &str) {
113        self.children
114            .entry(parent.to_string())
115            .or_default()
116            .insert(child.to_string());
117        self.parents
118            .entry(child.to_string())
119            .or_default()
120            .insert(parent.to_string());
121    }
122
123    pub fn is_empty(&self) -> bool {
124        self.parents.is_empty() && self.children.is_empty()
125    }
126
127    pub fn is_local(&self, id: &str) -> bool {
128        self.titles.contains_key(id)
129    }
130
131    pub fn title(&self, id: &str) -> Option<&str> {
132        self.titles.get(id).map(|s| s.as_str())
133    }
134
135    /// Would linking `parent` → `child` close a loop? True when `child` can
136    /// already reach `parent` by following children.
137    pub fn would_cycle(&self, parent: &str, child: &str) -> bool {
138        let mut seen: HashSet<&str> = HashSet::new();
139        let mut stack = vec![child];
140
141        while let Some(id) = stack.pop() {
142            if id == parent {
143                return true;
144            }
145            if !seen.insert(id) {
146                continue;
147            }
148            if let Some(kids) = self.children.get(id) {
149                stack.extend(kids.iter().map(|s| s.as_str()));
150            }
151        }
152
153        false
154    }
155
156    /// Every local conversation reachable upward from `start`, in dependency
157    /// order: a conversation always appears after everything it draws on.
158    ///
159    /// `start` is last. Absent conversations are skipped — there is nothing
160    /// local to print for them — but `absent_ancestors` reports them so the
161    /// document can say what it could not include.
162    pub fn ancestry(&self, start: &str) -> Vec<String> {
163        let mut out = Vec::new();
164        let mut state: HashMap<String, u8> = HashMap::new();
165        self.visit_up(start, &mut state, &mut out);
166        out
167    }
168
169    /// Post-order DFS. `state` marks 1 = in progress, 2 = emitted; the
170    /// in-progress mark is what makes an imported cycle terminate instead of
171    /// recursing forever.
172    fn visit_up(&self, id: &str, state: &mut HashMap<String, u8>, out: &mut Vec<String>) {
173        match state.get(id) {
174            Some(_) => return,
175            None => state.insert(id.to_string(), 1),
176        };
177
178        if let Some(parents) = self.parents.get(id) {
179            for parent in parents {
180                if self.is_local(parent) {
181                    self.visit_up(parent, state, out);
182                }
183            }
184        }
185
186        state.insert(id.to_string(), 2);
187
188        if self.is_local(id) {
189            out.push(id.to_string());
190        }
191    }
192
193    /// Every local conversation reachable downward from `start`, in dependency
194    /// order: a conversation always appears after everything it draws on.
195    ///
196    /// The reachable set is collected first, then ordered by walking *upward*
197    /// within it — the same rule that orders ancestors. Ordering by descent
198    /// would emit a diamond's tip before one of its arms.
199    pub fn descendants(&self, start: &str) -> Vec<String> {
200        let mut reachable: HashSet<String> = HashSet::new();
201        let mut stack = vec![start.to_string()];
202
203        while let Some(id) = stack.pop() {
204            if !reachable.insert(id.clone()) {
205                continue;
206            }
207            if let Some(kids) = self.children.get(&id) {
208                stack.extend(kids.iter().cloned());
209            }
210        }
211
212        let mut out = Vec::new();
213        let mut state: HashMap<String, u8> = HashMap::new();
214
215        // Seed `start` as emitted so it leads, and so parents outside the
216        // reachable set are never pulled in by the upward walk.
217        state.insert(start.to_string(), 2);
218        if self.is_local(start) {
219            out.push(start.to_string());
220        }
221
222        let mut members: Vec<&String> = reachable.iter().filter(|id| *id != start).collect();
223        members.sort();
224
225        for id in members {
226            self.visit_up_within(id, &reachable, &mut state, &mut out);
227        }
228
229        out
230    }
231
232    /// Post-order DFS restricted to `allowed`, so ordering a subgraph cannot
233    /// drag in conversations from outside it.
234    fn visit_up_within(
235        &self,
236        id: &str,
237        allowed: &HashSet<String>,
238        state: &mut HashMap<String, u8>,
239        out: &mut Vec<String>,
240    ) {
241        match state.get(id) {
242            Some(_) => return,
243            None => state.insert(id.to_string(), 1),
244        };
245
246        if let Some(parents) = self.parents.get(id) {
247            for parent in parents {
248                if allowed.contains(parent) && self.is_local(parent) {
249                    self.visit_up_within(parent, allowed, state, out);
250                }
251            }
252        }
253
254        state.insert(id.to_string(), 2);
255
256        if self.is_local(id) {
257            out.push(id.to_string());
258        }
259    }
260
261    /// Ancestors of `start` that this project does not hold, so a provenance
262    /// document can name what it was unable to include.
263    pub fn absent_ancestors(&self, start: &str) -> Vec<String> {
264        let local = self.ancestry(start);
265        let mut out = BTreeSet::new();
266
267        for id in local.iter().map(|s| s.as_str()).chain(std::iter::once(start)) {
268            if let Some(parents) = self.parents.get(id) {
269                for parent in parents {
270                    if !self.is_local(parent) {
271                        out.insert(parent.clone());
272                    }
273                }
274            }
275        }
276
277        out.into_iter().collect()
278    }
279
280    /// Direct local parents of `id`, for the lineage map at the head of a
281    /// provenance document.
282    pub fn parents_of(&self, id: &str) -> Vec<String> {
283        self.parents
284            .get(id)
285            .map(|set| set.iter().filter(|p| self.is_local(p)).cloned().collect())
286            .unwrap_or_default()
287    }
288
289    /// Every local conversation, sorted.
290    pub fn ids(&self) -> Vec<String> {
291        self.titles.keys().cloned().collect()
292    }
293
294    /// Parents of `id`, including ones this project does not hold.
295    pub fn all_parents(&self, id: &str) -> Vec<String> {
296        self.parents
297            .get(id)
298            .map(|set| set.iter().cloned().collect())
299            .unwrap_or_default()
300    }
301
302    /// Children of `id`, including ones this project does not hold.
303    pub fn all_children(&self, id: &str) -> Vec<String> {
304        self.children
305            .get(id)
306            .map(|set| set.iter().cloned().collect())
307            .unwrap_or_default()
308    }
309
310    /// Edges between two local conversations asserted from one side only.
311    ///
312    /// Compares the raw claims, never the union — in the union a one-sided
313    /// claim has already been mirrored for traversal, so checking it there
314    /// could never report anything. Edges whose far end is absent are skipped:
315    /// there is no local file that could hold the mirror.
316    pub fn asymmetric(&self) -> Vec<(String, String)> {
317        let mut out = BTreeSet::new();
318
319        for (parent, kids) in &self.claimed_children {
320            for child in kids {
321                if !self.is_local(parent) || !self.is_local(child) {
322                    continue;
323                }
324                let mirrored = self
325                    .claimed_parents
326                    .get(child)
327                    .map(|set| set.contains(parent))
328                    .unwrap_or(false);
329                if !mirrored {
330                    out.insert((parent.clone(), child.clone()));
331                }
332            }
333        }
334
335        for (child, parents) in &self.claimed_parents {
336            for parent in parents {
337                if !self.is_local(parent) || !self.is_local(child) {
338                    continue;
339                }
340                let mirrored = self
341                    .claimed_children
342                    .get(parent)
343                    .map(|set| set.contains(child))
344                    .unwrap_or(false);
345                if !mirrored {
346                    out.insert((parent.clone(), child.clone()));
347                }
348            }
349        }
350
351        out.into_iter().collect()
352    }
353
354    /// Conversation ids that nothing local holds as a parent.
355    pub fn dangling(&self) -> Vec<String> {
356        self.parents
357            .keys()
358            .chain(self.children.keys())
359            .chain(self.parents.values().flatten())
360            .chain(self.children.values().flatten())
361            .filter(|id| !self.is_local(id))
362            .cloned()
363            .collect::<BTreeSet<_>>()
364            .into_iter()
365            .collect()
366    }
367
368    /// Flatten the project into display order: roots first, children nested
369    /// beneath them, siblings in the order given by `order`.
370    ///
371    /// `order` is the caller's preferred sequence (newest first, typically);
372    /// it decides which root comes first and how siblings sort, while the
373    /// lineage decides the nesting. Conversations no root reaches — every
374    /// member of a cycle — are appended at the margin so nothing disappears.
375    pub fn forest(&self, order: &[String]) -> Vec<TreeEntry> {
376        let mut out = Vec::new();
377        let mut visited: HashSet<String> = HashSet::new();
378
379        for id in order {
380            if !self.is_local(id) || self.has_local_parent(id) {
381                continue;
382            }
383            self.descend(id, 0, order, &mut visited, &mut out);
384        }
385
386        for id in order {
387            if self.is_local(id) && !visited.contains(id) {
388                self.descend(id, 0, order, &mut visited, &mut out);
389            }
390        }
391
392        out
393    }
394
395    fn descend(
396        &self,
397        id: &str,
398        depth: usize,
399        order: &[String],
400        visited: &mut HashSet<String>,
401        out: &mut Vec<TreeEntry>,
402    ) {
403        let repeat = !visited.insert(id.to_string());
404
405        out.push(TreeEntry {
406            id: id.to_string(),
407            depth,
408            repeat,
409            orphan_parent: depth == 0 && self.has_only_absent_parents(id),
410        });
411
412        if repeat {
413            return;
414        }
415
416        for child in self.ordered_local_children(id, order) {
417            self.descend(&child, depth + 1, order, visited, out);
418        }
419    }
420
421    fn has_local_parent(&self, id: &str) -> bool {
422        self.parents
423            .get(id)
424            .map(|set| set.iter().any(|p| self.is_local(p)))
425            .unwrap_or(false)
426    }
427
428    fn has_only_absent_parents(&self, id: &str) -> bool {
429        match self.parents.get(id) {
430            Some(set) if !set.is_empty() => set.iter().all(|p| !self.is_local(p)),
431            _ => false,
432        }
433    }
434
435    fn ordered_local_children(&self, id: &str, order: &[String]) -> Vec<String> {
436        let Some(kids) = self.children.get(id) else {
437            return Vec::new();
438        };
439
440        let mut out: Vec<String> = kids.iter().filter(|k| self.is_local(k)).cloned().collect();
441
442        out.sort_by_key(|k| order.iter().position(|o| o == k).unwrap_or(usize::MAX));
443        out
444    }
445}
446
447fn string_list(value: &Value, key: &str) -> Vec<String> {
448    value[key]
449        .as_array()
450        .map(|arr| {
451            arr.iter()
452                .filter_map(|v| v.as_str().map(|s| s.to_string()))
453                .collect()
454        })
455        .unwrap_or_default()
456}