Merge main into #1420

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mert Dede
2026-10-30 01:48:09 +03:00
co-authored by Claude Opus 5.5
10 changed files with 649 additions and 351 deletions
Generated
+54
View File
@@ -746,6 +746,46 @@ dependencies = [
"num-traits",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "clipboard-win"
version = "5.4.3"
@@ -1737,6 +1777,7 @@ dependencies = [
name = "figcraft-cli"
version = "0.12.1"
dependencies = [
"clap",
"figcraft-doc",
"figcraft-engine",
"figcraft-format",
@@ -1745,6 +1786,7 @@ dependencies = [
"figcraft-svg",
"figcraft-text",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
@@ -2564,6 +2606,12 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -5006,6 +5054,12 @@ dependencies = [
"serde",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subsetter"
version = "0.2.6"
+2
View File
@@ -57,6 +57,8 @@ craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "92d39
# foundation
kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
# Command-line parsing for figcraft-cli (craft-contrib RFC-0012: clap derive, as photocraft does).
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
+2 -1
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -22,3 +22,5 @@ figcraft-svg = { workspace = true }
figcraft-format = { workspace = true }
figcraft-mcp = { workspace = true }
serde_json = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
+510 -155
View File
@@ -1,175 +1,530 @@
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`].
//! The command line, as clap types (craft-contrib RFC-0012). Values are parsed into their types
//! here, so a bad one is a usage error naming its flag (exit 2) before any work starts, and the
//! handlers never see it. A flag a subcommand doesn't take is a usage error, and so is one given
//! twice, except the automation roots, which repeat. A value starting with `-` is written
//! `--filter=-x`; after `--` every argument is a positional.
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::net::{Ipv4Addr, SocketAddr};
use crate::{CliError, CliResult};
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
use figcraft_doc::{PrintSettings, VdCompat};
#[derive(Debug, Default)]
pub struct Args {
pub positional: Vec<String>,
values: BTreeMap<String, Vec<String>>,
flags: Vec<String>,
/// FigCraft without a window
///
/// Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig
/// (best effort; `info` lists what wasn't imported), .sketch (Sketch 48 or newer, best effort),
/// .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer).
#[derive(Parser)]
#[command(name = "figcraft-cli", bin_name = "figcraft-cli", version, arg_required_else_help = true, after_help = "See docs/cli.md and docs/mcp.md.")]
pub struct Cli {
/// Lay text out with the fonts built into FigCraft only, for renders that match on every
/// machine (or set FIGCRAFT_NO_SYSTEM_FONTS=1). Otherwise installed fonts are indexed the
/// first time text is laid out, as in the app
#[arg(long, global = true)]
pub no_system_fonts: bool,
#[command(subcommand)]
pub command: Command,
}
impl Args {
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Self> {
let mut a = Args::default();
// The first bad option, reported once the rest of the line is checked for `--help`.
let mut bad: Option<CliError> = None;
let mut it = argv.iter();
while let Some(arg) = it.next() {
if arg == "--" {
a.positional.extend(it.cloned());
break;
#[derive(Subcommand)]
pub enum Command {
Info(InfoArgs),
Export(ExportArgs),
Convert(ConvertArgs),
Render(RenderArgs),
Run(RunArgs),
Commands(CommandsArgs),
Mcp(McpArgs),
/// Publish plugins to a server's plugin directory
Plugin {
#[command(subcommand)]
command: PluginCommand,
},
Perf(PerfArgs),
}
/// The folders a file subcommand may touch. Without either flag it reads and writes as the user
/// who started it; with one, reads are allowed only inside a read or write root and writes only
/// inside a write root, so a read root alone denies writing altogether.
#[derive(Args)]
pub struct RootArgs {
/// Confine file access: a folder that may be read (repeatable; it must exist)
#[arg(long, value_name = "DIR")]
pub automation_read_root: Vec<String>,
/// Confine file access: a folder that may be read and written (repeatable; it must exist)
#[arg(long, value_name = "DIR")]
pub automation_write_root: Vec<String>,
}
/// Pages, node counts, top-level frames, style/variable/image counts
#[derive(Args)]
pub struct InfoArgs {
/// The document to read
#[arg(value_name = "FILE")]
pub file: String,
/// Print JSON
#[arg(long)]
pub json: bool,
#[command(flatten)]
pub roots: RootArgs,
}
/// Export frames, a layer or the whole file
///
/// <OUT> is a folder: one file per top-level frame of the page. An <OUT> with an image extension,
/// or --node, writes one file. `fig` (or an <OUT> ending in .fig) writes the whole file as .fig,
/// experimental: other apps may not open every file. `penpot` (or an <OUT> ending in .penpot)
/// writes the whole file as a Penpot 2.x archive. The format comes from --format, else <OUT>'s
/// extension, else png.
#[derive(Args)]
pub struct ExportArgs {
/// The document to read
#[arg(value_name = "FILE")]
pub file: String,
/// The folder or file to write
#[arg(short = 'o', long = "out", value_name = "OUT")]
pub out: String,
/// png, jpg, svg, webp, pdf, xml (Android Vector Drawable), fig or penpot
#[arg(long, value_name = "FORMAT", value_parser = format)]
pub format: Option<String>,
#[command(flatten)]
pub scale: ScaleArg,
/// The page to export (default the first)
#[arg(long, value_name = "NAME")]
pub page: Option<String>,
/// The layer to export, by id (12 or 0:12) or name
#[arg(long, value_name = "ID|NAME")]
pub node: Option<String>,
/// PDF: grow each page by this bleed, in points or a length like 3mm or 0.125in (up to 2in)
#[arg(long, value_name = "LENGTH", value_parser = bleed, default_value = "0")]
pub bleed: f64,
/// PDF: draw crop marks outside the bleed
#[arg(long)]
pub crop_marks: bool,
/// PDF: draw bleed marks outside the bleed
#[arg(long)]
pub bleed_marks: bool,
/// PDF: write CMYK colour
#[arg(long)]
pub cmyk: bool,
/// Android Vector Drawable: what the format can't draw is approximated (bestEffort), left out
/// (exact) or dropped without notes (ignore)
#[arg(long, value_name = "bestEffort|exact|ignore", value_parser = vd_compat, default_value = "bestEffort")]
pub vd_compat: VdCompat,
#[command(flatten)]
pub roots: RootArgs,
}
impl ExportArgs {
/// The PDF print options.
pub fn print(&self) -> PrintSettings {
PrintSettings { bleed: self.bleed, crop_marks: self.crop_marks, bleed_marks: self.bleed_marks, cmyk: self.cmyk }
}
}
/// `--scale`, shared by `export` and `render`.
#[derive(Args)]
pub struct ScaleArg {
/// Raster scale, in (0, 64]: 2 or 2x
// allow_negative_numbers makes `--scale -3` an out-of-range value rather than an unexpected argument.
#[arg(long, value_name = "N", value_parser = scale, default_value = "1", allow_negative_numbers = true)]
pub scale: f64,
}
/// Convert by extension (.figcraft, .json, .svg, .png, .jpg, .webp, .pdf, .fig, .penpot)
#[derive(Args)]
pub struct ConvertArgs {
/// The document to read
#[arg(value_name = "IN")]
pub input: String,
/// The file to write; its extension picks the format
#[arg(value_name = "OUT")]
pub output: String,
#[command(flatten)]
pub roots: RootArgs,
}
/// Render the whole page with its background as png, jpg or webp
#[derive(Args)]
pub struct RenderArgs {
/// The document to read
#[arg(value_name = "FILE")]
pub file: String,
/// The image to write
#[arg(short = 'o', long = "out", value_name = "OUT.png")]
pub out: String,
#[command(flatten)]
pub scale: ScaleArg,
/// The page to render (default the first)
#[arg(long, value_name = "NAME")]
pub page: Option<String>,
#[command(flatten)]
pub roots: RootArgs,
}
/// Run a script of commands in a headless session, one JSON result line per step
///
/// The script is a JSON array (or JSON lines) of {"command": id, "params": {...}}, run as one
/// all-or-nothing undo step.
#[derive(Args)]
pub struct RunArgs {
/// The script to run
#[arg(value_name = "SCRIPT.json|SCRIPT.jsonl")]
pub script: String,
/// Open this file first instead of a new document
#[arg(long = "in", value_name = "FILE")]
pub input: Option<String>,
/// Save the result here, by extension
#[arg(long, value_name = "FILE")]
pub out: Option<String>,
#[command(flatten)]
pub roots: RootArgs,
}
/// List every command with its label, shortcut, undoable flag (*) and parameters
#[derive(Args)]
pub struct CommandsArgs {
/// Print JSON
#[arg(long)]
pub json: bool,
/// Only the commands whose id, label or parameters contain this text
#[arg(long, value_name = "TEXT")]
pub filter: Option<String>,
}
/// Serve MCP on stdio: headless, or attached to a running app
///
/// The automation roots confine the headless session's file access. A server started with
/// --attach is confined by the app it attaches to: start that app with `figcraft --control PORT`
/// and the same roots.
#[derive(Args)]
pub struct McpArgs {
/// Attach to the app started with `figcraft --control PORT`; a bare port means 127.0.0.1:PORT.
/// Refuses the automation roots
#[arg(long, value_name = "PORT", value_parser = attach, conflicts_with_all = ["automation_read_root", "automation_write_root"])]
pub attach: Option<String>,
#[command(flatten)]
pub roots: RootArgs,
}
#[derive(Subcommand)]
pub enum PluginCommand {
Publish(PublishArgs),
}
/// Send a plugin folder to a figcraft-server's plugin directory as a new version
///
/// The folder holds figcraft-plugin.json, its module and an optional assets/ folder. The server's
/// key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out
/// of the shell history); it is never printed.
#[derive(Args)]
pub struct PublishArgs {
/// The plugin folder
#[arg(value_name = "FOLDER")]
pub folder: String,
/// The server, ws://host:port/ (optionally ?key=KEY)
#[arg(long, value_name = "URL")]
pub server: String,
}
/// Measure the hot paths on synthetic documents and print Markdown tables
#[derive(Args)]
pub struct PerfArgs {
/// Document sizes in layers, each clamped to 10..=2,000,000
#[arg(long, value_name = "N[,N...]", value_parser = whole_number, value_delimiter = ',', action = ArgAction::Set, default_value = "1000,10000")]
pub layers: Vec<usize>,
/// Component instance counts, capped to 100,000; 0 skips the components table
#[arg(long, value_name = "N[,N...]", value_parser = whole_number, value_delimiter = ',', action = ArgAction::Set, default_value = "2000")]
pub instances: Vec<usize>,
}
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
enum ValueError {
#[error("expected a number in (0, 64], like 2 or 2x")]
Scale,
#[error("expected points or a length like 3mm or 0.125in, up to 2in")]
Bleed,
#[error("expected bestEffort, exact or ignore")]
VdCompat,
#[error("expected png, jpg, svg, webp, pdf, xml, fig or penpot")]
Format,
#[error("expected whole numbers, like 1000,10_000")]
WholeNumber,
#[error("expected a port number or host:port")]
Attach,
}
fn scale(s: &str) -> Result<f64, ValueError> {
s.trim_end_matches('x').parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 64.0).ok_or(ValueError::Scale)
}
fn bleed(s: &str) -> Result<f64, ValueError> {
PrintSettings::parse_length(s).filter(|b| *b <= PrintSettings::MAX_BLEED).ok_or(ValueError::Bleed)
}
fn vd_compat(s: &str) -> Result<VdCompat, ValueError> {
VdCompat::from_key(s).ok_or(ValueError::VdCompat)
}
/// `--format`, any case; kept as text for the exporter, which also reads it from the extension.
fn format(s: &str) -> Result<String, ValueError> {
let f = s.to_ascii_lowercase();
match f.as_str() {
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" | "fig" | "penpot" => Ok(f),
_ => Err(ValueError::Format),
}
}
fn whole_number(s: &str) -> Result<usize, ValueError> {
s.trim().replace('_', "").parse().map_err(|_| ValueError::WholeNumber)
}
/// `mcp --attach`: `host:port` as given, or a bare port on 127.0.0.1.
fn attach(s: &str) -> Result<String, ValueError> {
if s.contains(':') {
return Ok(s.to_owned());
}
let port: u16 = s.parse().map_err(|_| ValueError::Attach)?;
Ok(SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string())
}
/// Parse the arguments, the program name first. clap reads them as `OsString`s, so a non-Unicode
/// one is a usage error rather than a panic. A bare `--help` or `-h` before `--` is help even after
/// a mistake on the same line (`mcp --attach nope --help`, `export -o --help`), which clap would
/// report first.
pub fn parse(args: Vec<OsString>) -> Result<Cli, clap::Error> {
let mut cmd = Cli::command();
match cmd.try_get_matches_from_mut(&args) {
Ok(matches) => Cli::from_arg_matches(&matches),
Err(e) => match args.iter().skip(1).take_while(|a| *a != "--").find(|a| *a == "--help" || *a == "-h") {
Some(flag) => {
let help: Vec<OsString> = subcommand_path(&cmd, &args).into_iter().chain([flag.clone()]).collect();
Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e))
}
let name = if let Some(n) = arg.strip_prefix("--") {
n
} else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) {
n
} else {
a.positional.push(arg.clone());
continue;
};
let (name, inline) = match name.split_once('=') {
Some((n, v)) => (n, Some(v.to_string())),
None => (name, None),
};
// `--help` / `-h` anywhere among the options (not as an option's value) asks for help.
if inline.is_none() && matches!(name, "help" | "h") {
return Err(CliError::Help);
}
if valued.contains(&name) {
match inline.or_else(|| it.next().cloned()) {
Some(v) => a.values.entry(name.to_string()).or_default().push(v),
None => {
bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value")));
}
}
} else if flags.contains(&name) && inline.is_none() {
a.flags.push(name.to_string());
} else {
bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`")));
}
}
match bad {
Some(e) => Err(e),
None => Ok(a),
}
None => Err(e),
},
}
}
pub fn value(&self, k: &str) -> Option<String> {
self.values.get(k).and_then(|v| v.last()).cloned()
}
/// Every value of an option that may be given more than once, in order.
pub fn values(&self, k: &str) -> &[String] {
self.values.get(k).map_or(&[], Vec::as_slice)
}
pub fn flag(&self, k: &str) -> bool {
self.flags.iter().any(|f| f == k)
}
pub fn one_positional(&self, usage: &str) -> CliResult<String> {
match self.positional.as_slice() {
[one] => Ok(one.clone()),
_ => Err(CliError::Usage(format!("expected: {usage}"))),
}
}
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
/// `--bleed-marks`, `--cmyk`.
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
let bleed = match self.value("bleed") {
None => 0.0,
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
};
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
}
/// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
pub fn vd_compat(&self) -> CliResult<figcraft_doc::VdCompat> {
match self.value("vd-compat") {
None => Ok(figcraft_doc::VdCompat::default()),
Some(s) => figcraft_doc::VdCompat::from_key(&s)
.ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
}
}
pub fn scale(&self) -> CliResult<f64> {
match self.value("scale") {
None => Ok(1.0),
Some(s) => s
.trim_end_matches('x')
.parse::<f64>()
.ok()
.filter(|v| v.is_finite() && *v > 0.0 && *v <= 64.0)
.ok_or_else(|| CliError::Usage(format!("--scale must be a number in (0, 64], got `{s}`"))),
}
}
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
match self.value(k) {
None => Ok(default.to_vec()),
Some(s) => s
.split(',')
.map(|v| v.trim().replace('_', "").parse::<usize>().map_err(|_| CliError::Usage(format!("--{k} must be whole numbers, got `{s}`"))))
.collect(),
}
/// The program name and the subcommands `args` names (`figcraft-cli plugin publish`): at each
/// level, the first argument that isn't a flag, while it names a subcommand.
fn subcommand_path(cmd: &clap::Command, args: &[OsString]) -> Vec<OsString> {
let mut path: Vec<OsString> = args.first().cloned().into_iter().collect();
let mut at = cmd;
let mut rest = args.iter().skip(1).filter(|a| !a.to_string_lossy().starts_with('-'));
while let Some(sub) = rest.next().and_then(|a| a.to_str()).and_then(|name| at.find_subcommand(name)) {
path.push(sub.get_name().into());
at = sub;
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
use clap::error::ErrorKind;
fn v(s: &[&str]) -> Vec<String> {
s.iter().map(|x| x.to_string()).collect()
use super::{Cli, Command, PluginCommand, parse};
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
parse(std::iter::once("figcraft-cli").chain(args.iter().copied()).map(Into::into).collect())
}
fn ok(args: &[&str]) -> Command {
match try_parse(args) {
Ok(cli) => cli.command,
Err(e) => panic!("{args:?}: {e}"),
}
}
fn kind(args: &[&str]) -> ErrorKind {
match try_parse(args) {
Ok(_) => panic!("{args:?} parsed"),
Err(e) => e.kind(),
}
}
#[test]
fn the_definition_is_consistent() {
Cli::command().debug_assert();
}
#[test]
fn info_and_the_roots() {
let Command::Info(a) = ok(&["info", "a.figcraft", "--json", "--automation-read-root", "in", "--automation-read-root=more"]) else {
panic!("info")
};
assert_eq!(a.file, "a.figcraft");
assert!(a.json);
assert_eq!(a.roots.automation_read_root, ["in", "more"]);
assert!(a.roots.automation_write_root.is_empty());
let Command::Info(a) = ok(&["info", "--json", "--", "--drawing.figcraft"]) else { panic!("info") };
assert_eq!(a.file, "--drawing.figcraft");
}
#[test]
fn export_takes_every_documented_option() {
let Command::Export(a) = ok(&[
"export",
"app.figcraft",
"-o",
"poster.pdf",
"--format=PDF",
"--scale",
"2x",
"--page",
"Screens",
"--node",
"Poster",
"--bleed",
"3mm",
"--crop-marks",
"--bleed-marks",
"--cmyk",
"--vd-compat",
"exact",
]) else {
panic!("export")
};
assert_eq!((a.file.as_str(), a.out.as_str(), a.format.as_deref()), ("app.figcraft", "poster.pdf", Some("pdf")));
assert_eq!((a.scale.scale, a.page.as_deref(), a.node.as_deref()), (2.0, Some("Screens"), Some("Poster")));
let print = a.print();
assert!((print.bleed - 3.0 * 72.0 / 25.4).abs() < 1e-9, "{}", print.bleed);
assert!(print.crop_marks && print.bleed_marks && print.cmyk);
assert_eq!(a.vd_compat, figcraft_doc::VdCompat::Exact);
// The defaults, and `--out` for `-o`.
let Command::Export(a) = ok(&["export", "app.figcraft", "--out=exports/"]) else { panic!("export") };
assert_eq!((a.out.as_str(), a.format.as_deref(), a.scale.scale, a.print().bleed), ("exports/", None, 1.0, 0.0));
assert_eq!(a.vd_compat, figcraft_doc::VdCompat::default());
}
#[test]
fn convert_render_run_commands_perf() {
let Command::Convert(a) = ok(&["convert", "in.svg", "out.figcraft"]) else { panic!("convert") };
assert_eq!((a.input.as_str(), a.output.as_str()), ("in.svg", "out.figcraft"));
let Command::Render(a) = ok(&["render", "doc.figcraft", "--out", "doc.png", "--scale=0.5", "--page", "P"]) else { panic!("render") };
assert_eq!((a.out.as_str(), a.scale.scale, a.page.as_deref()), ("doc.png", 0.5, Some("P")));
let Command::Run(a) = ok(&["run", "s.jsonl", "--in", "a.figcraft", "--out", "b.figcraft", "--automation-write-root", "."]) else {
panic!("run")
};
assert_eq!((a.script.as_str(), a.input.as_deref(), a.out.as_deref()), ("s.jsonl", Some("a.figcraft"), Some("b.figcraft")));
assert_eq!(a.roots.automation_write_root, ["."]);
let Command::Commands(a) = ok(&["commands", "--json", "--filter=--help"]) else { panic!("commands") };
assert!(a.json);
assert_eq!(a.filter.as_deref(), Some("--help"));
let Command::Perf(a) = ok(&["perf"]) else { panic!("perf") };
assert_eq!((a.layers, a.instances), (vec![1000, 10_000], vec![2000]));
let Command::Perf(a) = ok(&["perf", "--layers", "1000,10_000, 50000", "--instances=0"]) else { panic!("perf") };
assert_eq!((a.layers, a.instances), (vec![1000, 10_000, 50_000], vec![0]));
}
#[test]
fn mcp_and_plugin_publish() {
let Command::Mcp(a) = ok(&["mcp"]) else { panic!("mcp") };
assert_eq!(a.attach, None);
let Command::Mcp(a) = ok(&["mcp", "--attach", "7979"]) else { panic!("mcp") };
assert_eq!(a.attach.as_deref(), Some("127.0.0.1:7979"));
let Command::Mcp(a) = ok(&["mcp", "--attach=localhost:7979"]) else { panic!("mcp") };
assert_eq!(a.attach.as_deref(), Some("localhost:7979"));
let Command::Mcp(a) = ok(&["mcp", "--automation-read-root", "in", "--automation-write-root", "out"]) else { panic!("mcp") };
assert_eq!((a.roots.automation_read_root, a.roots.automation_write_root), (vec!["in".to_owned()], vec!["out".to_owned()]));
let Command::Plugin { command: PluginCommand::Publish(a) } = ok(&["plugin", "publish", "dist/counter", "--server", "ws://h:47900/"]) else {
panic!("plugin")
};
assert_eq!((a.folder.as_str(), a.server.as_str()), ("dist/counter", "ws://h:47900/"));
}
#[test]
fn the_font_flag_goes_before_or_after_the_subcommand() {
for args in [&["--no-system-fonts", "commands"][..], &["commands", "--no-system-fonts"]] {
assert!(try_parse(args).is_ok_and(|cli| cli.no_system_fonts), "{args:?}");
}
assert!(try_parse(&["commands"]).is_ok_and(|cli| !cli.no_system_fonts));
}
#[test]
fn bad_arguments_are_usage_errors() {
use ErrorKind::{
ArgumentConflict, DisplayHelpOnMissingArgumentOrSubcommand, InvalidSubcommand, InvalidValue, MissingRequiredArgument, TooManyValues,
UnknownArgument, ValueValidation,
};
let cases: &[(&[&str], ErrorKind)] = &[
(&[], DisplayHelpOnMissingArgumentOrSubcommand),
(&["bogus"], InvalidSubcommand),
(&["version"], InvalidSubcommand),
(&["export", "x.figcraft"], MissingRequiredArgument),
(&["info", "--nope", "a"], UnknownArgument),
(&["info", "a", "b"], UnknownArgument),
(&["info", "--json=1", "a"], TooManyValues),
(&["render", "a", "-o"], InvalidValue),
(&["export", "a", "-o", "b", "--scale", "0"], ValueValidation),
(&["export", "a", "-o", "b", "--scale", "-3"], ValueValidation),
(&["export", "a", "-o", "b", "--scale", "65"], ValueValidation),
(&["export", "a", "-o", "b", "--bleed", "lots"], ValueValidation),
(&["export", "a", "-o", "b", "--bleed", "3in"], ValueValidation),
(&["export", "a", "-o", "b", "--vd-compat", "sloppy"], ValueValidation),
(&["export", "a", "-o", "b", "--format", "bmp"], ValueValidation),
(&["export", "a", "-o", "b", "--page", "A", "--page", "B"], ArgumentConflict),
(&["perf", "--shapes", "lots"], UnknownArgument),
(&["perf", "--layers", "10,x"], ValueValidation),
(&["perf", "--layers", "10", "--layers", "20"], ArgumentConflict),
(&["mcp", "--attach", "notaport"], ValueValidation),
(&["mcp", "--attach", "7979", "--automation-read-root", "."], ArgumentConflict),
(&["mcp", "--automation-write-root"], InvalidValue),
(&["mcp", "stray"], UnknownArgument),
(&["convert", "a"], MissingRequiredArgument),
(&["plugin", "publish", "dist"], MissingRequiredArgument),
(&["commands", "--filter", "--json"], InvalidValue),
];
for (args, want) in cases {
assert_eq!(kind(args), *want, "{args:?}");
assert_eq!(try_parse(args).err().map(|e| e.exit_code()), Some(2), "{args:?}");
}
assert_eq!(try_parse(&["plugin"]).err().map(|e| e.exit_code()), Some(2));
}
#[test]
fn help_and_version_exit_0() {
assert_eq!(kind(&["--version"]), ErrorKind::DisplayVersion);
assert_eq!(kind(&["-V"]), ErrorKind::DisplayVersion);
for args in [
&["--help"][..],
&["-h"],
&["help"],
&["help", "export"],
&["export", "x.figcraft", "-o", "out", "--help"],
&["export", "-o", "--help"],
&["mcp", "--attach", "notaport", "--help"],
&["mcp", "--bogus", "-h"],
&["--no-system-fonts", "plugin", "publish", "--bogus", "--help"],
] {
let e = try_parse(args).err();
assert_eq!(e.as_ref().map(clap::Error::kind), Some(ErrorKind::DisplayHelp), "{args:?}");
assert_eq!(e.map(|e| e.exit_code()), Some(0), "{args:?}");
}
// The help is the subcommand's, even after a mistake.
let help = try_parse(&["plugin", "publish", "--bogus", "--help"]).err().map(|e| e.to_string()).unwrap_or_default();
assert!(help.contains("Usage: figcraft-cli plugin publish"), "{help}");
// After `--`, `-h` is a file name.
let Command::Info(a) = ok(&["info", "--", "-h"]) else { panic!("info") };
assert_eq!(a.file, "-h");
}
/// Every subcommand's help renders and documents each of its options.
#[test]
fn parses_options_and_flags() {
let a = Args::parse(&v(&["in.figcraft", "-o", "out", "--scale=2", "--json"]), &["o", "scale"], &["json"]).unwrap();
assert_eq!(a.positional, ["in.figcraft"]);
assert_eq!(a.value("o").as_deref(), Some("out"));
assert_eq!(a.scale().unwrap(), 2.0);
assert!(a.flag("json"));
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
assert_eq!(a.values("root"), ["a", "b"]);
assert_eq!(a.value("root").as_deref(), Some("b"));
assert!(a.values("none").is_empty());
}
#[test]
fn rejects_bad_input() {
assert!(matches!(Args::parse(&v(&["--nope"]), &[], &[]), Err(CliError::Usage(_))));
assert!(matches!(Args::parse(&v(&["-o"]), &["o"], &[]), Err(CliError::Usage(_))));
assert!(matches!(Args::parse(&v(&["--json=1"]), &[], &["json"]), Err(CliError::Usage(_))));
let a = Args::parse(&v(&["--scale", "abc"]), &["scale"], &[]).unwrap();
assert!(a.scale().is_err());
let a = Args::parse(&v(&["--scale", "-3"]), &["scale"], &[]).unwrap();
assert!(a.scale().is_err());
// Negative numbers are positionals, not options.
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
assert_eq!(a.positional, ["-5"]);
}
#[test]
fn help_is_asked_for_anywhere_but_as_a_value() {
// Also after a bad option: help wins over the usage error (#1161 review).
for argv in
[&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]]
{
assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}");
fn every_subcommand_has_help() {
let mut cli = Cli::command();
cli.build();
let mut todo = vec![cli];
while let Some(mut cmd) = todo.pop() {
let help = cmd.render_long_help().to_string();
assert!(help.contains("Usage: figcraft-cli"), "{}: {help}", cmd.get_name());
for arg in cmd.get_arguments().filter(|a| !matches!(a.get_long(), Some("help" | "version"))) {
assert!(arg.get_help().is_some(), "{} {}", cmd.get_name(), arg.get_id());
}
todo.extend(cmd.get_subcommands().filter(|s| s.get_name() != "help").cloned());
}
let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap();
assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h")));
// `--help=x` is no request for help, just an unknown option.
assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_))));
}
}
+42 -168
View File
@@ -10,77 +10,38 @@ mod perf;
use std::io::Write;
use std::process::ExitCode;
use args::Args;
use args::{Cli, Command, PluginCommand, RootArgs};
use serde_json::{Value, json};
pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
figcraft-cli commands [--json] [--filter TEXT]
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too)
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
indexed the first time text is laid out, as in the app.
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
not open every file; `penpot`, or an <out> ending in .penpot: the whole file as a Penpot 2.x archive); PDFs take print options (a bleed like 3mm or 0.125in,
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
only inside a write root, everything else refused -- so giving no write root denies writing
altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands,
and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a
subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is
confined by the app it attaches to, not by flags passed to the CLI: start that app with
`figcraft --control PORT` and the same roots (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
if read.is_empty() && write.is_empty() {
fn roots_of(r: &RootArgs) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
if r.automation_read_root.is_empty() && r.automation_write_root.is_empty() {
return Ok(None);
}
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
Ok(Some(figcraft_engine::file_access::Roots::new(&r.automation_read_root, &r.automation_write_root).map_err(CliError::Failed)?))
}
/// Runs `f` confined to the roots `a` carries.
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
let roots = roots_of(a)?.map(std::sync::Arc::new);
/// Runs `f` confined to the roots `r` carries.
fn confined<R>(r: &RootArgs, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
let roots = roots_of(r)?.map(std::sync::Arc::new);
figcraft_engine::file_access::confined(roots.as_ref(), f)
}
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
match run(&argv) {
Ok(code) => code,
Err(CliError::Help) => {
println!("{USAGE}");
ExitCode::SUCCESS
// `args_os`: a non-Unicode argument is clap's usage error, not a panic.
let cli = match args::parse(std::env::args_os().collect()) {
Ok(cli) => cli,
Err(e) => {
// Help and `--version` on stdout with 0, usage errors on stderr with 2.
let _ = e.print();
return ExitCode::from(u8::try_from(e.exit_code()).unwrap_or(2));
}
};
match run(cli) {
Ok(code) => code,
Err(CliError::Usage(msg)) => {
eprintln!("error: {msg}\n\n{USAGE}");
eprintln!("error: {msg}\n\nFor more information, try '--help'.");
ExitCode::from(2)
}
Err(CliError::Failed(msg)) => {
@@ -92,9 +53,7 @@ fn main() -> ExitCode {
#[derive(Debug)]
pub enum CliError {
/// `--help` or `-h` after a subcommand: usage on stdout, exit code 0.
Help,
/// Bad arguments: usage is printed, exit code 2.
/// Bad arguments found after parsing (an output extension no format writes): exit code 2.
Usage(String),
/// The operation failed: exit code 1.
Failed(String),
@@ -108,10 +67,8 @@ impl From<String> for CliError {
pub type CliResult<T> = Result<T, CliError>;
fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Args> {
let flags = [flags, &["no-system-fonts"]].concat();
let args = Args::parse(argv, valued, &flags)?;
if !args.flag("no-system-fonts") {
fn run(cli: Cli) -> CliResult<ExitCode> {
if !cli.no_system_fonts {
// Text lays out with the installed fonts, like the desktop app, so headless renders and
// auto layout sizes match what users see.
figcraft_text::scan_system_fonts_on_demand();
@@ -119,106 +76,28 @@ fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Arg
// Nothing scans them then, not even a `.sketch` import matching its fonts.
figcraft_text::opt_out_of_system_fonts();
}
Ok(args)
}
fn run(argv: &[String]) -> CliResult<ExitCode> {
let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else {
return Err(CliError::Usage("missing subcommand".into()));
};
let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?;
// Keep leading global flags with the subcommand's options, where values and `--` are parsed.
let rest: Vec<String> = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect();
match cmd.as_str() {
"help" | "--help" | "-h" => {
println!("{USAGE}");
match cli.command {
Command::Info(a) => confined(&a.roots, || files::info(&a.file, a.json)),
Command::Export(a) => {
let target =
files::Target { print: a.print(), vd_compat: a.vd_compat, scale: a.scale.scale, format: a.format, page: a.page, node: a.node };
confined(&a.roots, || files::export(&a.file, &a.out, &target))
}
Command::Convert(a) => confined(&a.roots, || files::convert(&a.input, &a.output)),
Command::Render(a) => confined(&a.roots, || files::render(&a.file, &a.out, a.scale.scale, a.page.as_deref())),
Command::Run(a) => confined(&a.roots, || files::run_script(&a.script, a.input.as_deref(), a.out.as_deref())),
Command::Commands(a) => {
commands(a.json, a.filter.as_deref());
Ok(ExitCode::SUCCESS)
}
"--version" | "-V" | "version" => {
println!("figcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(ExitCode::SUCCESS)
// `--attach` conflicts with the roots: the app runs the commands, only its own roots can confine them.
Command::Mcp(a) => mcp(a.attach.as_deref(), roots_of(&a.roots)?),
Command::Plugin { command: PluginCommand::Publish(a) } => publish_plugin(&a.folder, &a.server),
Command::Perf(a) => {
let sizes: Vec<usize> = a.layers.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.instances.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
perf::run(&sizes, &instances, !cli.no_system_fonts)
}
"info" => {
let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?;
let file = a.one_positional("info <file>")?;
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
let target = files::Target {
format: a.value("format"),
scale: a.scale()?,
page: a.value("page"),
node: a.value("node"),
print: a.print()?,
vd_compat: a.vd_compat()?,
};
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
let a = parse_args(&rest, &ROOT_FLAGS, &[])?;
match a.positional.as_slice() {
[i, o] => confined(&a, || files::convert(i, o)),
_ => Err(CliError::Usage("convert <in> <out>".into())),
}
}
"render" => {
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
let a = parse_args(&rest, &valued, &[])?;
let file = a.one_positional("render <file> -o <out.png>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
}
"run" => {
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
let a = parse_args(&rest, &valued, &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
}
"commands" => {
let a = parse_args(&rest, &["filter"], &["json"])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("commands takes no positional arguments".into()));
}
commands(a.flag("json"), a.value("filter").as_deref());
Ok(ExitCode::SUCCESS)
}
"mcp" => {
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
let a = parse_args(&rest, &valued, &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
let attach = a.value("attach");
// The app runs the commands: only its own roots can confine them.
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
mcp(attach.as_deref(), roots_of(&a)?)
}
"plugin" => {
let a = parse_args(&rest, &["server"], &[])?;
match a.positional.as_slice() {
[verb, folder] if verb == "publish" => {
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
publish_plugin(folder, &server)
}
_ => Err(CliError::Usage("expected: plugin publish <folder> --server ws://host:port/".into())),
}
}
"perf" => {
let a = parse_args(&rest, &["layers", "instances"], &[])?;
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
perf::run(&sizes, &instances, !a.flag("no-system-fonts"))
}
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
}
}
@@ -270,13 +149,8 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
Some(p) => {
let addr = if p.contains(':') {
p.to_string()
} else {
format!("127.0.0.1:{}", p.parse::<u16>().map_err(|_| CliError::Usage(format!("--attach needs a port number, got `{p}`")))?)
};
let b = figcraft_mcp::Attached::connect(&addr)
Some(addr) => {
let b = figcraft_mcp::Attached::connect(addr)
.map_err(|e| format!("can't reach the FigCraft app at {addr}: {e} (start it with `figcraft --control PORT`)"))?;
Box::new(b)
}
-11
View File
@@ -535,17 +535,6 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
#[cfg(test)]
mod tests {
#[test]
fn layer_sizes_parse_as_a_list() {
use crate::args::Args;
let argv = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
let a = Args::parse(&argv(&["--layers", "1000,10_000, 50000"]), &["layers"], &[]).unwrap();
assert_eq!(a.usizes("layers", &[1]).unwrap(), vec![1000, 10_000, 50_000]);
assert_eq!(Args::parse(&[], &["layers"], &[]).unwrap().usizes("layers", &[7]).unwrap(), vec![7]);
let bad = Args::parse(&argv(&["--layers", "10,x"]), &["layers"], &[]).unwrap();
assert!(bad.usizes("layers", &[1]).is_err());
}
#[test]
fn measures_components_at_a_small_scale() {
let mut t = super::Table { rows: Vec::new() };
+10 -9
View File
@@ -65,8 +65,9 @@ fn option_terminator_allows_dash_prefixed_filenames() {
#[test]
fn global_font_flag_respects_option_values() {
for filter in ["--", "--no-system-fonts"] {
let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap();
// A value starting with `-` is written with `=` (RFC-0012).
for filter in ["--filter=--", "--filter=--no-system-fonts"] {
let output = bin().args(["commands", filter, "--no-system-fonts", "--json"]).output().unwrap();
assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr));
assert!(serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap().is_array());
}
@@ -98,15 +99,15 @@ fn bad_arguments_exit_2_without_panicking() {
let out = bin().args(args).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("USAGE"), "{args:?}");
assert!(err.contains("Usage: figcraft-cli") || err.contains("try '--help'"), "{args:?}: {err}");
assert!(!err.contains("panicked"));
}
let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
}
/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first
/// (#1155), whatever else is on the line; nothing runs.
/// `--help` / `-h` after a subcommand prints that subcommand's help on stdout and exits 0, as it
/// does first (#1155), whatever else is on the line; nothing runs.
#[test]
fn help_after_a_subcommand_prints_the_usage() {
for args in [
@@ -130,13 +131,13 @@ fn help_after_a_subcommand_prints_the_usage() {
let out = bin().args(args).stdin(Stdio::null()).output().unwrap();
assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}");
assert!(text.contains("Usage: figcraft-cli"), "{args:?}: {text}");
assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr));
}
// A value that happens to be `-h` is still a value, not a request for help.
let out = bin().args(["commands", "--filter", "--help"]).output().unwrap();
// A value that happens to be `--help` is still a value, not a request for help.
let out = bin().args(["commands", "--filter=--help"]).output().unwrap();
assert_eq!(out.status.code(), Some(0));
assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text");
assert!(!String::from_utf8_lossy(&out.stdout).contains("Usage:"), "--filter took `--help` as its text");
}
#[test]
+25 -6
View File
@@ -4,13 +4,32 @@ FigCraft without a window: inspect, export, convert and render documents, run co
commands, serve MCP and measure performance. Build it with
`cargo build --release -p figcraft-cli` (binary `target/release/figcraft-cli`).
`figcraft-cli --help` (or `-h`, or `help`) prints usage on stdout and exits with 0, and so does
`--help` or `-h` after a subcommand (`figcraft-cli mcp --help`). Bad arguments print usage and exit
with code 2; failures exit with 1. The CLI never panics on input.
The CLI follows [RFC-0012](https://github.com/storytold/craft-contrib/blob/main/rfcs/0012-cli-conventions.md),
the command-line conventions the Craft apps share, in how it parses its arguments (with clap). The
rules it doesn't follow yet are listed below. `figcraft-cli --help` is the source of truth for
the flags; this page explains what they do.
Put options before `--` to pass a filename beginning with a dash, for example
`figcraft-cli info --json -- --drawing.figcraft`. Every argument after `--` is literal,
including a filename named `--no-system-fonts`.
`figcraft-cli --help` (or `-h`, `help`, `help <subcommand>`) prints help on stdout and exits with
0. So does `--help` or `-h` after a subcommand (`figcraft-cli mcp --help`), even after a mistake on
the same line. `--version` (or `-V`) prints the version. Bad arguments are usage errors, caught
before any work starts: an option the subcommand doesn't take, an option given twice (only the
automation roots repeat), a missing or bad value (`--scale 0`, `--format bmp`, `--attach nope`)
and `mcp --attach` with automation roots. They print `error: …` on stderr and exit with code 2.
Failures exit with 1. The CLI never panics on input.
A value that starts with `-` is written with `=`, for example `--filter=--json`, so an option never
takes the next option as its value. Put options before `--` to pass a filename beginning with a
dash, for example `figcraft-cli info --json -- --drawing.figcraft`. Every argument after `--` is
literal, including a filename named `--no-system-fonts`.
Not yet as RFC-0012 asks:
- Arguments that name files are read as text, so a file name that isn't valid Unicode is a usage
error (exit 2) rather than reaching the command.
- Failures are messages rather than error enums, and `main` isn't yet a `run(args, out, err)`
function the tests call in-process.
- `FIGCRAFT_NO_SYSTEM_FONTS` and `FIGCRAFT_SERVER_KEY` are read where they're used, not declared
on a flag, so `--help` names them only in the flag descriptions.
- There are no generated shell completions or man pages, and no ast-grep checks in CI yet.
## Fonts
+2 -1
View File
File diff suppressed because one or more lines are too long