Skip to main content

fur_cli/schema/
diary.rs

1//! Registry-neutral snapshots of a FUR project diary.
2//!
3//! A diary is not a metadata object. It is the collection of canonical
4//! conversation directories beneath one project's `chats/` directory.
5
6use std::collections::BTreeSet;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11use serde_json::{json, Value};
12use sha2::{Digest, Sha256};
13
14use crate::schema::document::parse;
15use crate::schema::rebuild::{is_locked_archive, spine_paths, LOCK_CHECK, LOCK_SENTINEL};
16use crate::schema::snapshot::{build_publish_intent, Snapshot};
17
18const LEGACY_DIARY_METADATA: &str = ".fur-diary.json";
19
20#[derive(Debug, Clone, Serialize)]
21pub struct DiaryPublishIntent {
22    pub publish_schema: &'static str,
23    pub source: Value,
24    pub snapshot: DiarySnapshot,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct DiarySnapshot {
29    pub digest_algorithm: &'static str,
30    pub digest: String,
31    pub conversations: Vec<DiaryConversation>,
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct DiaryConversation {
36    pub folder: String,
37    pub source: Value,
38    pub snapshot: Snapshot,
39}
40
41/// Remove metadata emitted by the superseded diary implementation.
42///
43/// The file was generated by FUR itself and is not part of the canonical
44/// archive. An unknown file with the same name is left untouched.
45pub fn remove_legacy_diary_metadata(root: &Path) -> Result<bool, String> {
46    let path = root.join("chats").join(LEGACY_DIARY_METADATA);
47    if !path.exists() {
48        return Ok(false);
49    }
50    let metadata = fs::symlink_metadata(&path)
51        .map_err(|e| format!("cannot inspect {}: {}", path.display(), e))?;
52    if !metadata.is_file() || metadata.file_type().is_symlink() {
53        return Err(format!(
54            "refusing to remove non-regular legacy metadata: {}",
55            path.display()
56        ));
57    }
58    let text = fs::read_to_string(&path)
59        .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
60    let value: Value = serde_json::from_str(&text)
61        .map_err(|_| format!("{} is not recognized FUR diary metadata", path.display()))?;
62    if value["diary_schema"] != 1
63        || !value["diary_id"].is_string()
64        || !value["title"].is_string()
65        || !value["created_at"].is_string()
66    {
67        return Err(format!(
68            "{} is not recognized FUR diary metadata; remove it manually if appropriate",
69            path.display()
70        ));
71    }
72    fs::remove_file(&path)
73        .map_err(|e| format!("cannot remove obsolete {}: {}", path.display(), e))?;
74    Ok(true)
75}
76
77pub fn build_diary_publish_intent(root: &Path) -> Result<DiaryPublishIntent, String> {
78    if is_locked_archive(root) {
79        return Err("archive is locked; run `fur unlock` first".to_string());
80    }
81
82    let chats = root.join("chats");
83    if !chats.is_dir() {
84        return Err("no chats/ diary found".to_string());
85    }
86
87    let conversations = collect_conversations(root, &chats)?;
88    if conversations.is_empty() {
89        return Err("no conversation documents found under chats/".to_string());
90    }
91
92    let suggested_name = root
93        .canonicalize()
94        .unwrap_or_else(|_| root.to_path_buf())
95        .file_name()
96        .and_then(|value| value.to_str())
97        .filter(|value| !value.is_empty())
98        .unwrap_or("FUR Diary")
99        .to_string();
100    let digest = diary_digest(&conversations);
101
102    Ok(DiaryPublishIntent {
103        publish_schema: "fur.registry.diary.publish.v1",
104        source: json!({
105            "format": "fur.project-directory",
106            "suggested_name": suggested_name,
107            "producer": {"fur_version": env!("CARGO_PKG_VERSION")}
108        }),
109        snapshot: DiarySnapshot {
110            digest_algorithm: "sha256-diary-manifest-v1",
111            digest,
112            conversations,
113        },
114    })
115}
116
117fn collect_conversations(root: &Path, chats: &Path) -> Result<Vec<DiaryConversation>, String> {
118    let spines = spine_paths(root);
119    let mut by_folder: Vec<(String, PathBuf)> = Vec::new();
120    let mut seen_ids = BTreeSet::new();
121
122    let mut entries = fs::read_dir(chats)
123        .map_err(|e| format!("cannot read {}: {}", chats.display(), e))?
124        .collect::<Result<Vec<_>, _>>()
125        .map_err(|e| format!("cannot read diary entry: {}", e))?;
126    entries.sort_by_key(|entry| entry.file_name());
127
128    for entry in entries {
129        let path = entry.path();
130        let metadata = fs::symlink_metadata(&path)
131            .map_err(|e| format!("cannot inspect {}: {}", path.display(), e))?;
132        if metadata.file_type().is_symlink() {
133            return Err(format!("refusing to publish symlink: {}", path.display()));
134        }
135        if metadata.is_file() {
136            let name = entry.file_name();
137            let name = name.to_string_lossy();
138            if name == LOCK_SENTINEL || name == LOCK_CHECK {
139                return Err("diary security metadata cannot be published".to_string());
140            }
141            return Err(format!(
142                "unsupported file directly under chats/: {}",
143                path.display()
144            ));
145        }
146        if !metadata.is_dir() {
147            return Err(format!("unsupported diary entry: {}", path.display()));
148        }
149
150        let matching: Vec<&PathBuf> = spines
151            .iter()
152            .filter(|spine| spine.parent() == Some(path.as_path()))
153            .collect();
154        if matching.len() != 1 {
155            return Err(format!(
156                "{} must contain exactly one canonical conversation document",
157                path.display()
158            ));
159        }
160        let folder = entry
161            .file_name()
162            .into_string()
163            .map_err(|_| format!("conversation folder is not UTF-8: {}", path.display()))?;
164        by_folder.push((folder, matching[0].clone()));
165    }
166
167    let mut conversations = Vec::new();
168    for (folder, spine) in by_folder {
169        let text = fs::read_to_string(&spine)
170            .map_err(|e| format!("cannot read {}: {}", spine.display(), e))?;
171        let doc = parse(&text).map_err(|e| format!("invalid {}: {}", spine.display(), e))?;
172        if !seen_ids.insert(doc.conversation_id.clone()) {
173            return Err(format!(
174                "duplicate conversation id in diary: {}",
175                doc.conversation_id
176            ));
177        }
178        let intent = build_publish_intent(root, Some(&doc.conversation_id))?;
179        conversations.push(DiaryConversation {
180            folder,
181            source: intent.source,
182            snapshot: intent.snapshot,
183        });
184    }
185    Ok(conversations)
186}
187
188pub fn diary_digest(conversations: &[DiaryConversation]) -> String {
189    let mut hasher = Sha256::new();
190    for conversation in conversations {
191        hasher.update(conversation.folder.as_bytes());
192        hasher.update([0]);
193        hasher.update(
194            conversation.source["conversation_id"]
195                .as_str()
196                .unwrap_or("")
197                .as_bytes(),
198        );
199        hasher.update([0]);
200        hasher.update(conversation.snapshot.digest.as_bytes());
201        hasher.update(b"\n");
202    }
203    format!("{:x}", hasher.finalize())
204}