use std::path::Path;
use clap::Parser;
use colored::*;
use serde_json::Value;
use crate::schema::bridge::{document_from_thread, linked_sources, write_conversation_folder};
#[derive(Parser, Debug)]
pub struct ExportArgs {
#[arg(short, long)]
pub id: Option<String>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub force: bool,
}
pub fn run_export(args: ExportArgs) {
let fur_dir = Path::new(".fur");
let index_path = fur_dir.join("index.json");
if !index_path.exists() {
eprintln!("🚨 .fur/ not found. Run `fur new` first.");
return;
}
let Some(content) = crate::security::io::read_text_file(&index_path) else {
return;
};
let index: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => return eprintln!("❌ Invalid index.json: {}", e),
};
let threads: Vec<String> = index["threads"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let targets = match select_targets(&index, &threads, &args) {
Some(t) => t,
None => return,
};
let project_root = Path::new(".");
let mut exported = 0usize;
for tid in &targets {
match export_one(fur_dir, project_root, tid, args.force) {
Ok(folder) => {
println!("📄 {}", folder.display().to_string().green());
exported += 1;
}
Err(e) => eprintln!("❌ {} — {}", &short(tid).bright_black(), e),
}
}
println!(
"\n✔️ Exported {} of {} conversation(s).",
exported,
targets.len()
);
report_orphans(project_root);
}
fn report_orphans(project_root: &Path) {
let orphans = crate::schema::bridge::find_orphans(project_root);
if orphans.is_empty() {
return;
}
println!(
"\n{}",
"Duplicate long-form files in chats/ root".bright_yellow().bold()
);
for p in &orphans {
println!(" • {}", p.display());
}
println!(
"{}",
" These are already inside their conversation folder and can be deleted."
.bright_black()
);
}
fn export_one(
fur_dir: &Path,
project_root: &Path,
tid: &str,
force: bool,
) -> Result<std::path::PathBuf, String> {
let doc = document_from_thread(fur_dir, tid)?;
let linked = linked_sources(fur_dir, tid)?;
write_conversation_folder(project_root, &doc, &linked, force)
}
fn select_targets(index: &Value, threads: &[String], args: &ExportArgs) -> Option<Vec<String>> {
if args.all {
if threads.is_empty() {
eprintln!("⚠️ No conversations to export.");
return None;
}
return Some(threads.to_vec());
}
if let Some(prefix) = &args.id {
let matches: Vec<&String> = threads.iter().filter(|t| t.starts_with(prefix)).collect();
return match matches.as_slice() {
[] => {
eprintln!("❌ No conversation matches '{}'", prefix);
None
}
[single] => Some(vec![(*single).clone()]),
_ => {
eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
None
}
};
}
match index["active_thread"].as_str() {
Some(active) if !active.is_empty() => Some(vec![active.to_string()]),
_ => {
eprintln!("❌ No active conversation. Use --id or --all.");
None
}
}
}
fn short(id: &str) -> &str {
if id.len() >= 8 {
&id[..8]
} else {
id
}
}