Skip to main content

fur_cli/commands/
provenance.rs

1//! Assemble a conversation and its lineage into one Markdown document.
2//!
3//! The unit of value is not the conversation, it is the reasoning behind a
4//! conclusion — which routinely spans several conversations and several
5//! people. This renders that whole chain as one file a reader can open without
6//! knowing FUR exists.
7//!
8//! Ancestors come first, in dependency order, so the document reads the way
9//! the work happened: what it was built on, then the thing itself.
10
11use 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/// How much of the graph to include.
22#[derive(Clone, Copy, PartialEq, Eq)]
23pub enum Scope {
24    /// The conversation and everything it draws on.
25    Ancestors,
26    /// Ancestors, plus everything that draws on it.
27    Full,
28}
29
30pub struct Options {
31    pub scope: Scope,
32    /// Include the text of long-form attachments inline.
33    pub contents: bool,
34}
35
36/// Build the document. Returns the Markdown and the conversations it covered.
37pub 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
91/// A short map at the head, so a reader knows the shape before the content.
92fn 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
208/// Name what the document could not include, rather than leaving a silent gap.
209fn 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}