1use std::collections::HashSet;
12use std::fmt::Write as _;
13use std::fs;
14use std::path::Path;
15
16use serde_json::{json, Value};
17
18use crate::renderer::utils::load_message;
19use crate::schema::lineage::Lineage;
20
21#[derive(Clone, Copy, PartialEq, Eq)]
23pub enum Scope {
24 Ancestors,
26 Full,
28}
29
30pub struct Options {
31 pub scope: Scope,
32 pub contents: bool,
34}
35
36pub fn render(
38 fur_dir: &Path,
39 target: &str,
40 options: &Options,
41) -> Result<(String, usize), String> {
42 let lineage = Lineage::load(fur_dir)?;
43
44 if !lineage.is_local(target) {
45 return Err(format!("conversation {} is not in this project", short(target)));
46 }
47
48 let avatars: Value = fs::read_to_string(fur_dir.join("avatars.json"))
49 .ok()
50 .and_then(|c| serde_json::from_str(&c).ok())
51 .unwrap_or_else(|| json!({}));
52
53 let mut order = lineage.ancestry(target);
54
55 if options.scope == Scope::Full {
56 let seen: HashSet<String> = order.iter().cloned().collect();
57 for id in lineage.descendants(target) {
58 if !seen.contains(&id) {
59 order.push(id);
60 }
61 }
62 }
63
64 let mut out = String::new();
65
66 write_header(&mut out, &lineage, target, &order);
67 write_map(&mut out, &lineage, &order, target);
68
69 for id in &order {
70 write_conversation(&mut out, fur_dir, &lineage, &avatars, id, target, options);
71 }
72
73 write_absent(&mut out, &lineage, target);
74
75 Ok((out, order.len()))
76}
77
78fn write_header(out: &mut String, lineage: &Lineage, target: &str, order: &[String]) {
79 let title = lineage.title(target).unwrap_or("Untitled");
80
81 let _ = writeln!(out, "# {}", title);
82 let _ = writeln!(out);
83 let _ = writeln!(
84 out,
85 "_Provenance record — {} conversation(s), assembled by FUR._",
86 order.len()
87 );
88 let _ = writeln!(out);
89}
90
91fn write_map(out: &mut String, lineage: &Lineage, order: &[String], target: &str) {
93 if order.len() < 2 {
94 return;
95 }
96
97 let _ = writeln!(out, "## Lineage");
98 let _ = writeln!(out);
99
100 for id in order {
101 let marker = if id == target { " ← this record" } else { "" };
102 let parents = lineage.parents_of(id);
103
104 let from = if parents.is_empty() {
105 String::new()
106 } else {
107 let names: Vec<String> = parents
108 .iter()
109 .map(|p| lineage.title(p).unwrap_or("Untitled").to_string())
110 .collect();
111 format!(" — from {}", names.join(", "))
112 };
113
114 let _ = writeln!(
115 out,
116 "- **{}** `{}`{}{}",
117 lineage.title(id).unwrap_or("Untitled"),
118 short(id),
119 from,
120 marker
121 );
122 }
123
124 let _ = writeln!(out);
125 let _ = writeln!(out, "---");
126 let _ = writeln!(out);
127}
128
129fn write_conversation(
130 out: &mut String,
131 fur_dir: &Path,
132 lineage: &Lineage,
133 avatars: &Value,
134 id: &str,
135 target: &str,
136 options: &Options,
137) {
138 let title = lineage.title(id).unwrap_or("Untitled");
139 let marker = if id == target { " ← this record" } else { "" };
140
141 let _ = writeln!(out, "## {}{}", title, marker);
142 let _ = writeln!(out);
143 let _ = writeln!(out, "`{}`", id);
144 let _ = writeln!(out);
145
146 let convo_path = fur_dir.join("threads").join(format!("{}.json", id));
147 let Some(content) = crate::security::io::read_text_file(&convo_path) else {
148 let _ = writeln!(out, "_(unreadable)_");
149 let _ = writeln!(out);
150 return;
151 };
152 let Ok(convo) = serde_json::from_str::<Value>(&content) else {
153 let _ = writeln!(out, "_(unreadable)_");
154 let _ = writeln!(out);
155 return;
156 };
157
158 let empty: Vec<Value> = Vec::new();
159 let message_ids = convo["messages"].as_array().unwrap_or(&empty);
160
161 if message_ids.is_empty() {
162 let _ = writeln!(out, "_(no messages)_");
163 let _ = writeln!(out);
164 return;
165 }
166
167 for mid in message_ids {
168 let Some(mid) = mid.as_str() else { continue };
169 let Some(msg) = load_message(fur_dir, mid, avatars) else {
170 continue;
171 };
172
173 let _ = writeln!(out, "**{}** · {} {}", msg.name, msg.date_str, msg.time_str);
174 let _ = writeln!(out);
175
176 if !msg.text.trim().is_empty() && msg.text != "<no content>" {
177 let _ = writeln!(out, "{}", msg.text);
178 let _ = writeln!(out);
179 }
180
181 if let Some(path) = &msg.markdown {
182 if options.contents {
183 match fs::read_to_string(path) {
184 Ok(body) => {
185 let _ = writeln!(out, "{}", body.trim_end());
186 let _ = writeln!(out);
187 }
188 Err(_) => {
189 let _ = writeln!(out, "_(attachment missing: {})_", path);
190 let _ = writeln!(out);
191 }
192 }
193 } else {
194 let name = Path::new(path)
195 .file_name()
196 .and_then(|f| f.to_str())
197 .unwrap_or(path);
198 let _ = writeln!(out, "_(attachment: {})_", name);
199 let _ = writeln!(out);
200 }
201 }
202 }
203
204 let _ = writeln!(out, "---");
205 let _ = writeln!(out);
206}
207
208fn write_absent(out: &mut String, lineage: &Lineage, target: &str) {
210 let absent = lineage.absent_ancestors(target);
211 if absent.is_empty() {
212 return;
213 }
214
215 let _ = writeln!(out, "## Not included");
216 let _ = writeln!(out);
217 let _ = writeln!(
218 out,
219 "These conversations are referenced as sources but are not in this project:"
220 );
221 let _ = writeln!(out);
222
223 for id in absent {
224 let _ = writeln!(out, "- `{}`", id);
225 }
226
227 let _ = writeln!(out);
228}
229
230fn short(id: &str) -> &str {
231 if id.len() >= 8 {
232 &id[..8]
233 } else {
234 id
235 }
236}