calepin 0.0.8

A Rust CLI for preprocessing Typst documents with executable code chunks
use std::fs::{self, OpenOptions};
use std::io::Write;

use anyhow::{Context, Result};

use crate::cli::{set_quiet, CompileArgs, CompileOutputTemplate, NewArgs, StopArgs, WatchArgs};
use crate::typst::compile::{compile_with_typst, CompileOptions};
use crate::typst::preprocess::{preprocess, PreprocessOptions};

const NEW_FILE_TEMPLATE: &str = r#"#import ".calepin/calepin.typ"

#set document(title: [Calepin example])

#calepin.setup(
  echo: true,
  eval: true,
  results: "verbatim",
  raw-chunks: true,
)

#let py = calepin.inline.with("python")

= Calepin example

Inline Python result: #py[`print(40 + 2)`].

```python
message = "hello from a code chunk"
print(message)
```
"#;

pub fn handle_new(args: NewArgs) -> Result<()> {
    if let Some(parent) = args
        .path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }

    let mut options = OpenOptions::new();
    options.write(true);
    if args.force {
        options.create(true).truncate(true);
    } else {
        options.create_new(true);
    }

    let mut file = options
        .open(&args.path)
        .with_context(|| format!("failed to create {}", args.path.display()))?;
    file.write_all(NEW_FILE_TEMPLATE.as_bytes())
        .with_context(|| format!("failed to write {}", args.path.display()))?;

    if !crate::cli::QUIET.load(std::sync::atomic::Ordering::Relaxed) {
        eprintln!("Created {}", args.path.display());
    }

    Ok(())
}

pub fn handle_watch(args: WatchArgs) -> Result<()> {
    set_quiet(args.common.quiet);
    crate::typst::watch::run_watch(args)
}

pub fn handle_stop(args: StopArgs) -> Result<()> {
    crate::typst::watch::run_stop(args)
}

pub fn handle_compile(args: CompileArgs) -> Result<()> {
    set_quiet(args.common.quiet);
    let format = args.format.map(|format| format.as_str().to_string());
    let template_name = args.template.map(CompileOutputTemplate::html_template_name).flatten();
    let html_in_markdown = args
        .template
        .as_ref()
        .is_some_and(CompileOutputTemplate::is_html_in_markdown);
    if args.template.is_some() && format.as_deref() != Some("html") {
        return Err(anyhow::anyhow!(
            "`--template` can only be used with `--format html`"
        ));
    }
    let output = preprocess(PreprocessOptions {
        input: args.input,
        results: args.common.results,
        clean: args.common.clean,
        quiet: args.common.quiet,
        timeout: args.common.timeout,
        sync_pages: false,
    })?;
    compile_with_typst(
        &output.executables.typst,
        &output.layout,
        CompileOptions {
            output: args.output,
            format: format.as_deref(),
            typst_args: &args.typst_args,
            html_in_markdown,
            template_theme: template_name,
            themes_dir: &output.themes_dir,
        },
    )?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn new_writes_example_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes").join("example.typ");

        handle_new(NewArgs {
            path: path.clone(),
            force: false,
        })
        .unwrap();

        let content = fs::read_to_string(path).unwrap();
        assert!(content.contains(r#"#import ".calepin/calepin.typ""#));
        assert!(content.contains("calepin.inline.with(\"python\")"));
        assert!(content.contains("raw-chunks: true"));
        assert!(content.contains("```python\n"));
        assert!(content.contains("print(40 + 2)"));
        assert!(content.contains("hello from a code chunk"));
    }

    #[test]
    fn new_does_not_overwrite_existing_file_by_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("example.typ");
        fs::write(&path, "existing").unwrap();

        let err = handle_new(NewArgs {
            path: path.clone(),
            force: false,
        })
        .unwrap_err();

        assert!(err.to_string().contains("failed to create"));
        assert_eq!(fs::read_to_string(path).unwrap(), "existing");
    }

    #[test]
    fn new_force_overwrites_existing_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("example.typ");
        fs::write(&path, "existing").unwrap();

        handle_new(NewArgs {
            path: path.clone(),
            force: true,
        })
        .unwrap();

        assert!(fs::read_to_string(path)
            .unwrap()
            .contains("Calepin example"));
    }
}