1use std::fs;
17use std::path::{Path, PathBuf};
18
19use clap::Parser;
20use colored::*;
21use serde_json::{json, Value};
22
23use crate::schema::bridge::sync_conversation;
24
25#[derive(Parser, Debug)]
26pub struct LinkArgs {
27 pub id: Option<String>,
29
30 #[arg(long, conflicts_with = "child")]
32 pub parent: Option<String>,
33
34 #[arg(long)]
36 pub child: Option<String>,
37}
38
39#[derive(Clone, Copy, PartialEq, Eq)]
41pub enum Edge {
42 Parent,
43 Child,
44}
45
46impl Edge {
47 fn field(self) -> &'static str {
49 match self {
50 Edge::Parent => "parents",
51 Edge::Child => "children",
52 }
53 }
54
55 fn inverse_field(self) -> &'static str {
57 match self {
58 Edge::Parent => "children",
59 Edge::Child => "parents",
60 }
61 }
62
63 fn describe(self) -> &'static str {
64 match self {
65 Edge::Parent => "parent",
66 Edge::Child => "child",
67 }
68 }
69}
70
71#[derive(Debug, PartialEq, Eq)]
73pub enum Outcome {
74 Added,
75 Removed,
76 AlreadyPresent,
77 NotPresent,
78}
79
80pub fn run_link(args: LinkArgs) {
81 dispatch(args, false);
82}
83
84pub fn run_unlink(args: LinkArgs) {
85 dispatch(args, true);
86}
87
88fn dispatch(args: LinkArgs, remove: bool) {
89 let edge = match (&args.parent, &args.child) {
90 (Some(_), None) => Edge::Parent,
91 (None, Some(_)) => Edge::Child,
92 (None, None) => {
93 eprintln!("❌ Specify --parent <id> or --child <id>.");
94 return;
95 }
96 (Some(_), Some(_)) => {
97 eprintln!("❌ Pass one of --parent or --child, not both.");
98 return;
99 }
100 };
101
102 let other_ref = args
103 .parent
104 .as_deref()
105 .or(args.child.as_deref())
106 .unwrap_or_default();
107
108 match apply(Path::new("."), args.id.as_deref(), other_ref, edge, remove) {
109 Ok(report) => report.print(),
110 Err(e) => eprintln!("❌ {}", e),
111 }
112}
113
114#[derive(Debug)]
115pub struct LinkReport {
116 edge: &'static str,
117 target: String,
118 target_title: String,
119 other: String,
120 other_title: Option<String>,
121 near: Outcome,
122 far: Option<Outcome>,
123}
124
125impl LinkReport {
126 fn print(&self) {
127 let other_label = match &self.other_title {
128 Some(title) => format!("{} \"{}\"", short(&self.other), title),
129 None => format!("{} {}", short(&self.other), "(no local copy)".bright_black()),
130 };
131
132 match self.near {
133 Outcome::Added => println!(
134 "🔗 {} \"{}\" — {} → {}",
135 short(&self.target).bright_yellow(),
136 self.target_title,
137 self.edge.bright_cyan(),
138 other_label
139 ),
140 Outcome::Removed => println!(
141 "✂️ {} \"{}\" — removed {} {}",
142 short(&self.target).bright_yellow(),
143 self.target_title,
144 self.edge.bright_cyan(),
145 other_label
146 ),
147 Outcome::AlreadyPresent => println!(
148 "• Already linked: {} is a {} of {}",
149 short(&self.other),
150 self.edge,
151 short(&self.target)
152 ),
153 Outcome::NotPresent => println!(
154 "• Not linked: {} is not a {} of {}",
155 short(&self.other),
156 self.edge,
157 short(&self.target)
158 ),
159 }
160
161 match &self.far {
162 Some(Outcome::Added) | Some(Outcome::Removed) => {
163 println!(" {}", "reciprocal edge updated".bright_black())
164 }
165 None => println!(
166 " {}",
167 "one-sided: the other conversation is not in this project".bright_black()
168 ),
169 _ => {}
170 }
171 }
172}
173
174pub fn apply(
179 root: &Path,
180 target_ref: Option<&str>,
181 other_ref: &str,
182 edge: Edge,
183 remove: bool,
184) -> Result<LinkReport, String> {
185 let fur_dir = root.join(".fur");
186 let index = read_json(&fur_dir.join("index.json"))?;
187 let ids = thread_ids(&index);
188
189 let target = match target_ref {
190 Some(prefix) => resolve_local(&ids, prefix)?
191 .ok_or_else(|| format!("no conversation matches '{}'", prefix))?,
192 None => index["active_thread"]
193 .as_str()
194 .filter(|s| !s.is_empty())
195 .ok_or("no active conversation; pass a conversation id")?
196 .to_string(),
197 };
198
199 let (other, other_is_local) = match resolve_local(&ids, other_ref)? {
200 Some(id) => (id, true),
201 None if looks_like_conversation_id(other_ref) => (other_ref.to_string(), false),
202 None => {
203 return Err(format!(
204 "no conversation matches '{}'; pass a full conversation id to link one that is not in this project",
205 other_ref
206 ))
207 }
208 };
209
210 if target == other {
211 return Err(format!(
212 "{} \"{}\" cannot be its own parent or child — name the other conversation explicitly, e.g. `fur link <id> --child {}`",
213 short(&target),
214 title_of(&fur_dir, &target).unwrap_or_else(|| "Untitled".to_string()),
215 short(&other)
216 ));
217 }
218
219 if !remove {
223 let (parent, child) = match edge {
224 Edge::Parent => (other.as_str(), target.as_str()),
225 Edge::Child => (target.as_str(), other.as_str()),
226 };
227
228 if let Ok(lineage) = crate::schema::lineage::Lineage::load(&fur_dir) {
229 if lineage.would_cycle(parent, child) {
230 return Err(format!(
231 "that would loop: {} already leads back to {}",
232 short(child),
233 short(parent)
234 ));
235 }
236 }
237 }
238
239 let near = edit_edge(&fur_dir, &target, edge.field(), &other, remove)?;
240 let far = if other_is_local {
241 Some(edit_edge(
242 &fur_dir,
243 &other,
244 edge.inverse_field(),
245 &target,
246 remove,
247 )?)
248 } else {
249 None
250 };
251
252 if matches!(near, Outcome::Added | Outcome::Removed) {
254 sync_conversation(root, &fur_dir, &target)?;
255 }
256 if matches!(far, Some(Outcome::Added) | Some(Outcome::Removed)) {
257 sync_conversation(root, &fur_dir, &other)?;
258 }
259
260 Ok(LinkReport {
261 edge: edge.describe(),
262 target_title: title_of(&fur_dir, &target).unwrap_or_else(|| "Untitled".to_string()),
263 other_title: title_of(&fur_dir, &other),
264 target,
265 other,
266 near,
267 far,
268 })
269}
270
271fn edit_edge(
273 fur_dir: &Path,
274 tid: &str,
275 field: &str,
276 value: &str,
277 remove: bool,
278) -> Result<Outcome, String> {
279 let path = thread_path(fur_dir, tid);
280 let mut convo = read_json(&path)?;
281
282 let mut list: Vec<String> = convo[field]
283 .as_array()
284 .map(|arr| {
285 arr.iter()
286 .filter_map(|v| v.as_str().map(|s| s.to_string()))
287 .collect()
288 })
289 .unwrap_or_default();
290
291 let present = list.iter().any(|item| item == value);
292
293 if remove {
294 if !present {
295 return Ok(Outcome::NotPresent);
296 }
297 list.retain(|item| item != value);
298 } else {
299 if present {
300 return Ok(Outcome::AlreadyPresent);
301 }
302 list.push(value.to_string());
303 }
304
305 list.sort();
308 list.dedup();
309 convo[field] = json!(list);
310
311 fs::write(&path, pretty(&convo))
312 .map_err(|e| format!("cannot write {}: {}", path.display(), e))?;
313
314 Ok(if remove { Outcome::Removed } else { Outcome::Added })
315}
316
317fn thread_ids(index: &Value) -> Vec<String> {
318 index["threads"]
319 .as_array()
320 .map(|arr| {
321 arr.iter()
322 .filter_map(|v| v.as_str().map(|s| s.to_string()))
323 .collect()
324 })
325 .unwrap_or_default()
326}
327
328fn resolve_local(ids: &[String], needle: &str) -> Result<Option<String>, String> {
330 if needle.trim().is_empty() {
331 return Err("empty conversation reference".to_string());
332 }
333
334 if let Some(hit) = ids.iter().find(|id| id.as_str() == needle) {
335 return Ok(Some(hit.clone()));
336 }
337
338 let matches: Vec<&String> = ids.iter().filter(|id| id.starts_with(needle)).collect();
339
340 match matches.as_slice() {
341 [] => Ok(None),
342 [single] => Ok(Some((*single).clone())),
343 _ => Err(format!(
344 "ambiguous conversation prefix '{}': {:?}",
345 needle,
346 matches.iter().map(|m| short(m)).collect::<Vec<_>>()
347 )),
348 }
349}
350
351fn looks_like_conversation_id(raw: &str) -> bool {
354 raw.len() >= 32 && raw.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
355}
356
357fn title_of(fur_dir: &Path, tid: &str) -> Option<String> {
358 let convo = read_json(&thread_path(fur_dir, tid)).ok()?;
359 convo["title"].as_str().map(|s| s.to_string())
360}
361
362fn thread_path(fur_dir: &Path, tid: &str) -> PathBuf {
363 fur_dir.join("threads").join(format!("{}.json", tid))
364}
365
366fn read_json(path: &Path) -> Result<Value, String> {
367 let content = fs::read_to_string(path)
368 .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
369 serde_json::from_str(&content)
370 .map_err(|e| format!("invalid JSON in {}: {}", path.display(), e))
371}
372
373fn pretty(value: &Value) -> String {
374 serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
375}
376
377fn short(id: &str) -> &str {
378 if id.len() >= 8 {
379 &id[..8]
380 } else {
381 id
382 }
383}
384
385#[allow(dead_code)]
395pub fn edge_parent() -> Edge {
396 Edge::Parent
397}
398
399#[allow(dead_code)]
400pub fn edge_child() -> Edge {
401 Edge::Child
402}
403
404#[allow(dead_code)]
405impl LinkReport {
406 pub fn near_outcome(&self) -> &Outcome {
407 &self.near
408 }
409
410 pub fn far_outcome(&self) -> Option<&Outcome> {
411 self.far.as_ref()
412 }
413}