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
//! Publish canonical FUR conversations and project diaries to a registry.

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

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

use crate::schema::diary::{
    build_diary_publish_intent, remove_legacy_diary_metadata, DiaryPublishIntent,
};
use crate::schema::snapshot::{
    build_publish_intent, write_published_receipt, PublishIntent, PublishedReceipt,
};

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

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

pub fn run_publish(conversation: Option<&str>, diary: Option<&str>, registry: &str) {
    if let Some(selector) = diary {
        run_diary_publish(selector, registry);
        return;
    }
    let result = build_publish_intent(Path::new("."), conversation).and_then(|intent| {
        let created = submit_publish_intent(registry, &intent)?;
        // Cursor for the next publish. Written even for "unchanged", so a
        // copy that was published by an older client picks one up.
        write_published_receipt(
            &intent.folder,
            &PublishedReceipt {
                registry_url: registry.trim_end_matches('/').to_string(),
                registry_id: created.registry_id.clone(),
                publication_id: created.publication_id.clone(),
                revision_id: created.revision_id.clone(),
                snapshot_digest: created.snapshot_digest.clone(),
                published_at: created.published_at.clone(),
            },
        )?;
        Ok(created)
    });
    match result {
        Ok(created) => print_receipt("Conversation", &created),
        Err(error) => eprintln!("❌ Registry publication failed: {}", error),
    }
}

fn run_diary_publish(selector: &str, registry: &str) {
    let root = match resolve_diary_root(Path::new("."), selector) {
        Ok(root) => root,
        Err(error) => {
            eprintln!("❌ Registry diary publication failed: {}", error);
            return;
        }
    };

    let result = remove_legacy_diary_metadata(&root)
        .and_then(|removed| {
            if removed {
                println!("  Removed obsolete chats/.fur-diary.json metadata");
            }
            build_diary_publish_intent(&root)
        })
        .and_then(|mut intent| {
            if let Some(cursor) = load_diary_cursor(&root, registry)? {
                intent.source["registry_cursor"] = json!({
                    "registry_id": cursor.registry_id,
                    "publication_id": cursor.publication_id,
                    "revision_id": cursor.revision_id
                });
            }
            let created = submit_diary_publish_intent(registry, &intent)?;
            save_diary_cursor(&root, registry, &created)?;
            Ok(created)
        });

    match result {
        Ok(created) => print_receipt("Diary", &created),
        Err(error) => eprintln!("❌ Registry diary publication failed: {}", error),
    }
}

fn resolve_diary_root(start: &Path, selector: &str) -> Result<PathBuf, String> {
    let selected = if selector.trim().is_empty() || selector == "." {
        start.to_path_buf()
    } else {
        let candidate = PathBuf::from(selector);
        if candidate.is_absolute() {
            candidate
        } else {
            start.join(candidate)
        }
    };
    if !selected.join("chats").is_dir() {
        return Err(format!(
            "{} is not a FUR diary (missing chats/)",
            selected.display()
        ));
    }
    Ok(selected)
}

fn cursor_path(root: &Path, registry: &str) -> PathBuf {
    let mut hasher = Sha256::new();
    hasher.update(registry.trim_end_matches('/').as_bytes());
    let key = format!("{:x}", hasher.finalize());
    root.join(".fur")
        .join("registry")
        .join("diaries")
        .join(format!("{}.json", &key[..16]))
}

fn load_diary_cursor(
    root: &Path,
    registry: &str,
) -> Result<Option<DiaryRegistryCursor>, String> {
    let path = cursor_path(root, registry);
    if !path.exists() {
        return Ok(None);
    }
    let text = fs::read_to_string(&path)
        .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
    let cursor: DiaryRegistryCursor = serde_json::from_str(&text)
        .map_err(|e| format!("invalid {}: {}", path.display(), e))?;
    if cursor.registry_url != registry.trim_end_matches('/') {
        return Err(format!("registry cursor mismatch in {}", path.display()));
    }
    Ok(Some(cursor))
}

fn save_diary_cursor(
    root: &Path,
    registry: &str,
    created: &PublicationCreated,
) -> Result<(), String> {
    let path = cursor_path(root, registry);
    let parent = path.parent().ok_or("registry cursor has no parent directory")?;
    fs::create_dir_all(parent)
        .map_err(|e| format!("cannot create {}: {}", parent.display(), e))?;
    let cursor = DiaryRegistryCursor {
        registry_url: registry.trim_end_matches('/').to_string(),
        publication_id: created.publication_id.clone(),
        revision_id: created.revision_id.clone(),
        registry_id: created.registry_id.clone(),
        snapshot_digest: created.snapshot_digest.clone(),
        published_at: created.published_at.clone(),
    };
    let text = serde_json::to_string_pretty(&cursor)
        .map_err(|e| format!("cannot serialize diary registry cursor: {}", e))?;
    fs::write(&path, format!("{}\n", text))
        .map_err(|e| format!("cannot write {}: {}", path.display(), e))
}

pub(crate) fn save_imported_diary_cursor(
    root: &Path,
    registry: &str,
    publication_id: &str,
    revision_id: &str,
    registry_id: &str,
    snapshot_digest: &str,
    published_at: &str,
) -> Result<(), String> {
    save_diary_cursor(
        root,
        registry,
        &PublicationCreated {
            publication_id: publication_id.to_string(),
            revision_id: revision_id.to_string(),
            registry_id: registry_id.to_string(),
            snapshot_digest: snapshot_digest.to_string(),
            published_at: published_at.to_string(),
            publication_state: "imported".to_string(),
        },
    )
}

fn print_receipt(kind: &str, created: &PublicationCreated) {
    match (kind, created.publication_state.as_str()) {
        ("Diary", "unchanged") => println!("✔ Diary already published; no changes"),
        ("Diary", "revised") => println!("✔ Published new diary revision"),
        ("Diary", _) => println!("✔ Published diary"),
        (_, "unchanged") => println!("✔ Conversation already published; no changes"),
        (_, "revised") => println!("✔ Published new conversation revision"),
        (_, "forked") => println!("✔ Published as a fork; the original line was continued by someone else"),
        _ => println!("✔ Published conversation"),
    }
    println!("  Publication: {}", created.publication_id);
    println!("  Revision:    {}", created.revision_id);
    println!("  Registry:    {}", created.registry_id);
    println!("  Snapshot:    {}", created.snapshot_digest);
    println!("  Published:   {}", created.published_at);
}

fn submit_publish_intent(
    registry: &str,
    intent: &PublishIntent,
) -> Result<PublicationCreated, String> {
    submit_intent(registry, "/api/v2/publish", intent)
}

fn submit_diary_publish_intent(
    registry: &str,
    intent: &DiaryPublishIntent,
) -> Result<PublicationCreated, String> {
    submit_intent(registry, "/api/v2/diaries/publish", intent)
}

fn submit_intent<T: Serialize>(
    registry: &str,
    endpoint: &str,
    intent: &T,
) -> Result<PublicationCreated, String> {
    let url = format!("{}{}", registry.trim_end_matches('/'), endpoint);
    let response = reqwest::blocking::Client::new()
        .post(&url)
        .json(intent)
        .send()
        .map_err(|e| format!("cannot reach {}: {}", url, e))?;
    if !response.status().is_success() {
        let status = response.status();
        let detail = response.text().unwrap_or_default();
        return Err(format!("registry returned HTTP {}: {}", status, detail));
    }
    response
        .json::<PublicationCreated>()
        .map_err(|e| format!("invalid publication receipt: {}", e))
}