Skip to main content

oseda_cli/cmd/
export.rs

1use std::{
2    error::Error,
3    process::Command,
4    sync::{
5        atomic::{AtomicBool, Ordering},
6        Arc,
7    },
8};
9
10use clap::Args;
11
12use crate::{
13    cmd::{is_cwd_oseda_project, run},
14    config::read_and_validate_config,
15    net::kill_port,
16    puppeteer::{is_puppeteer_chrome_installed, prompt_install_puppeteer_chrome},
17};
18
19/// Options struct for the export subcommand
20#[derive(Args, Debug, Clone)]
21pub struct ExportOptions {
22    /// String name of the output PDF file
23    pub output: Option<String>,
24    /// Port the project runs on
25    #[arg(long, default_value_t = 3000)]
26    pub port: u16,
27}
28
29impl ExportOptions {
30    pub fn output_or_default(&self) -> String {
31        self.output.clone().unwrap_or_else(get_default_output)
32    }
33}
34
35fn get_default_output() -> String {
36    let default = String::from("slides.pdf");
37
38    let Ok(config) = read_and_validate_config() else {
39        println!("Warning: Could not read oseda-config.json");
40        println!("Using slides.pdf as name instead");
41        return default;
42    };
43
44    format!("{}.pdf", config.title.trim())
45}
46
47/// Export the current Oseda project to a PDF file via `decktape`
48pub fn export(opts: ExportOptions) -> Result<(), Box<dyn Error>> {
49    if !is_cwd_oseda_project() {
50        return Err("Current working directory is not an Oseda project".into());
51    }
52
53    println!("Cleaning any existing oseda processing...");
54    if kill_port(opts.port).is_err() {
55        eprintln!("Warning, could not kill value on desired port")
56    }
57
58    let output = Command::new("npm")
59        .args(["install", "decktape@3.15.0"])
60        .current_dir(".")
61        .output()?;
62
63    // prompt for puppeteer and confirm installation
64    if !is_puppeteer_chrome_installed() {
65        prompt_install_puppeteer_chrome()?;
66    }
67    println!("Puppeteer Chrome is installed, continuing...");
68
69    if !output.status.success() {
70        eprintln!(
71            "Decktape installation failure: {}",
72            String::from_utf8_lossy(&output.stderr)
73        );
74        return Err("npm init failed".into());
75    }
76
77    // decktape automatic http://localhost:3000/ Desktop/IntroToRust/slides.pdf
78
79    let shutdown_flag = Arc::new(AtomicBool::new(false));
80    let run_flag = shutdown_flag.clone();
81
82    let run_handle = std::thread::spawn(move || run::run_with_shutdown(run_flag));
83
84    // wait a moment for the localhost server to spin up
85    std::thread::sleep(std::time::Duration::from_millis(10000));
86
87    let addr = format!("http://localhost:{}", opts.port);
88
89    // run decktape, assuming the server has spun up by now
90    let export_output = Command::new("npm")
91        .args([
92            "exec",
93            "decktape",
94            "--",
95            "reveal",
96            "--fragments",
97            &addr,
98            &opts.output_or_default(),
99        ])
100        .output()?;
101
102    // send shutdown flag, should signal to run_with_shutdown to kill the process
103    shutdown_flag.store(true, Ordering::SeqCst);
104    // wait to run to terminate (hopefully gracefully) and join the process to cur. thread
105    let _ = run_handle.join();
106
107    if !export_output.status.success() {
108        eprintln!(
109            "Decktape PDF export failure: {}",
110            String::from_utf8_lossy(&export_output.stderr)
111        );
112        return Err("npm init failed".into());
113    }
114
115    Ok(())
116}