Skip to main content

fur_cli/commands/
export.rs

1use std::path::Path;
2
3use clap::Parser;
4use colored::*;
5use serde_json::Value;
6
7use crate::schema::bridge::{document_from_thread, linked_sources, write_conversation_folder};
8
9/// Arguments for `fur export`
10#[derive(Parser, Debug)]
11pub struct ExportArgs {
12    /// Conversation ID or prefix (defaults to the active conversation)
13    #[arg(short, long)]
14    pub id: Option<String>,
15
16    /// Export every conversation in the project
17    #[arg(long)]
18    pub all: bool,
19
20    /// Overwrite an existing convo.md
21    #[arg(long)]
22    pub force: bool,
23}
24
25/// `fur export` — write conversations out as canonical Markdown documents.
26///
27/// Phase A: this is additive. `.fur/` remains the source of truth and is left
28/// untouched; the export exists so the document format can be checked against
29/// real conversations before anything depends on it.
30pub fn run_export(args: ExportArgs) {
31    let fur_dir = Path::new(".fur");
32    let index_path = fur_dir.join("index.json");
33
34    if !index_path.exists() {
35        eprintln!("🚨 .fur/ not found. Run `fur new` first.");
36        return;
37    }
38
39    let Some(content) = crate::security::io::read_text_file(&index_path) else {
40        return;
41    };
42
43    let index: Value = match serde_json::from_str(&content) {
44        Ok(v) => v,
45        Err(e) => return eprintln!("āŒ Invalid index.json: {}", e),
46    };
47
48    let threads: Vec<String> = index["threads"]
49        .as_array()
50        .map(|arr| {
51            arr.iter()
52                .filter_map(|v| v.as_str().map(|s| s.to_string()))
53                .collect()
54        })
55        .unwrap_or_default();
56
57    let targets = match select_targets(&index, &threads, &args) {
58        Some(t) => t,
59        None => return,
60    };
61
62    let project_root = Path::new(".");
63    let mut exported = 0usize;
64
65    for tid in &targets {
66        match export_one(fur_dir, project_root, tid, args.force) {
67            Ok(folder) => {
68                println!("šŸ“„ {}", folder.display().to_string().green());
69                exported += 1;
70            }
71            Err(e) => eprintln!("āŒ {} — {}", &short(tid).bright_black(), e),
72        }
73    }
74
75    println!(
76        "\nāœ”ļø Exported {} of {} conversation(s).",
77        exported,
78        targets.len()
79    );
80
81    report_orphans(project_root);
82}
83
84/// Long-form files sitting in `chats/` root that also exist inside a
85/// conversation folder. Rebuild cannot see them; `lock` still encrypts them.
86fn report_orphans(project_root: &Path) {
87    let orphans = crate::schema::bridge::find_orphans(project_root);
88
89    if orphans.is_empty() {
90        return;
91    }
92
93    println!(
94        "\n{}",
95        "Duplicate long-form files in chats/ root".bright_yellow().bold()
96    );
97
98    for p in &orphans {
99        println!("  • {}", p.display());
100    }
101
102    println!(
103        "{}",
104        "  These are already inside their conversation folder and can be deleted."
105            .bright_black()
106    );
107}
108
109fn export_one(
110    fur_dir: &Path,
111    project_root: &Path,
112    tid: &str,
113    force: bool,
114) -> Result<std::path::PathBuf, String> {
115    let doc = document_from_thread(fur_dir, tid)?;
116    let linked = linked_sources(fur_dir, tid)?;
117
118    write_conversation_folder(project_root, &doc, &linked, force)
119}
120
121/// Resolve which conversations to export: an explicit prefix, everything, or
122/// the active conversation.
123fn select_targets(index: &Value, threads: &[String], args: &ExportArgs) -> Option<Vec<String>> {
124    if args.all {
125        if threads.is_empty() {
126            eprintln!("āš ļø No conversations to export.");
127            return None;
128        }
129        return Some(threads.to_vec());
130    }
131
132    if let Some(prefix) = &args.id {
133        let matches: Vec<&String> = threads.iter().filter(|t| t.starts_with(prefix)).collect();
134
135        return match matches.as_slice() {
136            [] => {
137                eprintln!("āŒ No conversation matches '{}'", prefix);
138                None
139            }
140            [single] => Some(vec![(*single).clone()]),
141            _ => {
142                eprintln!("āŒ Ambiguous prefix '{}': {:?}", prefix, matches);
143                None
144            }
145        };
146    }
147
148    match index["active_thread"].as_str() {
149        Some(active) if !active.is_empty() => Some(vec![active.to_string()]),
150        _ => {
151            eprintln!("āŒ No active conversation. Use --id or --all.");
152            None
153        }
154    }
155}
156
157fn short(id: &str) -> &str {
158    if id.len() >= 8 {
159        &id[..8]
160    } else {
161        id
162    }
163}