use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cli::ExportFormat;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::export;
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
pub fn run(
project: &Path,
format: ExportFormat,
output: Option<&Path>,
book_name: Option<&str>,
status_floor: Option<&str>,
) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
let h = Hierarchy::load(&store)?;
let scope = resolve_export_scope(&h, book_name)?;
let floor_idx = parse_status_floor(status_floor)?;
let combined = build_combined(&layout, &h, scope.root_id, floor_idx)?;
let epub_title = scope.title_for_epub(project);
match format {
ExportFormat::Typst => write_typst(&combined, output),
ExportFormat::Pdf => write_pdf(&combined, output),
ExportFormat::Markdown => write_artefact(
export::build_markdown(&combined),
output,
"markdown",
),
ExportFormat::Tex => write_artefact(
export::build_tex(&combined),
output,
"tex",
),
ExportFormat::Epub => {
let md = export::markdown::typst_to_markdown(&combined);
let artefact = export::build_epub(&md, &epub_title)
.map_err(|e| Error::Store(format!("epub: {e:#}")))?;
write_artefact(artefact, output, "epub")
}
}
}
struct ExportScope<'a> {
root_id: Option<uuid::Uuid>,
book_title: Option<&'a str>,
}
impl<'a> ExportScope<'a> {
fn title_for_epub(&self, project: &Path) -> String {
if let Some(t) = self.book_title {
return t.to_string();
}
project
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("inkhaven book")
.to_string()
}
}
fn resolve_export_scope<'a>(
h: &'a Hierarchy,
book_name: Option<&str>,
) -> Result<ExportScope<'a>> {
let user_books: Vec<&Node> = h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
.collect();
match book_name {
Some(name) => {
let needle = name.trim().to_ascii_lowercase();
let pick = user_books.iter().copied().find(|b| {
b.title.to_ascii_lowercase() == needle
|| b.slug.to_ascii_lowercase() == needle
});
match pick {
Some(book) => Ok(ExportScope {
root_id: Some(book.id),
book_title: Some(book.title.as_str()),
}),
None => {
let listing = user_books
.iter()
.map(|b| format!("`{}` (slug: {})", b.title, b.slug))
.collect::<Vec<_>>()
.join(", ");
let listing = if listing.is_empty() {
"no user books in this project".into()
} else {
listing
};
Err(Error::Store(format!(
"export: no book matches `--book-name {name}`. Available: {listing}"
)))
}
}
}
None => {
if user_books.len() > 1 {
let listing = user_books
.iter()
.map(|b| format!("`{}`", b.title))
.collect::<Vec<_>>()
.join(", ");
return Err(Error::Store(format!(
"export: project has {n} user books — pass --book-name <name>. Available: {listing}",
n = user_books.len(),
)));
}
let book_title = user_books.first().map(|b| b.title.as_str());
Ok(ExportScope {
root_id: user_books.first().map(|b| b.id),
book_title,
})
}
}
}
fn write_artefact(
artefact: export::Artefact,
output: Option<&Path>,
fmt_label: &str,
) -> Result<()> {
match output {
Some(path) => {
artefact.write_to(path).map_err(|e| {
Error::Store(format!("write {fmt_label}: {e:#}"))
})?;
eprintln!("wrote {} ({fmt_label})", path.display());
}
None => match &artefact {
export::Artefact::Markdown(s) | export::Artefact::Tex(s) => {
print!("{s}");
}
export::Artefact::Epub(_) => {
return Err(Error::Store(
"epub export needs --output <path.epub> (binary archive)".into(),
));
}
},
}
Ok(())
}
fn build_combined(
layout: &ProjectLayout,
h: &Hierarchy,
root_id: Option<uuid::Uuid>,
status_floor: Option<usize>,
) -> Result<String> {
export::assemble_typst_source_filtered(layout, h, root_id, status_floor)
.map_err(|e| Error::Store(format!("assemble: {e:#}")))
}
fn parse_status_floor(s: Option<&str>) -> Result<Option<usize>> {
let Some(raw) = s else { return Ok(None) };
let lowered = raw.trim().to_ascii_lowercase();
match STATUS_LADDER
.iter()
.position(|name| *name == lowered.as_str())
{
Some(i) => Ok(Some(i)),
None => Err(Error::Store(format!(
"export: unknown --status `{raw}`. Valid: {}",
STATUS_LADDER.join(", ")
))),
}
}
const STATUS_LADDER: &[&str] = &[
"none", "napkin", "first", "second", "third", "final", "ready",
];
fn write_typst(combined: &str, output: Option<&Path>) -> Result<()> {
match output {
Some(path) => {
std::fs::write(path, combined.as_bytes()).map_err(Error::Io)?;
eprintln!("wrote {} bytes to {}", combined.len(), path.display());
}
None => {
print!("{combined}");
}
}
Ok(())
}
fn write_pdf(combined: &str, output: Option<&Path>) -> Result<()> {
let output = output.ok_or_else(|| {
Error::Store("PDF export needs --output <path.pdf>".into())
})?;
if which("typst").is_none() {
return Err(Error::Store(
"the `typst` binary is not on PATH — install it from https://typst.app/ \
or run `inkhaven export typst -o file.typ` and compile manually"
.into(),
));
}
let typ_path: PathBuf = output.with_extension("typ");
std::fs::write(&typ_path, combined.as_bytes()).map_err(Error::Io)?;
let status = Command::new("typst")
.arg("compile")
.arg(&typ_path)
.arg(output)
.status()
.map_err(|e| Error::Store(format!("failed to spawn `typst`: {e}")))?;
if !status.success() {
return Err(Error::Store(format!(
"`typst compile` exited with {status}; intermediate source kept at {}",
typ_path.display()
)));
}
eprintln!("wrote {} (source: {})", output.display(), typ_path.display());
Ok(())
}
fn which(prog: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(prog);
if candidate.is_file() {
return Some(candidate);
}
let with_ext = dir.join(format!("{prog}.exe"));
if with_ext.is_file() {
return Some(with_ext);
}
}
None
}