1use 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 #[arg(long)]
29 pub out: Option<String>,
30
31 #[arg(long)]
33 pub no_layout: bool,
34}
35
36const 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
91pub 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 "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
180fn 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 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
254pub fn layout(lineage: &Lineage) -> Vec<Placed> {
260 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
304fn 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
342fn 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}