1use 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
41pub 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}