fur-cli 2.1.1

Turn your AI chats into a durable, local-first diary. Save messages, attach notes, organize conversations, and stop losing context every time the model forgets you exist.
Documentation
//! Lineage edges: reciprocity when both ends are local, one-sided when not.
use std::fs;
use std::path::Path;

use fur_cli::commands::link::{apply, Outcome};
use fur_cli::schema::document::parse;

const A: &str = "aaaaaaaa-1111-4111-8111-111111111111";
const B: &str = "bbbbbbbb-2222-4222-8222-222222222222";
const ABSENT: &str = "ffffffff-9999-4999-8999-999999999999";


fn boot(name: &str) -> std::path::PathBuf {
    let root = std::env::temp_dir().join(name);
    let _ = fs::remove_dir_all(&root);
    fs::create_dir_all(root.join(".fur/threads")).unwrap();
    fs::create_dir_all(root.join(".fur/messages")).unwrap();
    fs::create_dir_all(root.join("chats")).unwrap();

    for (id, title) in [(A, "Plan"), (B, "Weak Points")] {
        fs::write(
            root.join(format!(".fur/messages/{}-m.json", &id[..8])),
            format!(
                r#"{{"id":"{}-m","avatar":"andrew","timestamp":"2026-08-17T00:00:00Z","text":"body","markdown":null,"attachment":null}}"#,
                &id[..8]
            ),
        )
        .unwrap();
        fs::write(
            root.join(format!(".fur/threads/{}.json", id)),
            format!(
                r#"{{"id":"{}","title":"{}","created_at":"2026-08-17T00:00:00Z","messages":["{}-m"],"tags":[],"parents":[],"children":[],"schema_version":"0.3"}}"#,
                id, title, &id[..8]
            ),
        )
        .unwrap();
    }

    fs::write(
        root.join(".fur/index.json"),
        format!(
            r#"{{"threads":["{}","{}"],"active_thread":"{}","current_message":null,"schema_version":"0.3"}}"#,
            A, B, A
        ),
    )
    .unwrap();

    root
}

fn spine(root: &Path, slug_prefix: &str) -> String {
    let chats = root.join("chats");
    for entry in fs::read_dir(&chats).unwrap().flatten() {
        let name = entry.file_name().to_string_lossy().to_string();
        if name.starts_with(slug_prefix) {
            return fs::read_to_string(entry.path().join("convo.md")).unwrap();
        }
    }
    panic!("no conversation folder starting with {}", slug_prefix);
}

#[test]
fn linking_two_local_conversations_writes_both_ends() {
    let root = boot("fur_link_reciprocal");

    let report = apply(&root, Some(A), B, fur_cli::commands::link::edge_child(), false).unwrap();
    assert_eq!(report.near_outcome(), &Outcome::Added);

    let plan = parse(&spine(&root, "plan-")).unwrap();
    let weak = parse(&spine(&root, "weak-points-")).unwrap();

    assert_eq!(plan.children, vec![B.to_string()]);
    assert_eq!(weak.parents, vec![A.to_string()]);
    assert!(plan.parents.is_empty());
}

#[test]
fn linking_an_absent_conversation_is_one_sided_and_legal() {
    let root = boot("fur_link_dangling");

    apply(&root, Some(B), ABSENT, fur_cli::commands::link::edge_parent(), false).unwrap();

    let weak = parse(&spine(&root, "weak-points-")).unwrap();
    assert_eq!(weak.parents, vec![ABSENT.to_string()]);
}

#[test]
fn unlinking_removes_both_ends() {
    let root = boot("fur_link_unlink");

    apply(&root, Some(A), B, fur_cli::commands::link::edge_child(), false).unwrap();
    apply(&root, Some(A), B, fur_cli::commands::link::edge_child(), true).unwrap();

    assert!(parse(&spine(&root, "plan-")).unwrap().children.is_empty());
    assert!(parse(&spine(&root, "weak-points-")).unwrap().parents.is_empty());
}

#[test]
fn self_reference_is_refused() {
    let root = boot("fur_link_self");
    let err = apply(&root, Some(A), A, fur_cli::commands::link::edge_parent(), false).unwrap_err();
    assert!(err.contains("its own"), "got: {}", err);
}

#[test]
fn a_short_typo_is_refused_rather_than_dangling() {
    let root = boot("fur_link_typo");
    let err = apply(&root, Some(A), "zzz", fur_cli::commands::link::edge_child(), false).unwrap_err();
    assert!(err.contains("no conversation matches"), "got: {}", err);
}