Skip to main content

fur_cli/commands/
printed.rs

1use crate::commands::provenance::{self, Options, Scope};
2use crate::commands::timeline::{run_timeline, TimelineArgs};
3use colored::*;
4use serde_json::Value;
5use std::fs;
6use std::path::Path;
7
8/// `fur printed` — exports the active conversation to Markdown or PDF.
9///
10/// With `--provenance`, exports the conversation together with the lineage it
11/// draws on, as one Markdown document.
12pub fn run_printed(
13    out: Option<String>,
14    verbose: bool,
15    provenance_scope: Option<Scope>,
16    id: Option<String>,
17) {
18    let fur_dir = Path::new(".fur");
19    let index_path = fur_dir.join("index.json");
20
21    if !index_path.exists() {
22        eprintln!("🚨 .fur/ not found. Run `fur new` first.");
23        return;
24    }
25
26    let index: Value = serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
27
28    let target = match resolve_target(&index, id.as_deref()) {
29        Ok(t) => t,
30        Err(e) => return eprintln!("❌ {}", e),
31    };
32
33    if let Some(scope) = provenance_scope {
34        return run_provenance(fur_dir, &target, scope, out, verbose);
35    }
36
37    run_single(fur_dir, &target, out, verbose);
38}
39
40/// Resolve an explicit id or prefix, falling back to the active conversation.
41fn resolve_target(index: &Value, requested: Option<&str>) -> Result<String, String> {
42    let threads: Vec<String> = index["threads"]
43        .as_array()
44        .map(|arr| {
45            arr.iter()
46                .filter_map(|v| v.as_str().map(|s| s.to_string()))
47                .collect()
48        })
49        .unwrap_or_default();
50
51    let Some(needle) = requested else {
52        return index["active_thread"]
53            .as_str()
54            .filter(|s| !s.is_empty())
55            .map(|s| s.to_string())
56            .ok_or_else(|| "No active conversation found.".to_string());
57    };
58
59    if threads.iter().any(|t| t == needle) {
60        return Ok(needle.to_string());
61    }
62
63    let matches: Vec<&String> = threads.iter().filter(|t| t.starts_with(needle)).collect();
64
65    match matches.as_slice() {
66        [] => Err(format!("No conversation matches '{}'", needle)),
67        [single] => Ok((*single).clone()),
68        _ => Err(format!("Ambiguous prefix '{}'", needle)),
69    }
70}
71
72/// The original behaviour: one conversation, Markdown or PDF.
73fn run_single(fur_dir: &Path, target: &str, out: Option<String>, verbose: bool) {
74    let convo_path = fur_dir.join("threads").join(format!("{}.json", target));
75    let conversation_json: Value =
76        serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
77
78    let title = conversation_json["title"].as_str().unwrap_or("untitled");
79
80    let out_path = out.unwrap_or_else(|| default_name(title, target, "md"));
81    let is_pdf = out_path.to_lowercase().ends_with(".pdf");
82
83    let args = TimelineArgs {
84        verbose,
85        contents: true,
86        out: Some(out_path.clone()),
87        conversation_override: Some(target.to_string()),
88    };
89
90    println!(
91        "{}",
92        format!("🖨️  Printing conversation: {} ({}) → {}", title, target, out_path)
93            .bright_green()
94            .bold()
95    );
96
97    run_timeline(args);
98
99    println!(
100        "{}",
101        if is_pdf {
102            format!("✔️  Exported PDF: {}", out_path)
103        } else {
104            format!("✔️  Exported Markdown: {}", out_path)
105        }
106        .bright_green()
107        .bold()
108    );
109}
110
111/// The conversation plus its lineage, as one Markdown document.
112fn run_provenance(
113    fur_dir: &Path,
114    target: &str,
115    scope: Scope,
116    out: Option<String>,
117    verbose: bool,
118) {
119    let options = Options {
120        scope,
121        contents: verbose,
122    };
123
124    let (body, count) = match provenance::render(fur_dir, target, &options) {
125        Ok(result) => result,
126        Err(e) => return eprintln!("❌ {}", e),
127    };
128
129    let convo_path = fur_dir.join("threads").join(format!("{}.json", target));
130    let title = fs::read_to_string(&convo_path)
131        .ok()
132        .and_then(|c| serde_json::from_str::<Value>(&c).ok())
133        .and_then(|v| v["title"].as_str().map(|s| s.to_string()))
134        .unwrap_or_else(|| "untitled".to_string());
135
136    let out_path = out.unwrap_or_else(|| default_name(&format!("{}_PROVENANCE", title), target, "md"));
137
138    if out_path.to_lowercase().ends_with(".pdf") {
139        eprintln!("❌ Provenance records are Markdown only.");
140        return;
141    }
142
143    if let Err(e) = fs::write(&out_path, body) {
144        return eprintln!("❌ Could not write {}: {}", out_path, e);
145    }
146
147    println!(
148        "{}",
149        format!(
150            "🧬 Provenance record: {} conversation(s) → {}",
151            count, out_path
152        )
153        .bright_green()
154        .bold()
155    );
156}
157
158/// ALLCAPS_TITLE_SHORTID.ext at the project root — a printed record, not part
159/// of the archive.
160fn default_name(title: &str, id: &str, ext: &str) -> String {
161    let all_caps = title.to_uppercase().replace(' ', "_");
162    let short_id = id.split('-').next().unwrap_or(id);
163    format!("{}_{}.{}", all_caps, short_id, ext)
164}