Skip to main content

fur_cli/commands/
link.rs

1//! Conversation-level lineage: `fur link` / `fur unlink`.
2//!
3//! Edges are stored in `.fur/threads/<id>.json` under `parents` / `children`
4//! and carried out to `chats/<slug>/convo.md` by the usual sync. `.fur/` is
5//! written first and remains authoritative; the document is regenerated from
6//! it, exactly as `jot` does.
7//!
8//! # Reciprocity
9//!
10//! `parents` and `children` are *assertions*, not two halves of one record.
11//! When both conversations are local, linking writes both ends. When the other
12//! end is not local — imported from a registry, or simply not pulled yet — only
13//! this end is written, and that is a complete, valid state rather than a
14//! failure. `fur doctor` reports asymmetry; it does not treat it as damage.
15
16use 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    /// Conversation to modify (id or unique prefix). Defaults to the active one.
28    pub id: Option<String>,
29
30    /// Conversation this one derives from
31    #[arg(long, conflicts_with = "child")]
32    pub parent: Option<String>,
33
34    /// Conversation that derives from this one
35    #[arg(long)]
36    pub child: Option<String>,
37}
38
39/// Which side of the edge the user named.
40#[derive(Clone, Copy, PartialEq, Eq)]
41pub enum Edge {
42    Parent,
43    Child,
44}
45
46impl Edge {
47    /// Field on the conversation being modified.
48    fn field(self) -> &'static str {
49        match self {
50            Edge::Parent => "parents",
51            Edge::Child => "children",
52        }
53    }
54
55    /// Field on the conversation at the far end of the edge.
56    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/// What a single-sided edit actually did.
72#[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
174/// Add or remove one edge, writing both ends when both are local.
175///
176/// `root` is explicit so this is testable without changing the process
177/// directory.
178pub 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    // A loop can only be *created* locally, so it is refused here rather than in
220    // the format. An archive can still contain one after importing two halves
221    // published independently, which is why every walk keeps a visited set.
222    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    // Regenerate only the documents that actually changed.
253    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
271/// Mutate one conversation's edge list in `.fur/`.
272fn 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    // Canonical on disk as well as in the document, so `.fur/` and `convo.md`
306    // never disagree about ordering.
307    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
328/// Exact match first, then unique prefix — the same rule `fur convo` uses.
329fn 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
351/// A reference to a conversation this project does not hold is only accepted
352/// in full, so a typo cannot quietly become a dangling edge.
353fn 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// --- test seam -------------------------------------------------------------
386//
387// `Edge` is an implementation detail of argument parsing, but the
388// integration test needs to name a direction without going through clap.
389//
390// The binary compiles the command modules separately from the library, where
391// this seam is intentionally unused — the same `#[allow(dead_code)]` situation
392// as `registry::install_pull_value`.
393
394#[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}