use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
pub static QUIET: AtomicBool = AtomicBool::new(false);
pub fn set_quiet(q: bool) {
QUIET.store(q, Ordering::Relaxed);
}
pub fn is_quiet() -> bool {
QUIET.load(Ordering::Relaxed)
}
#[derive(Parser, Debug)]
#[command(
name = "calepin",
about = "Preprocess Typst documents with executable code chunks",
version,
disable_version_flag = true,
arg_required_else_help = true
)]
#[command(arg(clap::Arg::new("version")
.short('v')
.long("version")
.action(clap::ArgAction::Version)
.help("Print version")
))]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
New(NewArgs),
Health(HealthArgs),
Compile(CompileArgs),
Watch(WatchArgs),
Serve(ServeArgs),
Update,
Clean(CleanArgs),
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompileFormat {
Pdf,
Png,
Svg,
Html,
Script,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatchFormat {
Pdf,
Png,
Svg,
Html,
}
impl CompileFormat {
pub fn as_str(self) -> &'static str {
match self {
Self::Pdf => "pdf",
Self::Png => "png",
Self::Svg => "svg",
Self::Html => "html",
Self::Script => "script",
}
}
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum NewTheme {
Calepin,
Academic,
}
impl NewTheme {
pub fn as_str(self) -> &'static str {
match self {
Self::Calepin => "calepin",
Self::Academic => "academic",
}
}
}
#[derive(clap::Args, Debug, Clone)]
#[command(
arg_required_else_help = true,
after_help = "Examples:\n calepin new paper.typ\n calepin new website\n calepin new website --theme academic\n calepin new theme\n calepin new theme --theme academic\n calepin new theme themes/my-theme"
)]
pub struct NewArgs {
#[arg(value_name = "PATH|website|theme")]
pub path: PathBuf,
#[arg(long, value_enum)]
pub theme: Option<NewTheme>,
#[arg(value_name = "DIR")]
pub output: Option<PathBuf>,
#[arg(short, long)]
pub force: bool,
}
#[derive(clap::Args, Debug, Clone)]
pub struct HealthArgs {
#[arg(long)]
pub config: Option<PathBuf>,
#[arg(short = 'd', long)]
pub depth: Option<usize>,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub strict: bool,
#[arg(long)]
pub check_external_links: bool,
}
#[derive(clap::Args, Debug, Clone)]
#[command(arg_required_else_help = true)]
pub struct CompileArgs {
pub input: PathBuf,
pub output: Option<PathBuf>,
#[arg(long, value_enum)]
pub format: Option<CompileFormat>,
#[arg(long)]
pub minify: bool,
#[command(flatten)]
pub common: CommonArgs,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub typst_args: Vec<String>,
}
#[derive(clap::Args, Debug, Clone)]
#[command(arg_required_else_help = true)]
pub struct WatchArgs {
pub input: PathBuf,
pub output: Option<PathBuf>,
#[arg(long, value_enum)]
pub format: Option<WatchFormat>,
#[arg(long)]
pub eval_only: bool,
#[arg(long)]
pub serve: bool,
#[arg(long)]
pub open: bool,
#[arg(long, default_value = "127.0.0.1")]
pub host: String,
#[arg(long)]
pub port: Option<u16>,
#[command(flatten)]
pub common: CommonArgs,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub typst_args: Vec<String>,
}
#[derive(clap::Args, Debug, Clone)]
#[command(arg_required_else_help = true)]
pub struct ServeArgs {
pub dir: PathBuf,
#[arg(long, default_value = "127.0.0.1")]
pub host: String,
#[arg(short, long)]
pub port: Option<u16>,
#[arg(long)]
pub open: bool,
}
#[derive(clap::Args, Debug, Clone)]
pub struct CleanArgs {
#[arg(short, long)]
pub depth: Option<usize>,
#[arg(short, long)]
pub yes: bool,
}
#[derive(clap::Args, Debug, Clone)]
pub struct CommonArgs {
#[arg(long)]
pub config: Option<PathBuf>,
#[arg(short, long)]
pub quiet: bool,
#[arg(long)]
pub timeout: Option<u64>,
#[arg(long = "set", value_name = "KEY=VALUE")]
pub sets: Vec<String>,
}
macro_rules! cwarn {
($($arg:tt)*) => {
eprint!("\x1b[33mWarning:\x1b[0m ");
eprintln!($($arg)*);
};
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn test_health_args() {
let cli = Cli::try_parse_from([
"calepin",
"health",
"--config",
"config.toml",
"--json",
"--strict",
"--check-external-links",
])
.unwrap();
match cli.command {
Command::Health(args) => {
assert_eq!(args.config, Some(PathBuf::from("config.toml")));
assert_eq!(args.depth, None);
assert!(args.json);
assert!(args.strict);
assert!(args.check_external_links);
}
other => panic!("expected health command, got {other:?}"),
}
}
#[test]
fn test_health_args_depth() {
let cli = Cli::try_parse_from(["calepin", "health", "--depth", "2"]).unwrap();
match cli.command {
Command::Health(args) => assert_eq!(args.depth, Some(2)),
other => panic!("expected health command, got {other:?}"),
}
}
#[test]
fn test_typst_compile_args() {
let cli = Cli::try_parse_from([
"calepin",
"compile",
"paper.typ",
"paper.pdf",
"--",
"--font-path",
"fonts",
"--input",
"theme=dark",
])
.unwrap();
match cli.command {
Command::Compile(args) => {
assert_eq!(args.input, PathBuf::from("paper.typ"));
assert_eq!(args.output, Some(PathBuf::from("paper.pdf")));
assert_eq!(
args.typst_args,
vec!["--font-path", "fonts", "--input", "theme=dark"]
);
}
other => panic!("expected compile command, got {other:?}"),
}
}
#[test]
fn test_new_website_with_output_from_positional_arg() {
let cli = Cli::try_parse_from(["calepin", "new", "website", "my_site"]).unwrap();
match cli.command {
Command::New(args) => {
assert_eq!(args.path, PathBuf::from("website"));
assert_eq!(args.output, Some(PathBuf::from("my_site")));
}
other => panic!("expected new command, got {other:?}"),
}
}
#[test]
fn test_typst_compile_args_minify() {
let cli = Cli::try_parse_from([
"calepin",
"compile",
"paper.typ",
"--format",
"html",
"--minify",
])
.unwrap();
match cli.command {
Command::Compile(args) => {
assert!(args.minify);
}
other => panic!("expected compile command, got {other:?}"),
}
}
#[test]
fn test_typst_compile_script_format() {
let cli = Cli::try_parse_from([
"calepin",
"compile",
"paper.typ",
"scripts/paper.{ext}",
"--format",
"script",
])
.unwrap();
match cli.command {
Command::Compile(args) => {
assert_eq!(args.format, Some(CompileFormat::Script));
assert_eq!(args.output, Some(PathBuf::from("scripts/paper.{ext}")));
}
other => panic!("expected compile command, got {other:?}"),
}
}
#[test]
fn test_typst_compile_rejects_template_alias() {
let err = Cli::try_parse_from([
"calepin",
"compile",
"paper.typ",
"--format",
"html",
"--template",
"calepin",
])
.unwrap_err();
assert!(err.to_string().contains("--template"), "{err}");
}
#[test]
fn test_compile_set_overrides() {
let cli = Cli::try_parse_from([
"calepin",
"compile",
"paper.typ",
"--set",
"vars.region=NY",
"--set",
"vars.min_count=25",
])
.unwrap();
match cli.command {
Command::Compile(args) => {
assert_eq!(
args.common.sets,
vec!["vars.region=NY", "vars.min_count=25"]
);
}
other => panic!("expected compile command, got {other:?}"),
}
}
#[test]
fn test_watch_set_overrides() {
let cli = Cli::try_parse_from(["calepin", "watch", "paper.typ", "--set", "theme=academic"])
.unwrap();
match cli.command {
Command::Watch(args) => {
assert_eq!(args.common.sets, vec!["theme=academic"]);
}
other => panic!("expected watch command, got {other:?}"),
}
}
#[test]
fn test_typst_watch_args() {
let cli = Cli::try_parse_from([
"calepin",
"watch",
"paper.typ",
"out/paper.html",
"--format",
"html",
"--quiet",
"--timeout",
"42",
"--",
"--font-path",
"fonts",
])
.unwrap();
match cli.command {
Command::Watch(args) => {
assert_eq!(args.input, PathBuf::from("paper.typ"));
assert_eq!(args.output, Some(PathBuf::from("out/paper.html")));
assert_eq!(args.format, Some(WatchFormat::Html));
assert!(!args.eval_only);
assert!(!args.serve);
assert!(!args.open);
assert!(args.common.quiet);
assert_eq!(args.common.timeout, Some(42));
assert_eq!(args.typst_args, vec!["--font-path", "fonts"]);
}
other => panic!("expected watch command, got {other:?}"),
}
}
#[test]
fn test_typst_watch_rejects_script_format() {
let error = Cli::try_parse_from(["calepin", "watch", "paper.typ", "--format", "script"])
.unwrap_err();
assert!(error.to_string().contains("invalid value 'script'"));
}
#[test]
fn test_typst_watch_eval_only_args() {
let cli = Cli::try_parse_from(["calepin", "watch", "paper.typ", "--eval-only", "--quiet"])
.unwrap();
match cli.command {
Command::Watch(args) => {
assert_eq!(args.input, PathBuf::from("paper.typ"));
assert!(args.eval_only);
assert!(args.common.quiet);
}
other => panic!("expected watch command, got {other:?}"),
}
}
#[test]
fn test_watch_website_serve_args() {
let cli = Cli::try_parse_from([
"calepin",
"watch",
"docs",
"--config",
"project.toml",
"--serve",
"--open",
"--host",
"0.0.0.0",
"--port",
"3000",
])
.unwrap();
match cli.command {
Command::Watch(args) => {
assert_eq!(args.input, PathBuf::from("docs"));
assert_eq!(args.common.config, Some(PathBuf::from("project.toml")));
assert!(args.serve);
assert!(args.open);
assert_eq!(args.host, "0.0.0.0");
assert_eq!(args.port, Some(3000));
}
other => panic!("expected watch command, got {other:?}"),
}
}
#[test]
fn test_serve_args() {
let cli = Cli::try_parse_from([
"calepin", "serve", "docs", "--host", "0.0.0.0", "--port", "3000", "--open",
])
.unwrap();
match cli.command {
Command::Serve(args) => {
assert_eq!(args.dir, PathBuf::from("docs"));
assert_eq!(args.host, "0.0.0.0");
assert_eq!(args.port, Some(3000));
assert!(args.open);
}
other => panic!("expected serve command, got {other:?}"),
}
}
#[test]
fn test_stop_subcommand_is_not_supported() {
let error = Cli::try_parse_from(["calepin", "stop"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::InvalidSubcommand);
}
#[test]
fn test_clean_args_depth() {
let cli = Cli::try_parse_from(["calepin", "clean", "--depth", "3", "--yes"]).unwrap();
match cli.command {
Command::Clean(args) => {
assert_eq!(args.depth, Some(3));
assert!(args.yes);
}
other => panic!("expected clean command, got {other:?}"),
}
}
#[test]
fn test_new_args() {
let cli = Cli::try_parse_from(["calepin", "new", "paper.typ", "--force"]).unwrap();
match cli.command {
Command::New(args) => {
assert_eq!(args.path, PathBuf::from("paper.typ"));
assert_eq!(args.output, None);
assert!(args.force);
}
other => panic!("expected new command, got {other:?}"),
}
}
#[test]
fn test_new_website_args() {
let cli = Cli::try_parse_from(["calepin", "new", "website", "--force"]).unwrap();
match cli.command {
Command::New(args) => {
assert_eq!(args.path, PathBuf::from("website"));
assert_eq!(args.output, None);
assert!(args.force);
}
other => panic!("expected new command, got {other:?}"),
}
}
#[test]
fn test_new_website_output_args() {
let cli = Cli::try_parse_from(["calepin", "new", "website", "site", "--force"]).unwrap();
match cli.command {
Command::New(args) => {
assert_eq!(args.path, PathBuf::from("website"));
assert_eq!(args.output, Some(PathBuf::from("site")));
assert!(args.force);
}
other => panic!("expected new command, got {other:?}"),
}
}
#[test]
fn test_new_website_theme_arg() {
let cli = Cli::try_parse_from(["calepin", "new", "website", "site", "--theme", "academic"])
.unwrap();
match cli.command {
Command::New(args) => {
assert_eq!(args.path, PathBuf::from("website"));
assert_eq!(args.output, Some(PathBuf::from("site")));
assert_eq!(args.theme, Some(NewTheme::Academic));
}
other => panic!("expected new command, got {other:?}"),
}
}
#[test]
fn test_new_theme_theme_arg() {
let cli = Cli::try_parse_from(["calepin", "new", "theme", "--theme", "academic"]).unwrap();
match cli.command {
Command::New(args) => {
assert_eq!(args.path, PathBuf::from("theme"));
assert_eq!(args.output, None);
assert_eq!(args.theme, Some(NewTheme::Academic));
}
other => panic!("expected new command, got {other:?}"),
}
}
#[test]
fn test_executable_path_flags_removed() {
for flag in ["--typst", "--rscript", "--python"] {
let err = Cli::try_parse_from(["calepin", "compile", "paper.typ", flag, "custom"])
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
}
}