use crate::commands::{load_quill, read_document, read_fonts, render_date};
use crate::errors::{CliError, Result};
use crate::output::write_file;
use clap::Parser;
use quillmark::typst_workspace::{workspace, FONTS_DIR, HELPER_DIR, PACKAGES_DIR};
use quillmark::{CalendarDate, FontLibrary, Severity};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Parser)]
pub struct WorkspaceArgs {
#[arg(value_name = "QUILL_PATH")]
quill: PathBuf,
#[arg(value_name = "MARKDOWN_FILE")]
markdown_file: Option<PathBuf>,
#[arg(short, long, value_name = "DIR", default_value = "quillmark-workspace")]
output: PathBuf,
#[arg(long, value_name = "YYYY-MM-DD")]
today: Option<CalendarDate>,
#[arg(long)]
quiet: bool,
#[arg(long = "font", value_name = "FONT_FILE")]
fonts: Vec<PathBuf>,
}
pub fn execute(args: WorkspaceArgs) -> Result<()> {
let quill = load_quill(&args.quill)?;
if lies_within(&args.output, &args.quill)? {
return Err(CliError::InvalidArgument(format!(
"Workspace directory {} is inside the quill, which would load it as quill files",
args.output.display()
)));
}
let (document, parse_warnings) = read_document(&quill, args.markdown_file.as_deref())?;
let today = render_date(args.today);
let json_data = quill.compile_checked(&document, today)?;
let library = FontLibrary::new(read_fonts(&args.fonts)?)?;
let workspace = workspace(&quill, &json_data, &library)?;
clear_previous_export(&args.output)?;
for (path, contents) in &workspace.files {
write_file(&args.output.join(path), contents, false)?;
}
if !args.quiet {
let unclaimed = quill
.validate(&document)
.into_iter()
.filter(|d| d.severity == Severity::Warning);
let warnings: Vec<_> = parse_warnings
.into_iter()
.chain(unclaimed)
.chain(workspace.warnings.iter().cloned())
.collect();
crate::errors::print_warnings(&warnings);
println!("Workspace written to: {}", args.output.display());
let plate = args.quill.join(&workspace.plate_file);
let pdf_name = Path::new(&workspace.plate_file).with_extension("pdf");
let pdf = args.output.join(pdf_name.file_name().unwrap_or_default());
println!(
"typst watch --root {quill} --package-path {packages} --font-path {fonts} \
--ignore-system-fonts --ignore-embedded-fonts --creation-timestamp={timestamp} \
{plate} {pdf}",
quill = shell_word(&args.quill),
packages = shell_word(&args.output.join(PACKAGES_DIR)),
fonts = shell_word(&args.output.join(FONTS_DIR)),
timestamp = creation_timestamp(today),
plate = shell_word(&plate),
pdf = shell_word(&pdf),
);
}
Ok(())
}
fn creation_timestamp(today: CalendarDate) -> i64 {
let month = time::Month::try_from(today.month()).expect("a calendar month");
time::Date::from_calendar_date(today.year(), month, today.day())
.and_then(|date| date.with_hms(12, 0, 0))
.expect("a calendar date has a noon")
.assume_utc()
.unix_timestamp()
}
fn clear_previous_export(out: &Path) -> Result<()> {
let owned = [out.join(PACKAGES_DIR), out.join(FONTS_DIR)];
if !owned.iter().any(|dir| dir.exists()) {
return Ok(());
}
if !out.join(PACKAGES_DIR).join(HELPER_DIR).is_dir() {
return Err(CliError::InvalidArgument(format!(
"Workspace directory {} holds a {PACKAGES_DIR}/ or {FONTS_DIR}/ directory \
no earlier export wrote; name an empty or new directory",
out.display()
)));
}
for dir in owned {
if dir.exists() {
fs::remove_dir_all(dir)?;
}
}
Ok(())
}
fn lies_within(path: &Path, root: &Path) -> Result<bool> {
let root = fs::canonicalize(root)?;
let mut existing = std::env::current_dir()?.join(path);
let mut rest = Vec::new();
let mut resolved = loop {
if let Ok(canonical) = fs::canonicalize(&existing) {
break canonical;
}
let Some(last) = existing.components().next_back() else {
break PathBuf::new();
};
rest.push(last.as_os_str().to_owned());
if !existing.pop() {
break PathBuf::new();
}
};
for name in rest.into_iter().rev() {
if name == ".." {
resolved.pop();
} else {
resolved.push(name);
}
}
Ok(resolved.starts_with(root))
}
fn shell_word(path: &Path) -> String {
let text = path.display().to_string();
let safe = |c: char| c.is_ascii_alphanumeric() || "/._-+=:@,%".contains(c);
if !text.is_empty() && text.chars().all(safe) {
text
} else {
format!("'{}'", text.replace('\'', "'\\''"))
}
}