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 a FUR project diary.
//!
//! A diary is not a metadata object. It is the collection of canonical
//! conversation directories beneath one project's `chats/` directory.

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

use serde::Serialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};

use crate::schema::document::parse;
use crate::schema::rebuild::{is_locked_archive, spine_paths, LOCK_CHECK, LOCK_SENTINEL};
use crate::schema::snapshot::{build_publish_intent, Snapshot};

const LEGACY_DIARY_METADATA: &str = ".fur-diary.json";

#[derive(Debug, Clone, Serialize)]
pub struct DiaryPublishIntent {
    pub publish_schema: &'static str,
    pub source: Value,
    pub snapshot: DiarySnapshot,
}

#[derive(Debug, Clone, Serialize)]
pub struct DiarySnapshot {
    pub digest_algorithm: &'static str,
    pub digest: String,
    pub conversations: Vec<DiaryConversation>,
}

#[derive(Debug, Clone, Serialize)]
pub struct DiaryConversation {
    pub folder: String,
    pub source: Value,
    pub snapshot: Snapshot,
}

/// Remove metadata emitted by the superseded diary implementation.
///
/// The file was generated by FUR itself and is not part of the canonical
/// archive. An unknown file with the same name is left untouched.
pub fn remove_legacy_diary_metadata(root: &Path) -> Result<bool, String> {
    let path = root.join("chats").join(LEGACY_DIARY_METADATA);
    if !path.exists() {
        return Ok(false);
    }
    let metadata = fs::symlink_metadata(&path)
        .map_err(|e| format!("cannot inspect {}: {}", path.display(), e))?;
    if !metadata.is_file() || metadata.file_type().is_symlink() {
        return Err(format!(
            "refusing to remove non-regular legacy metadata: {}",
            path.display()
        ));
    }
    let text = fs::read_to_string(&path)
        .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
    let value: Value = serde_json::from_str(&text)
        .map_err(|_| format!("{} is not recognized FUR diary metadata", path.display()))?;
    if value["diary_schema"] != 1
        || !value["diary_id"].is_string()
        || !value["title"].is_string()
        || !value["created_at"].is_string()
    {
        return Err(format!(
            "{} is not recognized FUR diary metadata; remove it manually if appropriate",
            path.display()
        ));
    }
    fs::remove_file(&path)
        .map_err(|e| format!("cannot remove obsolete {}: {}", path.display(), e))?;
    Ok(true)
}

pub fn build_diary_publish_intent(root: &Path) -> Result<DiaryPublishIntent, String> {
    if is_locked_archive(root) {
        return Err("archive is locked; run `fur unlock` first".to_string());
    }

    let chats = root.join("chats");
    if !chats.is_dir() {
        return Err("no chats/ diary found".to_string());
    }

    let conversations = collect_conversations(root, &chats)?;
    if conversations.is_empty() {
        return Err("no conversation documents found under chats/".to_string());
    }

    let suggested_name = root
        .canonicalize()
        .unwrap_or_else(|_| root.to_path_buf())
        .file_name()
        .and_then(|value| value.to_str())
        .filter(|value| !value.is_empty())
        .unwrap_or("FUR Diary")
        .to_string();
    let digest = diary_digest(&conversations);

    Ok(DiaryPublishIntent {
        publish_schema: "fur.registry.diary.publish.v1",
        source: json!({
            "format": "fur.project-directory",
            "suggested_name": suggested_name,
            "producer": {"fur_version": env!("CARGO_PKG_VERSION")}
        }),
        snapshot: DiarySnapshot {
            digest_algorithm: "sha256-diary-manifest-v1",
            digest,
            conversations,
        },
    })
}

fn collect_conversations(root: &Path, chats: &Path) -> Result<Vec<DiaryConversation>, String> {
    let spines = spine_paths(root);
    let mut by_folder: Vec<(String, PathBuf)> = Vec::new();
    let mut seen_ids = BTreeSet::new();

    let mut entries = fs::read_dir(chats)
        .map_err(|e| format!("cannot read {}: {}", chats.display(), e))?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| format!("cannot read diary entry: {}", e))?;
    entries.sort_by_key(|entry| entry.file_name());

    for entry in entries {
        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_file() {
            let name = entry.file_name();
            let name = name.to_string_lossy();
            if name == LOCK_SENTINEL || name == LOCK_CHECK {
                return Err("diary security metadata cannot be published".to_string());
            }
            return Err(format!(
                "unsupported file directly under chats/: {}",
                path.display()
            ));
        }
        if !metadata.is_dir() {
            return Err(format!("unsupported diary entry: {}", path.display()));
        }

        let matching: Vec<&PathBuf> = spines
            .iter()
            .filter(|spine| spine.parent() == Some(path.as_path()))
            .collect();
        if matching.len() != 1 {
            return Err(format!(
                "{} must contain exactly one canonical conversation document",
                path.display()
            ));
        }
        let folder = entry
            .file_name()
            .into_string()
            .map_err(|_| format!("conversation folder is not UTF-8: {}", path.display()))?;
        by_folder.push((folder, matching[0].clone()));
    }

    let mut conversations = Vec::new();
    for (folder, spine) in by_folder {
        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 !seen_ids.insert(doc.conversation_id.clone()) {
            return Err(format!(
                "duplicate conversation id in diary: {}",
                doc.conversation_id
            ));
        }
        let intent = build_publish_intent(root, Some(&doc.conversation_id))?;
        conversations.push(DiaryConversation {
            folder,
            source: intent.source,
            snapshot: intent.snapshot,
        });
    }
    Ok(conversations)
}

pub fn diary_digest(conversations: &[DiaryConversation]) -> String {
    let mut hasher = Sha256::new();
    for conversation in conversations {
        hasher.update(conversation.folder.as_bytes());
        hasher.update([0]);
        hasher.update(
            conversation.source["conversation_id"]
                .as_str()
                .unwrap_or("")
                .as_bytes(),
        );
        hasher.update([0]);
        hasher.update(conversation.snapshot.digest.as_bytes());
        hasher.update(b"\n");
    }
    format!("{:x}", hasher.finalize())
}