Files
deckcraft/apps/deckcraft-cli/src/main.rs
T
Brandon Thomas 9fc910886c Merge PR #247 from isaac-levine: MCP: confine file access with --automation-read-root and --automation-write-root
# Conflicts:
#	apps/deckcraft-cli/src/main.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/lib.rs
2026-10-14 15:25:09 -04:00

654 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Headless DeckCraft.
//!
//! ```text
//! deckcraft-cli info FILE # slides, titles, layouts (JSON)
//! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR
//! N starts at 0. --all writes slide-01.png, slide-02.png, …
//! deckcraft-cli render-transition FILE --slide N --t T[,T…] [--width PX] OUT.png # transition frame(s)
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide)
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
//! deckcraft-cli commands [FILTER] # list commands (JSON)
//! deckcraft-cli describe ID # one command
//! deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app
//! deckcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]
//! # MCP server over stdio
//! deckcraft-cli bench [FILE | --sample] [--runs N] [--json] # time open, render, save, export
//! deckcraft-cli links | --version
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod automation_args;
use std::process::ExitCode;
use deckcraft_engine::Session;
use serde_json::{Value, json};
macro_rules! outln {
($($arg:tt)*) => {{
use std::io::Write as _;
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("deckcraft-cli: can't write to stdout: {e}");
std::process::exit(1);
}
}};
}
const USAGE: &str = "usage: deckcraft-cli info FILE
deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT
N starts at 0 (the first slide). --all writes slide-01.png, slide-02.png, …
deckcraft-cli render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png
deckcraft-cli convert IN OUT
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
deckcraft-cli commands [FILTER]
deckcraft-cli describe COMMAND
deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]
deckcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]
the two roots confine every file the agent's commands read (open, pictures,
audio, video) and write (save, export, PDF) to those folders, links followed;
a root left out grants none of its access. They run the in-process server, so
they conflict with --connect (a running app is not confined by them).
deckcraft-cli bench [FILE | --sample] [--runs N] [--json]
deckcraft-cli links | --version";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let rest = args.get(1..).unwrap_or(&[]);
match args.first().map(String::as_str) {
Some("--version" | "-V" | "version") => {
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Some("info") => report(info(rest)),
Some("bench") => report(bench(rest)),
Some("render") => report(render(rest)),
Some("render-transition") => report(render_transition(rest)),
Some("convert") => report(convert(rest)),
Some("run") => report(run(rest)),
Some("--help" | "-h" | "help") => {
outln!("{USAGE}");
ExitCode::SUCCESS
}
Some("commands") if rest.first().is_some_and(|a| is_help(a)) => {
outln!("{USAGE}");
ExitCode::SUCCESS
}
Some("commands") => {
let s = Session::new();
let f = rest.first().map(|x| x.to_lowercase());
let list: Vec<Value> = s
.commands()
.into_iter()
.filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str())))
.map(|c| serde_json::to_value(c).unwrap_or_default())
.collect();
outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
ExitCode::SUCCESS
}
Some("describe") => report(describe(rest.first().map(String::as_str))),
Some("app") => report(app(rest)),
Some("mcp") => report(mcp(rest)),
Some("links") => {
use deckcraft_engine::links::*;
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
ExitCode::SUCCESS
}
_ => {
eprintln!("{USAGE}");
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
ExitCode::FAILURE
}
}
}
fn is_help(arg: &str) -> bool {
matches!(arg, "--help" | "-h")
}
fn report(r: Result<(), String>) -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("deckcraft-cli: {e}");
ExitCode::FAILURE
}
}
}
fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String> {
if sample {
return deckcraft_engine::sample::open_sample(s).map_err(|e| e.to_string());
}
match input {
Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()),
None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()),
}
}
/// Median and slowest of `runs` timings of `f`, in milliseconds.
fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> {
let mut ms = Vec::with_capacity(runs);
for _ in 0..runs.max(1) {
let t = std::time::Instant::now();
f()?;
ms.push(t.elapsed().as_secs_f64() * 1000.0);
}
ms.sort_by(f64::total_cmp);
let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
Ok((median, ms.last().copied().unwrap_or(0.0)))
}
/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
fn peak_rss_mib() -> Option<f64> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
let kb: f64 = s.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
Some(kb / 1024.0)
}
/// The performance baseline (docs/gaps.md "No baseline"): how long a deck takes to open, to
/// render each slide, to save and to export, and the peak memory, as JSON or a table.
fn bench(args: &[String]) -> Result<(), String> {
use deckcraft_engine::cmd::file::{open_presentation, save_bytes};
let mut input = None;
let mut sample = false;
let mut runs = 5usize;
let mut as_json = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--sample" => sample = true,
"--json" => as_json = true,
"--runs" => {
runs = it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=1000).contains(n)).ok_or("--runs needs a number from 1 to 1000")?
}
f if !f.starts_with("--") && input.is_none() => input = Some(f.to_string()),
o => return Err(format!("bench: unknown argument `{o}`")),
}
}
if input.is_none() && !sample {
return Err("bench needs a FILE or --sample".into());
}
let mut s = Session::new();
let (name, bytes) = match &input {
Some(p) => (p.clone(), std::fs::read(p).map_err(|e| format!("{p}: {e}"))?),
None => {
open(&mut s, None, true)?;
let doc = &s.doc().map_err(|e| e.to_string())?.doc;
("sample.deckcraft".to_string(), save_bytes(doc, "deckcraft").map_err(|e| e.to_string())?)
}
};
let e = |x: deckcraft_engine::EngineError| x.to_string();
let doc = open_presentation(&name, &bytes).map_err(e)?;
let slides = doc.slides.len();
let open = time_ms(runs, || open_presentation(&name, &bytes).map(|_| ()).map_err(e))?;
let opts = deckcraft_engine::render::RenderOpts { scale: 1.0, ..Default::default() };
let mut per_slide = vec![];
for i in 0..slides {
per_slide.push(time_ms(runs, || {
deckcraft_engine::render::render_slide(&doc, i, &opts);
Ok(())
})?);
}
let pptx = save_bytes(&doc, "pptx").map_err(e)?;
let mut steps = vec![
("open", open),
("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?),
("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?),
("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
];
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
let peak = peak_rss_mib();
if as_json {
let v = json!({
"file": name,
"bytes": bytes.len(),
"slides": slides,
"runs": runs,
"steps": steps.iter().map(|(k, (m, x))| json!({"step": k, "medianMs": m, "maxMs": x})).collect::<Vec<_>>(),
"renderMsPerSlide": per_slide.iter().map(|m| m.0).collect::<Vec<_>>(),
"peakRssMiB": peak,
});
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
} else {
outln!("{name}: {slides} slides, {} KiB, median of {runs} runs", bytes.len() / 1024);
for (k, (m, x)) in &steps {
outln!(" {k:<24} {m:>9.1} ms (slowest {x:.1} ms)");
}
if let Some((i, m)) = per_slide.iter().enumerate().max_by(|a, b| a.1.0.total_cmp(&b.1.0)) {
outln!(" slowest slide {:>9.1} ms (slide {})", m.0, i + 1);
}
if let Some(p) = peak {
outln!(" peak memory {p:>9.1} MiB");
}
}
Ok(())
}
fn info(args: &[String]) -> Result<(), String> {
if args.first().is_some_and(|a| is_help(a)) {
outln!("{USAGE}");
return Ok(());
}
let path = args.first().ok_or("info needs a FILE")?;
let mut s = Session::new();
open(&mut s, Some(path), false)?;
let v = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
Ok(())
}
fn render(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut sample = false;
let mut slide: Option<usize> = None;
let mut all = false;
let mut scale = 2.0;
let mut edit = false;
let mut out = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--sample" => sample = true,
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
"--all" => all = true,
"--edit" => edit = true,
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
"--help" | "-h" => {
outln!("{USAGE}");
return Ok(());
}
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
other
if input.is_none()
&& !sample
&& !other.starts_with("--")
&& out.is_none()
&& !(other.ends_with(".png") || other.ends_with(".jpg"))
|| (input.is_none() && !sample && std::path::Path::new(other).is_file()) =>
{
input = Some(other.to_string())
}
other => out = Some(other.to_string()),
}
}
let out = out.ok_or("render needs an output path")?;
let mut s = Session::new();
open(&mut s, input.as_deref(), sample)?;
let st = s.doc().map_err(|e| e.to_string())?;
let n = st.doc.slides.len();
let list = slide_indices(n, all, slide)?;
if all {
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
}
for i in list {
let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit);
let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() };
std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?;
outln!("{path} ({w}×{h})");
}
Ok(())
}
/// Slides `--slide` asks for. `N` is a 0-based index, the same one `slide.go` uses.
/// `--all` still names files from 1 (`slide-01.png`), which is PowerPoint's slide number.
fn slide_indices(n: usize, all: bool, slide: Option<usize>) -> Result<Vec<usize>, String> {
if all {
return Ok((0..n).collect());
}
let i = slide.unwrap_or(0);
if i >= n {
return Err(missing_slide(i, n));
}
Ok(vec![i])
}
fn missing_slide(i: usize, n: usize) -> String {
match n {
0 => format!("no slide index {i} (this deck has no slides)"),
1 => format!("no slide index {i} (this deck has 1 slide, index 0, which --all names slide-01.png)"),
n => {
let last = n - 1;
format!(
"no slide index {i} (this deck has {n} slides, numbered 0 through {last}; --all names them slide-01.png through slide-{n:02}.png)"
)
}
}
}
/// Frames of slide N's transition (0-based N; from slide N-1, or black for the first slide) at
/// the given progress values. With several values, OUT gets `-t<value>` before its extension.
fn render_transition(args: &[String]) -> Result<(), String> {
use deckcraft_engine::render;
let mut input = None;
let mut slide: Option<usize> = None;
let mut ts: Vec<f64> = Vec::new();
let mut width: Option<f64> = None;
let mut out = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
"--t" => {
let v = it.next().ok_or("--t needs a value")?;
for x in v.split(',') {
let t: f64 = x.trim().parse().map_err(|_| format!("bad --t value `{x}`"))?;
if !t.is_finite() {
return Err(format!("bad --t value `{x}`"));
}
ts.push(t);
}
}
"--width" => {
width = Some(it.next().and_then(|v| v.parse().ok()).filter(|w: &f64| w.is_finite() && *w >= 1.0).ok_or("--width needs pixels")?)
}
other if input.is_none() => input = Some(other.to_string()),
other => out = Some(other.to_string()),
}
}
let input = input.ok_or("render-transition needs a FILE")?;
let out = out.ok_or("render-transition needs an output path")?;
let i = slide.ok_or("render-transition needs --slide N")?;
if ts.is_empty() {
return Err("render-transition needs --t".into());
}
let paths = frame_paths(&out, &ts)?;
let mut s = Session::new();
open(&mut s, Some(&input), false)?;
let st = s.doc().map_err(|e| e.to_string())?;
let doc = &st.doc;
let n = doc.slides.len();
let Some(sl) = doc.slides.get(i) else { return Err(format!("no slide {i} (the deck has {n})")) };
let sw = doc.slide_size.width.max(1.0);
let w = width.unwrap_or(sw * 2.0).min(render::MAX_SIDE as f64);
let opts = render::RenderOpts { scale: w / sw, ..Default::default() };
let tr = sl.transition.clone().unwrap_or_default();
// Render both slides once and composite every frame from them (morph re-renders per frame).
let prev = i.checked_sub(1);
let cached = (tr.kind != "morph").then(|| {
let parts = render::uses_slide_parts(&tr.kind, &tr.option).then(|| render::SlideParts::render(doc, prev, i, &opts));
(prev.map(|p| render::render_slide(doc, p, &opts)), render::render_slide(doc, i, &opts), parts)
});
let aspect = sw / doc.slide_size.height.max(1.0);
for (t, path) in ts.iter().zip(&paths) {
let img = match &cached {
Some((old, new, parts)) => {
render::composite_transition(old.as_ref(), new, parts.as_ref(), &render::transition_frame(&tr.kind, &tr.option, *t, aspect))
}
None => render::render_transition(doc, i, *t, &opts),
};
std::fs::write(path, img.to_png_at(render::dpi_for_scale(opts.scale))).map_err(|e| format!("{path}: {e}"))?;
outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option);
}
Ok(())
}
/// Output path of each frame. A single frame goes to OUT; several get `-t<value>` (three decimals)
/// before the extension. Values that round to the same name would overwrite each other, so that is an error.
fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
if ts.len() == 1 {
return Ok(vec![out.to_string()]);
}
let p = std::path::Path::new(out);
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
let mut paths: Vec<String> = Vec::with_capacity(ts.len());
for t in ts {
let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned();
if paths.contains(&path) {
return Err(format!("--t values round to the same three decimals: more than one frame would be written to {path}"));
}
paths.push(path);
}
Ok(paths)
}
fn convert(args: &[String]) -> Result<(), String> {
if args.first().is_some_and(|a| is_help(a)) {
outln!("{USAGE}");
return Ok(());
}
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
let fmt = output_format(out)?;
let mut s = Session::new();
open(&mut s, Some(input), false)?;
let r = if matches!(fmt, "png" | "jpeg") {
s.execute("file.export", &json!({"path": out, "slide": 0}))
} else {
s.execute("file.saveAs", &json!({"path": out}))
};
r.map_err(|e| e.to_string())?;
outln!("{out}");
Ok(())
}
/// The format `convert`, `run --save` and `run --export` write to `path`, or an error naming the
/// supported extensions when `path`'s extension isn't one (rather than native bytes under that name).
fn output_format(path: &str) -> Result<&'static str, String> {
use deckcraft_engine::cmd::file::{WRITABLE_EXTENSIONS, writable_format_for_path};
writable_format_for_path(path).ok_or_else(|| {
let ext = std::path::Path::new(path).extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_else(|| "(none)".into());
format!("{path}: unsupported output format {ext} (supported: .{})", WRITABLE_EXTENSIONS.join(", ."))
})
}
fn run(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut sample = false;
let mut cmds = vec![];
let mut save = None;
let mut export = None;
let mut print = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
"--sample" => sample = true,
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?),
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
"--print" => print = true,
"--help" | "-h" => {
outln!("{USAGE}");
return Ok(());
}
other => return Err(format!("unknown option `{other}`")),
}
}
for p in save.iter().chain(&export) {
output_format(p)?;
}
let mut s = Session::new();
open(&mut s, input.as_deref(), sample)?;
for c in cmds {
let (id, p) = match c.split_once('=') {
Some((id, j)) => (id.to_string(), serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
None => (c.clone(), json!({})),
};
let r = s.execute(&id, &p).map_err(|e| e.to_string())?;
if print {
outln!("{id} → {r}");
}
}
if let Some(p) = save {
s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if let Some(p) = export {
s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?;
}
Ok(())
}
fn describe(id: Option<&str>) -> Result<(), String> {
if id.is_some_and(is_help) {
outln!("{USAGE}");
return Ok(());
}
let id = id.ok_or("describe needs a command id")?;
let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params);
Ok(())
}
fn app(args: &[String]) -> Result<(), String> {
use deckcraft_mcp::{Backend, Remote, control_addr};
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
let mut method: Option<String> = None;
let mut rest = vec![];
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--port" => port = it.next().cloned().ok_or("--port needs a value")?,
"--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?),
"--help" | "-h" => {
outln!("{USAGE}");
return Ok(());
}
other => rest.push(other.to_string()),
}
}
let mut r = Remote::connect(&control_addr(&port))
.map_err(|e| format!("no DeckCraft app on port {port} ({e}); start it with `deckcraft --control {port}`"))?;
let parse = |s: Option<&String>| -> Result<Value, String> {
s.map(|j| serde_json::from_str(j).map_err(|e| format!("bad JSON: {e}"))).transpose().map(|v| v.unwrap_or(json!({})))
};
let v = match method {
Some(m) => r.call(&m, parse(rest.first())?)?,
None => {
let id = rest.first().ok_or("app needs a COMMAND or --method")?;
r.call("engine.execute", json!({"command": id, "params": parse(rest.get(1))?}))?
}
};
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
Ok(())
}
fn mcp(args: &[String]) -> Result<(), String> {
use deckcraft_engine::file_access::{self, AutomationRoots};
use deckcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
let mut connect: Option<String> = None;
let mut sample = false;
let mut roots_args = automation_args::AutomationArgs::new(None, None);
let mut it = args.iter().cloned();
while let Some(a) = it.next() {
if roots_args.consume(&a, &mut it)? {
continue;
}
match a.as_str() {
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
"--sample" => sample = true,
"--help" | "-h" => {
outln!("{USAGE}");
return Ok(());
}
other => return Err(format!("unknown mcp option `{other}`")),
}
}
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
.into());
}
let backend: Box<dyn Backend> = match connect {
Some(c) => Box::new(Remote::connect(&control_addr(&c)).map_err(|e| format!("can't reach the app at {c}: {e}"))?),
None => {
let mut h = Headless::new();
// `--sample` is the operator's own choice, made before the roots come into force.
if sample {
deckcraft_engine::sample::open_sample(&mut h.session).map_err(|e| e.to_string())?;
}
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
h = h.with_automation_roots(Some(roots));
}
Box::new(h)
}
};
let mut server = Server::new(backend);
let stdin = std::io::stdin();
server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
}
#[test]
fn render_help_prints_usage_and_writes_nothing() {
assert!(render(&args(&["--help"])).is_ok());
assert!(render(&args(&["-h"])).is_ok());
assert!(!std::path::Path::new("--help").exists());
}
#[test]
fn render_rejects_unknown_flag() {
let err = render(&args(&["--sample", "--scael", "2", "out.png"])).unwrap_err();
assert_eq!(err, "unknown option `--scael`");
assert!(!std::path::Path::new("--scael").exists());
}
#[test]
fn other_subcommands_accept_help() {
assert!(info(&args(&["--help"])).is_ok());
assert!(convert(&args(&["-h"])).is_ok());
assert!(run(&args(&["--help"])).is_ok());
assert!(describe(Some("--help")).is_ok());
assert!(mcp(&args(&["-h"])).is_ok());
}
}
#[cfg(test)]
mod slide_index_tests {
use super::{missing_slide, slide_indices};
#[test]
fn slide_numbers_start_at_zero_and_files_at_one() {
assert_eq!(slide_indices(8, false, None).ok().as_deref(), Some(&[0][..]));
assert_eq!(slide_indices(8, false, Some(0)).ok().as_deref(), Some(&[0][..]));
assert_eq!(slide_indices(8, false, Some(7)).ok().as_deref(), Some(&[7][..]));
assert_eq!(slide_indices(8, true, None).ok().as_deref(), Some(&[0, 1, 2, 3, 4, 5, 6, 7][..]));
let err = slide_indices(8, false, Some(8)).err().unwrap_or_default();
assert!(!err.is_empty(), "index 8 was accepted");
assert!(err.contains("numbered 0 through 7"), "{err}");
assert!(err.contains("slide-01.png"), "{err}");
assert!(err.contains("slide-08.png"), "{err}");
assert!(missing_slide(0, 0).contains("no slides"));
assert!(missing_slide(1, 1).contains("index 0"));
}
}
#[cfg(test)]
mod frame_path_tests {
use super::frame_paths;
#[test]
fn single_frame_uses_out_as_is() {
assert_eq!(frame_paths("a/b.png", &[0.5]), Ok(vec!["a/b.png".to_string()]));
}
#[test]
fn frames_get_distinct_names() {
let paths = frame_paths("f.png", &[0.0, 0.25, 1.0]).unwrap_or_default();
assert_eq!(paths, ["f-t0.000.png", "f-t0.250.png", "f-t1.000.png"]);
}
#[test]
fn values_rounding_to_one_name_are_rejected() {
assert!(frame_paths("f.png", &[0.1001, 0.1002]).is_err());
assert!(frame_paths("f.png", &[0.5, 0.5]).is_err());
}
}