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
//! Registry-neutral snapshots of canonical FUR conversation directories.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Component, Path, PathBuf};

use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};

use crate::schema::document::{parse, FurDocument};
use crate::schema::rebuild::spine_paths;

const ORIGIN_FILENAME: &str = ".fur-origin.json";
/// Where this copy was last published, written beside the document so it
/// survives a `.fur/` rebuild. Excluded from the snapshot like the origin
/// receipt: it describes the record's history, it is not the record.
const PUBLISHED_FILENAME: &str = ".fur-published.json";

#[derive(Debug, Clone, Serialize)]
pub struct PublishIntent {
    pub publish_schema: &'static str,
    pub source: Value,
    pub snapshot: Snapshot,
    /// The conversation folder, so the caller can write a receipt next to it.
    #[serde(skip)]
    pub folder: PathBuf,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishedReceipt {
    pub registry_url: String,
    pub registry_id: String,
    pub publication_id: String,
    pub revision_id: String,
    pub snapshot_digest: String,
    pub published_at: String,
}

pub fn write_published_receipt(folder: &Path, receipt: &PublishedReceipt) -> Result<(), String> {
    let path = folder.join(PUBLISHED_FILENAME);
    let text = serde_json::to_string_pretty(receipt)
        .map_err(|e| format!("cannot serialize publish receipt: {}", e))?;
    fs::write(&path, format!("{}\n", text))
        .map_err(|e| format!("cannot write {}: {}", path.display(), e))
}

#[derive(Debug, Clone, Serialize)]
pub struct Snapshot {
    pub digest_algorithm: &'static str,
    pub digest: String,
    pub spine_path: String,
    pub files: Vec<SnapshotFile>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SnapshotFile {
    pub path: String,
    pub media_type: String,
    pub size: u64,
    pub sha256: String,
    pub encoding: &'static str,
    pub content: String,
}

#[derive(Debug, Deserialize)]
struct OriginReceipt {
    registry_id: String,
    publication_id: String,
    revision_id: String,
    source_conversation_id: String,
    snapshot_digest: String,
}

pub fn build_publish_intent(root: &Path, selector: Option<&str>) -> Result<PublishIntent, String> {
    let (folder, spine, doc) = resolve_conversation(root, selector)?;
    let mut files = collect_snapshot_files(&folder)?;
    files.sort_by(|left, right| left.path.cmp(&right.path));

    let spine_path = relative_path(&folder, &spine)?;
    validate_document_files(&doc, &files)?;
    let digest = manifest_digest(&files);
    let source = source_metadata(&folder, &doc)?;

    Ok(PublishIntent {
        publish_schema: "fur.registry.publish.v1",
        source,
        snapshot: Snapshot {
            digest_algorithm: "sha256-manifest-v1",
            digest,
            spine_path,
            files,
        },
        folder,
    })
}

fn validate_document_files(doc: &FurDocument, files: &[SnapshotFile]) -> Result<(), String> {
    let hashes: BTreeMap<&str, &str> = files
        .iter()
        .map(|file| (file.path.as_str(), file.sha256.as_str()))
        .collect();

    for message in &doc.messages {
        for reference in [&message.link, &message.img].into_iter().flatten() {
            if !hashes.contains_key(reference.as_str()) {
                return Err(format!("conversation references missing file: {}", reference));
            }
        }
        if let (Some(link), Some(expected)) = (&message.link, &message.sha256) {
            if hashes.get(link.as_str()).copied() != Some(expected.as_str()) {
                return Err(format!("linked file hash does not match convo.md: {}", link));
            }
        }
    }
    Ok(())
}

fn resolve_conversation(
    root: &Path,
    selector: Option<&str>,
) -> Result<(PathBuf, PathBuf, FurDocument), String> {
    let selected = match selector {
        Some(value) if !value.trim().is_empty() => value.trim().to_string(),
        _ => active_conversation_id(root)?,
    };

    let mut matches = Vec::new();
    for spine in spine_paths(root) {
        let text = fs::read_to_string(&spine)
            .map_err(|e| format!("cannot read {}: {}", spine.display(), e))?;
        let doc = parse(&text).map_err(|e| format!("invalid {}: {}", spine.display(), e))?;
        if doc.conversation_id == selected || doc.conversation_id.starts_with(&selected) {
            let folder = spine
                .parent()
                .ok_or_else(|| format!("{} has no conversation folder", spine.display()))?
                .to_path_buf();
            matches.push((folder, spine, doc));
        }
    }

    match matches.len() {
        0 => Err(format!("no conversation matches '{}'", selected)),
        1 => Ok(matches.remove(0)),
        _ => Err(format!("conversation prefix '{}' is ambiguous", selected)),
    }
}

fn active_conversation_id(root: &Path) -> Result<String, String> {
    let path = root.join(".fur").join("index.json");
    let text = fs::read_to_string(&path)
        .map_err(|_| "no active conversation; pass a conversation hash".to_string())?;
    let index: Value = serde_json::from_str(&text)
        .map_err(|e| format!("cannot parse {}: {}", path.display(), e))?;
    index["active_thread"]
        .as_str()
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .ok_or_else(|| "no active conversation; pass a conversation hash".to_string())
}

fn collect_snapshot_files(folder: &Path) -> Result<Vec<SnapshotFile>, String> {
    let mut paths = Vec::new();
    collect_paths(folder, &mut paths)?;

    let mut files = Vec::new();
    for path in paths {
        let relative = relative_path(folder, &path)?;
        if relative == ORIGIN_FILENAME || relative == PUBLISHED_FILENAME {
            continue;
        }
        files.push(snapshot_file(folder, &path)?);
    }
    Ok(files)
}

pub(crate) fn snapshot_file(root: &Path, path: &Path) -> Result<SnapshotFile, String> {
    let relative = relative_path(root, path)?;
    let bytes = fs::read(path).map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
    let (encoding, content) = match String::from_utf8(bytes.clone()) {
        Ok(text) => ("utf-8", text),
        Err(_) => ("base64", BASE64.encode(&bytes)),
    };
    Ok(SnapshotFile {
        path: relative.clone(),
        media_type: media_type(&relative).to_string(),
        size: bytes.len() as u64,
        sha256: sha256(&bytes),
        encoding,
        content,
    })
}

fn collect_paths(directory: &Path, paths: &mut Vec<PathBuf>) -> Result<(), String> {
    let entries = fs::read_dir(directory)
        .map_err(|e| format!("cannot read {}: {}", directory.display(), e))?;
    for entry in entries {
        let entry = entry.map_err(|e| format!("cannot read directory entry: {}", e))?;
        let path = entry.path();
        let metadata = fs::symlink_metadata(&path)
            .map_err(|e| format!("cannot inspect {}: {}", path.display(), e))?;
        if metadata.file_type().is_symlink() {
            return Err(format!("refusing to publish symlink: {}", path.display()));
        }
        if metadata.is_dir() {
            collect_paths(&path, paths)?;
        } else if metadata.is_file() {
            paths.push(path);
        }
    }
    Ok(())
}

fn source_metadata(folder: &Path, doc: &FurDocument) -> Result<Value, String> {
    let origin_path = folder.join(ORIGIN_FILENAME);

    // Lineage is carried in `source`, not in the snapshot, because `source` is
    // the queryable metadata channel: a registry can index and graph these
    // edges without parsing every spine it holds.
    //
    // The edges are *also* inside `convo.md`, which is part of the digest, so
    // linking a published conversation legitimately produces a new revision.
    // That is the intended reading — the document is the record, and the record
    // changed.
    //
    // Edges name conversation ids, not publication ids. A referenced
    // conversation that was never published leaves an edge pointing at nothing,
    // which is partial knowledge rather than corruption, exactly as it is
    // locally.
    
    // Authorship travels beside lineage, for the same reason: a registry can
    // index and display who spoke without parsing every spine it holds.
    //
    // Derived from the `avatar=` markers actually present in the document —
    // ground truth for who spoke — not from whoever ran `fur publish`. Humans
    // and AI share one list, discriminated by `kind`, because the document
    // format makes no distinction and consumers should choose presentation.
    // `role` appears only when configured.
    //
    // `source` is not hashed, so enriching authorship never forces a revision.
    let source = json!({
        "format": "fur.conversation-directory",
        "fur_document_schema": doc.schema,
        "conversation_id": doc.conversation_id,
        "origin_kind": "local",
        "lineage": {
            "parents": doc.parents,
            "children": doc.children
        },
        "authors": document_authors(folder, doc),
        "producer": {"fur_version": env!("CARGO_PKG_VERSION")}
    });
    let mut source = source;

    if origin_path.exists() {
        let text = fs::read_to_string(&origin_path)
            .map_err(|e| format!("cannot read {}: {}", origin_path.display(), e))?;
        let receipt: OriginReceipt = serde_json::from_str(&text)
            .map_err(|e| format!("invalid {}: {}", origin_path.display(), e))?;
        if receipt.source_conversation_id != doc.conversation_id {
            return Err("origin receipt does not match this conversation".to_string());
        }
        source["origin_kind"] = json!("registry-import");
        source["import_origin"] = json!({
            "registry_id": receipt.registry_id,
            "publication_id": receipt.publication_id,
            "revision_id": receipt.revision_id,
            "source_conversation_id": receipt.source_conversation_id,
            "snapshot_digest": receipt.snapshot_digest
        });
    }

    // The cursor says which revision this copy is a continuation of. A
    // registry uses it to tell a fast-forward from a fork; the import origin
    // above is the same claim for a copy that has never been republished.
    let published_path = folder.join(PUBLISHED_FILENAME);
    if published_path.exists() {
        let text = fs::read_to_string(&published_path)
            .map_err(|e| format!("cannot read {}: {}", published_path.display(), e))?;
        let receipt: PublishedReceipt = serde_json::from_str(&text)
            .map_err(|e| format!("invalid {}: {}", published_path.display(), e))?;
        source["publish_cursor"] = json!({
            "registry_url": receipt.registry_url,
            "registry_id": receipt.registry_id,
            "publication_id": receipt.publication_id,
            "revision_id": receipt.revision_id,
            "snapshot_digest": receipt.snapshot_digest
        });
    }
    Ok(source)
}

/// The distinct avatars that speak in this document, sorted for determinism.
///
/// Reads `.fur/avatars.json` when present, purely to enrich; an archive with no
/// `.fur/` still publishes correct authorship from the markers alone.
fn document_authors(folder: &Path, doc: &FurDocument) -> Vec<Value> {
    let avatars = folder
        .parent()
        .and_then(|chats| chats.parent())
        .map(|root| root.join(".fur").join("avatars.json"))
        .and_then(|path| fs::read_to_string(path).ok())
        .and_then(|text| serde_json::from_str::<Value>(&text).ok())
        .unwrap_or_else(|| json!({}));

    let mut names: Vec<&str> = doc
        .messages
        .iter()
        .map(|message| message.avatar.as_str())
        .filter(|name| !name.trim().is_empty())
        .collect();
    names.sort_unstable();
    names.dedup();
    // Humans first, then AI — a paper lists its authors before its tools.
    names.sort_by_key(|name| crate::avatars::kind_of(&avatars, name) == "ai");

    names
        .into_iter()
        .map(|name| {
            let mut entry = json!({
                "name": name,
                "kind": crate::avatars::kind_of(&avatars, name)
            });
            if let Some(display) = crate::avatars::display_name_of(&avatars, name) {
                entry["display_name"] = json!(display);
            }
            if let Some(role) = crate::avatars::role_of(&avatars, name) {
                entry["role"] = json!(role);
            }
            entry
        })
        .collect()
}

fn relative_path(root: &Path, path: &Path) -> Result<String, String> {
    let relative = path
        .strip_prefix(root)
        .map_err(|_| format!("{} is outside {}", path.display(), root.display()))?;
    if relative.components().any(|part| !matches!(part, Component::Normal(_))) {
        return Err(format!("unsafe snapshot path: {}", relative.display()));
    }
    let parts = relative
        .components()
        .map(|part| {
            part.as_os_str()
                .to_str()
                .ok_or_else(|| format!("snapshot path is not UTF-8: {}", relative.display()))
        })
        .collect::<Result<Vec<_>, _>>()?;
    let normalized = parts.join("/");
    if normalized.contains('\\') {
        return Err(format!("unsafe snapshot path: {}", relative.display()));
    }
    Ok(normalized)
}

fn manifest_digest(files: &[SnapshotFile]) -> String {
    let mut hasher = Sha256::new();
    for file in files {
        hasher.update(file.path.as_bytes());
        hasher.update([0]);
        hasher.update(file.size.to_string().as_bytes());
        hasher.update([0]);
        hasher.update(file.sha256.as_bytes());
        hasher.update(b"\n");
    }
    format!("{:x}", hasher.finalize())
}

fn sha256(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    format!("{:x}", hasher.finalize())
}

fn media_type(path: &str) -> &'static str {
    match Path::new(path).extension().and_then(|value| value.to_str()) {
        Some("md") => "text/markdown",
        Some("json") => "application/json",
        Some("png") => "image/png",
        Some("jpg" | "jpeg") => "image/jpeg",
        Some("gif") => "image/gif",
        Some("webp") => "image/webp",
        Some("svg") => "image/svg+xml",
        Some("pdf") => "application/pdf",
        _ => "application/octet-stream",
    }
}