Skip to main content

fur_cli/commands/
graph.rs

1//! Export the project's conversation lineage as JSON, with layout.
2//!
3//! The CLI computes the graph; it does not draw it. Layering and crossing
4//! reduction are the expensive, reusable part, so they ship in the payload —
5//! a front end can honour `x`/`y` directly, use `layer` and run its own
6//! placement, or ignore both and use the edges alone.
7//!
8//! # Determinism
9//!
10//! The same archive always produces the same coordinates. Every ordering step
11//! breaks ties by conversation id, and no step consults the clock or a random
12//! source. A layout that reshuffles between runs cannot be diffed, cached, or
13//! trusted by anything downstream.
14
15use std::collections::{BTreeMap, HashMap, HashSet};
16use std::fs;
17use std::path::Path;
18
19use clap::Parser;
20use colored::*;
21use serde_json::{json, Value};
22
23use crate::schema::lineage::Lineage;
24
25#[derive(Parser, Debug)]
26pub struct GraphArgs {
27    /// Write to a file instead of stdout
28    #[arg(long)]
29    pub out: Option<String>,
30
31    /// Omit computed x/y coordinates, keeping layers and edges
32    #[arg(long)]
33    pub no_layout: bool,
34}
35
36/// Abstract units. A renderer scales these; they are not pixels.
37const NODE_W: f64 = 210.0;
38const NODE_H: f64 = 54.0;
39const COL_GAP: f64 = 34.0;
40const ROW_GAP: f64 = 118.0;
41const MARGIN: f64 = 48.0;
42
43pub struct Placed {
44    pub id: String,
45    pub layer: usize,
46    pub x: f64,
47    pub y: f64,
48}
49
50pub fn run_graph(args: GraphArgs) {
51    let fur_dir = Path::new(".fur");
52
53    if !fur_dir.join("threads").exists() {
54        eprintln!("🚨 .fur/ not found. Run `fur new` first.");
55        return;
56    }
57
58    let lineage = match Lineage::load(fur_dir) {
59        Ok(l) => l,
60        Err(e) => return eprintln!("❌ {}", e),
61    };
62
63    if lineage.ids().is_empty() {
64        eprintln!("📭 No conversations yet.");
65        return;
66    }
67
68    let payload = build(fur_dir, &lineage, !args.no_layout);
69    let text = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
70
71    let Some(path) = args.out else {
72        println!("{}", text);
73        return;
74    };
75
76    match fs::write(&path, format!("{}\n", text)) {
77        Ok(_) => println!(
78            "{}",
79            format!(
80                "🌐 Lineage: {} conversation(s) → {}",
81                lineage.ids().len(),
82                path
83            )
84            .bright_green()
85            .bold()
86        ),
87        Err(e) => eprintln!("❌ Could not write {}: {}", path, e),
88    }
89}
90
91/// Assemble the export document.
92pub fn build(fur_dir: &Path, lineage: &Lineage, with_layout: bool) -> Value {
93    let placed = layout(lineage);
94    let meta = read_metadata(fur_dir, lineage);
95
96    let nodes: Vec<Value> = placed
97        .iter()
98        .map(|node| {
99            let mut entry = json!({
100                "id": node.id,
101                "title": lineage.title(&node.id),
102                "local": lineage.is_local(&node.id),
103                "layer": node.layer,
104                "parents": lineage.all_parents(&node.id),
105                "children": lineage.all_children(&node.id)
106            });
107
108            if with_layout {
109                entry["x"] = json!(node.x);
110                entry["y"] = json!(node.y);
111            }
112
113            if let Some(info) = meta.get(node.id.as_str()) {
114                entry["created_at"] = info.created.clone();
115                entry["updated_at"] = info.updated.clone();
116                entry["messages"] = json!(info.messages);
117                entry["tags"] = info.tags.clone();
118            }
119
120            entry
121        })
122        .collect();
123
124    let mut edges: Vec<(String, String)> = Vec::new();
125    for node in &placed {
126        for child in lineage.all_children(&node.id) {
127            edges.push((node.id.clone(), child));
128        }
129    }
130    edges.sort();
131    edges.dedup();
132
133    let edges: Vec<Value> = edges
134        .into_iter()
135        .map(|(from, to)| {
136            json!({
137                "from": from,
138                "to": to,
139                // False when either end is absent: the edge is real, but one
140                // side of it is not in this project.
141                "resolved": lineage.is_local(&from) && lineage.is_local(&to)
142            })
143        })
144        .collect();
145
146    let mut payload = json!({
147        "graph_schema": "fur.lineage.v1",
148        "nodes": nodes,
149        "edges": edges,
150        "dangling": lineage.dangling(),
151        "asymmetric": lineage
152            .asymmetric()
153            .into_iter()
154            .map(|(parent, child)| json!({ "parent": parent, "child": child }))
155            .collect::<Vec<_>>()
156    });
157
158    if with_layout {
159        let width = placed.iter().map(|n| n.x + NODE_W).fold(0.0, f64::max) + MARGIN;
160        let height = placed.iter().map(|n| n.y + NODE_H).fold(0.0, f64::max) + MARGIN;
161
162        payload["canvas"] = json!({
163            "width": width,
164            "height": height,
165            "node_width": NODE_W,
166            "node_height": NODE_H
167        });
168    }
169
170    payload
171}
172
173struct Meta {
174    created: Value,
175    updated: Value,
176    messages: usize,
177    tags: Value,
178}
179
180/// Per-conversation detail the lineage index does not carry.
181fn read_metadata<'a>(fur_dir: &Path, lineage: &'a Lineage) -> HashMap<String, Meta> {
182    let mut out = HashMap::new();
183
184    for id in lineage.ids() {
185        let path = fur_dir.join("threads").join(format!("{}.json", id));
186        let Some(content) = crate::security::io::read_text_file(&path) else {
187            continue;
188        };
189        let Ok(convo) = serde_json::from_str::<Value>(&content) else {
190            continue;
191        };
192
193        let messages = convo["messages"].as_array().map(|a| a.len()).unwrap_or(0);
194
195        // `updated_at` is derived from the last message when the spine is
196        // written, so it is recomputed here rather than read from `.fur/`,
197        // which does not store it.
198        let updated = last_message_ts(fur_dir, &convo);
199
200        out.insert(
201            id.clone(),
202            Meta {
203                created: convo["created_at"].clone(),
204                updated,
205                messages,
206                tags: convo["tags"].clone(),
207            },
208        );
209    }
210
211    out
212}
213
214fn last_message_ts(fur_dir: &Path, convo: &Value) -> Value {
215    let Some(ids) = convo["messages"].as_array() else {
216        return Value::Null;
217    };
218
219    let mut best: Option<String> = None;
220
221    for mid in ids.iter().filter_map(|v| v.as_str()) {
222        let path = fur_dir.join("messages").join(format!("{}.json", mid));
223        let Some(content) = crate::security::io::read_text_file(&path) else {
224            continue;
225        };
226        let Ok(msg) = serde_json::from_str::<Value>(&content) else {
227            continue;
228        };
229        let Some(ts) = msg["timestamp"].as_str() else {
230            continue;
231        };
232
233        let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(ts) else {
234            continue;
235        };
236
237        let better = best
238            .as_deref()
239            .and_then(|b| chrono::DateTime::parse_from_rfc3339(b).ok())
240            .map(|current| parsed > current)
241            .unwrap_or(true);
242
243        if better {
244            best = Some(ts.to_string());
245        }
246    }
247
248    match best {
249        Some(ts) => json!(ts),
250        None => Value::Null,
251    }
252}
253
254// ======================================================
255//  LAYOUT
256// ======================================================
257
258/// Assign every node a layer and a position within it.
259pub fn layout(lineage: &Lineage) -> Vec<Placed> {
260    // Local conversations plus the absent ones they reference, so a renderer
261    // can draw an imported conversation's missing sources rather than dropping
262    // edges that lead nowhere.
263    let mut ids: Vec<String> = lineage.ids();
264    ids.extend(lineage.dangling());
265    ids.sort();
266    ids.dedup();
267
268    let layers = assign_layers(lineage, &ids);
269    let depth = layers.values().copied().max().unwrap_or(0) + 1;
270
271    let mut rows: Vec<Vec<String>> = vec![Vec::new(); depth];
272    for id in &ids {
273        rows[layers[id]].push(id.clone());
274    }
275    for row in rows.iter_mut() {
276        row.sort();
277    }
278
279    order_rows(lineage, &mut rows);
280
281    let widest = rows.iter().map(|r| r.len()).max().unwrap_or(1) as f64;
282    let canvas_w = widest * NODE_W + (widest - 1.0).max(0.0) * COL_GAP;
283
284    let mut out = Vec::new();
285
286    for (layer, row) in rows.iter().enumerate() {
287        let count = row.len() as f64;
288        let row_w = count * NODE_W + (count - 1.0).max(0.0) * COL_GAP;
289        let start = MARGIN + (canvas_w - row_w) / 2.0;
290
291        for (i, id) in row.iter().enumerate() {
292            out.push(Placed {
293                id: id.clone(),
294                layer,
295                x: start + i as f64 * (NODE_W + COL_GAP),
296                y: MARGIN + layer as f64 * (NODE_H + ROW_GAP),
297            });
298        }
299    }
300
301    out
302}
303
304/// `layer(n) = 0` when nothing points at `n`, else one past its deepest parent.
305///
306/// The in-progress guard makes an imported cycle terminate: a back edge
307/// contributes nothing rather than recursing forever.
308fn assign_layers(lineage: &Lineage, ids: &[String]) -> BTreeMap<String, usize> {
309    let mut layers: BTreeMap<String, usize> = BTreeMap::new();
310    let mut visiting: HashSet<String> = HashSet::new();
311
312    for id in ids {
313        depth_of(lineage, id, &mut layers, &mut visiting);
314    }
315
316    layers
317}
318
319fn depth_of(
320    lineage: &Lineage,
321    id: &str,
322    layers: &mut BTreeMap<String, usize>,
323    visiting: &mut HashSet<String>,
324) -> usize {
325    if let Some(&known) = layers.get(id) {
326        return known;
327    }
328    if !visiting.insert(id.to_string()) {
329        return 0;
330    }
331
332    let mut best = 0usize;
333    for parent in lineage.all_parents(id) {
334        best = best.max(depth_of(lineage, &parent, layers, visiting) + 1);
335    }
336
337    visiting.remove(id);
338    layers.insert(id.to_string(), best);
339    best
340}
341
342/// Reduce edge crossings by pulling each node toward the average position of
343/// its parents. Two passes is enough at this scale, and ties break by id so the
344/// result stays deterministic.
345fn order_rows(lineage: &Lineage, rows: &mut [Vec<String>]) {
346    for _ in 0..2 {
347        for layer in 1..rows.len() {
348            let above: HashMap<&str, usize> = rows[layer - 1]
349                .iter()
350                .enumerate()
351                .map(|(i, id)| (id.as_str(), i))
352                .collect();
353
354            let mut keyed: Vec<(f64, String)> = rows[layer]
355                .iter()
356                .map(|id| {
357                    let positions: Vec<usize> = lineage
358                        .all_parents(id)
359                        .iter()
360                        .filter_map(|p| above.get(p.as_str()).copied())
361                        .collect();
362
363                    let key = if positions.is_empty() {
364                        f64::MAX
365                    } else {
366                        positions.iter().sum::<usize>() as f64 / positions.len() as f64
367                    };
368
369                    (key, id.clone())
370                })
371                .collect();
372
373            keyed.sort_by(|a, b| {
374                a.0.partial_cmp(&b.0)
375                    .unwrap_or(std::cmp::Ordering::Equal)
376                    .then_with(|| a.1.cmp(&b.1))
377            });
378
379            rows[layer] = keyed.into_iter().map(|(_, id)| id).collect();
380        }
381    }
382}