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,
}
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())
}