mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 13:06:56 -06:00
Merge the landing branch into land-r7
# Conflicts: # apps/deckcraft-cli/src/main.rs # apps/deckcraft-cli/tests/cli.rs # crates/engine/src/cmd/file.rs # crates/engine/src/cmd/text.rs # crates/engine/src/lib.rs # crates/engine/src/tests.rs # crates/ui-egui/src/canvas.rs # crates/ui-egui/src/lib.rs # crates/ui-egui/src/textures.rs
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
pub struct Subcommand {
|
||||
pub name: &'static str,
|
||||
pub usage: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub details: &'static str,
|
||||
}
|
||||
|
||||
pub const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand {
|
||||
name: "info",
|
||||
usage: "info FILE",
|
||||
summary: "print a presentation's slides, layouts and theme as JSON",
|
||||
details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes,
|
||||
notes, transition), its layouts, theme fonts, sections and slide size.
|
||||
|
||||
The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command:
|
||||
|
||||
deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "render",
|
||||
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
|
||||
summary: "draw slides as PNG images",
|
||||
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
|
||||
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
|
||||
slide. Prints each image's path and size.
|
||||
|
||||
--slide N the slide at index N; indices start at 0, and 0 is the default
|
||||
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
|
||||
--edit draw what the editor shows: placeholder prompts and their dashed outlines
|
||||
|
||||
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
|
||||
},
|
||||
Subcommand {
|
||||
name: "render-transition",
|
||||
usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png",
|
||||
summary: "draw frames of the transition into a slide as PNG images",
|
||||
details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N),
|
||||
as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as
|
||||
OUT-t0.250.png and so on. Prints each image's path, size, transition and time.
|
||||
|
||||
--slide N the slide the transition leads into; indices start at 0
|
||||
--t T,T... the times to draw, comma-separated
|
||||
--width PX the image width in pixels, twice the slide width in points by default
|
||||
|
||||
deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png",
|
||||
},
|
||||
Subcommand {
|
||||
name: "convert",
|
||||
usage: "convert IN OUT",
|
||||
summary: "save a presentation in the format OUT's extension names",
|
||||
details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names:
|
||||
.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an
|
||||
animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
|
||||
|
||||
deckcraft-cli convert deck.deckcraft deck.pptx",
|
||||
},
|
||||
Subcommand {
|
||||
name: "run",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
summary: "run commands on a presentation, then save or export it",
|
||||
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
|
||||
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
|
||||
everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the
|
||||
current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per
|
||||
slide). --export-params passes file.export options as one JSON object, such as
|
||||
'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF
|
||||
(`deckcraft-cli describe file.export` lists them).
|
||||
|
||||
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
|
||||
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
|
||||
|
||||
deckcraft-cli run \\
|
||||
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
|
||||
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
|
||||
--cmd 'format.size={\"size\":28}' \\
|
||||
--save q3.deckcraft
|
||||
|
||||
`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its
|
||||
parameters.
|
||||
|
||||
What the ids don't say:
|
||||
- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is
|
||||
[x, y, width, height].
|
||||
- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current;
|
||||
slide.go {\"index\": N} makes slide N current.
|
||||
- Shape ids are looked up on the current slide only.
|
||||
- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts
|
||||
on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands
|
||||
take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on.
|
||||
|
||||
If a command fails, run stops there and saves nothing. --print shows each command's answer, such as
|
||||
the id of an inserted shape.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "commands",
|
||||
usage: "commands [FILTER]",
|
||||
summary: "list the commands run can use, one per line",
|
||||
details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in
|
||||
the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case.
|
||||
|
||||
deckcraft-cli commands format.
|
||||
|
||||
In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what
|
||||
the command answers. `deckcraft-cli describe COMMAND` spells one command out.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "describe",
|
||||
usage: "describe COMMAND",
|
||||
summary: "explain one command and its parameters",
|
||||
details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run
|
||||
calls it.
|
||||
|
||||
deckcraft-cli describe shape.insert",
|
||||
},
|
||||
Subcommand {
|
||||
name: "app",
|
||||
usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]",
|
||||
summary: "run a command in the DeckCraft app started with --control",
|
||||
details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default)
|
||||
and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD
|
||||
[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object.
|
||||
|
||||
deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}'
|
||||
deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
|
||||
summary: "serve the commands to an agent over MCP on stdio",
|
||||
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
|
||||
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
|
||||
--sample opens the sample presentation.
|
||||
|
||||
--automation-read-root DIR and --automation-write-root DIR 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).",
|
||||
},
|
||||
Subcommand {
|
||||
name: "bench",
|
||||
usage: "bench (FILE | --sample) [--runs N] [--json]",
|
||||
summary: "time opening, drawing, saving and exporting a presentation",
|
||||
details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to
|
||||
save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the
|
||||
median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json.
|
||||
|
||||
deckcraft-cli bench deck.pptx --runs 10",
|
||||
},
|
||||
Subcommand {
|
||||
name: "links",
|
||||
usage: "links",
|
||||
summary: "print the DeckCraft community and project links",
|
||||
details: "Prints the DeckCraft community and project links.",
|
||||
},
|
||||
Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." },
|
||||
];
|
||||
|
||||
pub fn page(name: &str) -> Option<String> {
|
||||
let name = match name {
|
||||
"help" | "--help" | "-h" => return Some(overview()),
|
||||
"--version" | "-V" => "version",
|
||||
name => name,
|
||||
};
|
||||
SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details))
|
||||
}
|
||||
|
||||
pub fn overview() -> String {
|
||||
let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default();
|
||||
let list: Vec<String> =
|
||||
SUBCOMMANDS.iter().map(|s| format!(" {:<width$} {}", s.usage.split(' ').next().unwrap_or_default(), s.summary)).collect();
|
||||
format!(
|
||||
"deckcraft-cli works on DeckCraft presentations (.deckcraft, .pptx) without a window.
|
||||
|
||||
{}
|
||||
|
||||
A presentation is edited by running commands on it:
|
||||
|
||||
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
|
||||
|
||||
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
|
||||
`deckcraft-cli help run` explains how to write a command.",
|
||||
list.join("\n")
|
||||
)
|
||||
}
|
||||
+164
-65
@@ -1,25 +1,11 @@
|
||||
//! 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
|
||||
//! ```
|
||||
//! Headless DeckCraft. `deckcraft-cli help` explains each subcommand (`help.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod automation_args;
|
||||
|
||||
mod help;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
@@ -38,27 +24,28 @@ macro_rules! outln {
|
||||
}};
|
||||
}
|
||||
|
||||
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(&[]);
|
||||
let asks_help = rest.iter().any(|a| a == "--help" || a == "-h");
|
||||
if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) {
|
||||
outln!("{page}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
match args.first().map(String::as_str) {
|
||||
Some("help" | "--help" | "-h") => match rest.first() {
|
||||
None => {
|
||||
outln!("{}", help::overview());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some(name) => match help::page(name) {
|
||||
Some(page) => {
|
||||
outln!("{page}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
None => unknown(name),
|
||||
},
|
||||
},
|
||||
Some("--version" | "-V" | "version") => {
|
||||
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
@@ -69,24 +56,17 @@ fn main() -> ExitCode {
|
||||
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()
|
||||
for c in deckcraft_engine::command_specs()
|
||||
.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());
|
||||
{
|
||||
match c.params.strip_prefix("{}") {
|
||||
Some(answer) => outln!("{} — {}{answer}", c.id, c.label),
|
||||
None => outln!("{} — {}: {}", c.id, c.label, c.params),
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("describe") => report(describe(rest.first().map(String::as_str))),
|
||||
@@ -97,11 +77,8 @@ fn main() -> ExitCode {
|
||||
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
|
||||
}
|
||||
Some(other) => unknown(other),
|
||||
None => usage(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +86,22 @@ fn is_help(arg: &str) -> bool {
|
||||
matches!(arg, "--help" | "-h")
|
||||
}
|
||||
|
||||
/// The help page of subcommand `name`, else the overview.
|
||||
fn help_page(name: &str) -> String {
|
||||
help::page(name).unwrap_or_else(help::overview)
|
||||
}
|
||||
|
||||
fn unknown(sub: &str) -> ExitCode {
|
||||
eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n");
|
||||
usage()
|
||||
}
|
||||
|
||||
fn usage() -> ExitCode {
|
||||
eprintln!("{}", help::overview());
|
||||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
fn report(r: Result<(), String>) -> ExitCode {
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -233,7 +226,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("info"));
|
||||
return Ok(());
|
||||
}
|
||||
let path = args.first().ok_or("info needs a FILE")?;
|
||||
@@ -261,7 +254,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
"--edit" => edit = true,
|
||||
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("render"));
|
||||
return Ok(());
|
||||
}
|
||||
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
|
||||
@@ -410,7 +403,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("convert"));
|
||||
return Ok(());
|
||||
}
|
||||
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
|
||||
@@ -419,6 +412,8 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
open(&mut s, Some(input), false)?;
|
||||
let r = if matches!(fmt, "png" | "jpeg") {
|
||||
s.execute("file.export", &json!({"path": out, "slide": 0}))
|
||||
} else if fmt == "gif" {
|
||||
s.execute("file.export", &json!({"path": out}))
|
||||
} else {
|
||||
s.execute("file.saveAs", &json!({"path": out}))
|
||||
};
|
||||
@@ -443,18 +438,26 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut cmds = vec![];
|
||||
let mut save = None;
|
||||
let mut export = None;
|
||||
let mut export_params = json!({});
|
||||
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]")?),
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--export-params" => {
|
||||
let j = it.next().ok_or("--export-params needs a JSON object")?;
|
||||
export_params = serde_json::from_str::<Value>(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?;
|
||||
if !export_params.is_object() {
|
||||
return Err("--export-params must be a JSON object".into());
|
||||
}
|
||||
}
|
||||
"--print" => print = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("run"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown option `{other}`")),
|
||||
@@ -479,22 +482,76 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
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())?;
|
||||
export_params["path"] = json!(p);
|
||||
s.execute("file.export", &export_params).map_err(|e| e.to_string())?;
|
||||
} else if export_params != json!({}) {
|
||||
return Err("--export-params needs --export OUT".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn describe(id: Option<&str>) -> Result<(), String> {
|
||||
if id.is_some_and(is_help) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("describe"));
|
||||
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);
|
||||
let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?;
|
||||
let mut head = format!("{}: {}", c.id, c.label);
|
||||
if !c.menu.is_empty() {
|
||||
head.push_str(&format!(", in {}", c.menu.join(" ▸ ")));
|
||||
}
|
||||
if let Some(k) = c.shortcut {
|
||||
head.push_str(&format!(", shortcut {k}"));
|
||||
}
|
||||
outln!("{head}.\n");
|
||||
let (args, answer) = c.params.split_once('→').unwrap_or((c.params, ""));
|
||||
let parsed = deckcraft_engine::cmd::parse_params(c.params);
|
||||
match &parsed {
|
||||
Err(_) => outln!("Parameters: {}", args.trim()),
|
||||
Ok(params) if params.is_empty() => outln!("Takes no parameters."),
|
||||
Ok(params) => print_params(params),
|
||||
}
|
||||
let params = parsed.unwrap_or_default();
|
||||
if let Some(acts) = c.acts_on() {
|
||||
outln!("{acts}");
|
||||
}
|
||||
let remark = remark(args);
|
||||
if !remark.is_empty() {
|
||||
outln!("Note: {remark}");
|
||||
}
|
||||
if !answer.trim().is_empty() {
|
||||
outln!("Answers with {}.", answer.trim());
|
||||
}
|
||||
let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional));
|
||||
let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) };
|
||||
outln!("\nRun it with: deckcraft-cli run --cmd {call}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_params(params: &[deckcraft_engine::cmd::Param]) {
|
||||
let name = |p: &deckcraft_engine::cmd::Param| {
|
||||
let or = if p.or { "or " } else { "" };
|
||||
let optional = if p.optional { ", optional" } else { "" };
|
||||
format!("{or}{}{optional}", p.name)
|
||||
};
|
||||
let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default();
|
||||
let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0;
|
||||
outln!("Parameters:");
|
||||
for (k, p) in params.iter().enumerate() {
|
||||
if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) {
|
||||
outln!(" {}", if p.object == 0 { "either:" } else { "or:" });
|
||||
}
|
||||
let indent = if sets { " " } else { " " };
|
||||
outln!("{}", format!("{indent}{:<width$} {}", name(p), p.about).trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
fn remark(args: &str) -> &str {
|
||||
let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim();
|
||||
after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after)
|
||||
}
|
||||
|
||||
fn app(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_mcp::{Backend, Remote, control_addr};
|
||||
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
|
||||
@@ -506,7 +563,7 @@ fn app(args: &[String]) -> Result<(), String> {
|
||||
"--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}");
|
||||
outln!("{}", help_page("app"));
|
||||
return Ok(());
|
||||
}
|
||||
other => rest.push(other.to_string()),
|
||||
@@ -543,7 +600,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
|
||||
"--sample" => sample = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("mcp"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
@@ -580,6 +637,8 @@ so drop --connect (or use --connect without the roots)"
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
@@ -608,6 +667,46 @@ mod tests {
|
||||
assert!(describe(Some("--help")).is_ok());
|
||||
assert!(mcp(&args(&["-h"])).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() {
|
||||
let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
for flag in ["--bogus", "-x"] {
|
||||
let args: Vec<String> = ["--sample", flag, &out.to_string_lossy()].iter().map(|a| a.to_string()).collect();
|
||||
let e = render(&args).unwrap_err();
|
||||
assert!(e.contains(&format!("unknown option `{flag}`")), "{flag}: {e}");
|
||||
assert!(!out.exists(), "{flag}: rendered anyway");
|
||||
}
|
||||
}
|
||||
|
||||
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
|
||||
#[test]
|
||||
fn exports_an_animated_gif() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let gif = dir.join("sample.gif").to_string_lossy().to_string();
|
||||
let deck = dir.join("sample.deckcraft").to_string_lossy().to_string();
|
||||
let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#;
|
||||
run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap();
|
||||
let bytes = std::fs::read(&gif).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide");
|
||||
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block");
|
||||
|
||||
let gif2 = dir.join("converted.gif").to_string_lossy().to_string();
|
||||
convert(&args(&[&deck, &gif2])).unwrap();
|
||||
let bytes = std::fs::read(&gif2).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default");
|
||||
|
||||
// Bad options are errors, not panics.
|
||||
let bad = dir.join("bad.gif").to_string_lossy().to_string();
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok());
|
||||
assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user