mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-25 03:06:53 -06:00
Excel stores the path of a linked workbook relative to the file that uses it, and rewrites it when the file is saved into another folder. The path was kept as text, so after Save As it named a file next to the new copy: a missing link, or another workbook with the same name. Relative externalLinkPath targets are now resolved against the folder the workbook was read from and written relative to the folder it is saved into (absolute when there is no relative path, on another drive).
587 lines
23 KiB
Rust
587 lines
23 KiB
Rust
//! Headless GridCraft.
|
|
//!
|
|
//! ```text
|
|
//! gridcraft-cli info <file> [--json]
|
|
//! gridcraft-cli convert <in> <out> [--sheet NAME]
|
|
//! gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
|
|
//! gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
|
|
//! gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
|
|
//! gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet]
|
|
//! gridcraft-cli commands [--json] [--search X]
|
|
//! gridcraft-cli functions [--json] [--search X] [--category C]
|
|
//! gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
|
|
//! gridcraft-cli send <port> <method> [json]
|
|
//! gridcraft-cli version | --version
|
|
//! ```
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
#![forbid(unsafe_code)]
|
|
|
|
use std::process::ExitCode;
|
|
|
|
/// `println!` that ends the program quietly when stdout is closed (`gridcraft-cli commands |
|
|
/// head`) instead of panicking on a broken pipe.
|
|
macro_rules! outln {
|
|
($($arg:tt)*) => {{
|
|
use std::io::Write as _;
|
|
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
|
$crate::stdout_failed(e);
|
|
}
|
|
}};
|
|
}
|
|
|
|
mod check;
|
|
|
|
use gridcraft_engine::Session;
|
|
use serde_json::{Value, json};
|
|
|
|
const USAGE: &str = "usage:
|
|
gridcraft-cli info <file> [--json] sheets, used ranges, tables, charts, names
|
|
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html by extension
|
|
gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
|
|
gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
|
|
gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
|
|
cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference)
|
|
gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl]
|
|
[--out FILE] [--print RANGE] [--quiet]
|
|
gridcraft-cli commands [--json] [--search X]
|
|
gridcraft-cli functions [--json] [--search X] [--category C]
|
|
gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
|
|
gridcraft-cli send <port> <method> [json]
|
|
gridcraft-cli version";
|
|
|
|
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly; any
|
|
/// other write error is reported.
|
|
fn stdout_failed(e: std::io::Error) -> ! {
|
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
|
std::process::exit(0);
|
|
}
|
|
eprintln!("gridcraft-cli: can't write to stdout: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
fn main() -> ExitCode {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let rest = args.get(1..).unwrap_or_default();
|
|
let r = match args.first().map(String::as_str) {
|
|
Some("--version" | "-V" | "version") => {
|
|
outln!("gridcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
|
Ok(())
|
|
}
|
|
Some("--help" | "-h" | "help") => {
|
|
outln!("{USAGE}");
|
|
Ok(())
|
|
}
|
|
Some("info") => info(rest),
|
|
Some("convert") => convert(rest),
|
|
Some("eval") => eval(rest),
|
|
Some("cat") => cat(rest),
|
|
Some("run") => run(rest),
|
|
Some("check") => return check::main(rest),
|
|
Some("commands") => commands(rest),
|
|
Some("functions") => functions(rest),
|
|
Some("mcp") => mcp(rest),
|
|
Some("send") => send(rest),
|
|
Some(other) => Err(format!("unknown subcommand `{other}`\n{USAGE}")),
|
|
None => Err(USAGE.to_string()),
|
|
};
|
|
match r {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(e) => {
|
|
eprintln!("gridcraft-cli: {e}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- argument parsing
|
|
|
|
/// Parsed arguments: positionals, `--flag value` options and boolean `--switch`es.
|
|
struct Args {
|
|
positional: Vec<String>,
|
|
opts: Vec<(String, String)>,
|
|
switches: Vec<String>,
|
|
}
|
|
|
|
impl Args {
|
|
/// `with_value` lists the options that take a value; `switches` the boolean ones. Anything
|
|
/// else starting with `--` is an error.
|
|
fn parse(args: &[String], with_value: &[&str], switches: &[&str]) -> Result<Args, String> {
|
|
let mut out = Args { positional: vec![], opts: vec![], switches: vec![] };
|
|
let mut it = args.iter();
|
|
while let Some(a) = it.next() {
|
|
if let Some(name) = a.strip_prefix("--").filter(|n| !n.is_empty()) {
|
|
let (name, inline) = match name.split_once('=') {
|
|
Some((n, v)) => (n, Some(v.to_string())),
|
|
None => (name, None),
|
|
};
|
|
if with_value.contains(&name) {
|
|
let v = match inline {
|
|
Some(v) => v,
|
|
None => it.next().cloned().ok_or_else(|| format!("--{name} needs a value"))?,
|
|
};
|
|
out.opts.push((name.to_string(), v));
|
|
} else if switches.contains(&name) && inline.is_none() {
|
|
out.switches.push(name.to_string());
|
|
} else {
|
|
return Err(format!("unknown option --{name}"));
|
|
}
|
|
} else {
|
|
out.positional.push(a.clone());
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
fn opt(&self, name: &str) -> Option<&str> {
|
|
self.opts.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
|
|
}
|
|
fn has(&self, name: &str) -> bool {
|
|
self.switches.iter().any(|s| s == name)
|
|
}
|
|
fn pos(&self, i: usize, what: &str) -> Result<&str, String> {
|
|
self.positional.get(i).map(String::as_str).ok_or_else(|| format!("missing {what}\n{USAGE}"))
|
|
}
|
|
fn no_extra(&self, n: usize) -> Result<(), String> {
|
|
match self.positional.get(n) {
|
|
Some(x) => Err(format!("unexpected argument `{x}`")),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- helpers
|
|
|
|
fn exec(s: &mut Session, id: &str, params: Value) -> Result<Value, String> {
|
|
let r = s.execute_headless(id, params).map_err(|e| e.to_string());
|
|
if id == "file.open"
|
|
&& let Ok(value) = &r
|
|
&& let Some(warnings) = value.get("warnings").and_then(Value::as_array)
|
|
{
|
|
for warning in warnings.iter().filter_map(Value::as_str) {
|
|
eprintln!("Warning: {warning}");
|
|
}
|
|
}
|
|
r
|
|
}
|
|
|
|
fn open(path: &str) -> Result<Session, String> {
|
|
let mut s = Session::new();
|
|
exec(&mut s, "file.open", json!({"path": path}))?;
|
|
Ok(s)
|
|
}
|
|
|
|
fn blank() -> Session {
|
|
let mut s = Session::new();
|
|
s.new_workbook();
|
|
s
|
|
}
|
|
|
|
fn activate_sheet(s: &mut Session, sheet: Option<&str>) -> Result<(), String> {
|
|
if let Some(name) = sheet {
|
|
let p = match name.parse::<u64>() {
|
|
// A number that isn't also a sheet name is an index.
|
|
Ok(i) if s.doc().is_ok_and(|d| d.wb.sheet_index(name).is_none()) => json!({"sheet": i}),
|
|
_ => json!({"sheet": name}),
|
|
};
|
|
exec(s, "sheet.activate", p)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn pretty(v: &Value) -> String {
|
|
serde_json::to_string_pretty(v).unwrap_or_default()
|
|
}
|
|
|
|
/// A number as a person would type it: `6`, `0.5`, `1e+300`.
|
|
fn number_text(n: f64) -> String {
|
|
if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { format!("{n}") }
|
|
}
|
|
|
|
/// A computed value (as returned by `formulas.evaluate` / `sheet.read`) as plain text.
|
|
fn value_text(v: &Value) -> String {
|
|
match v {
|
|
Value::Null => String::new(),
|
|
Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
|
|
Value::Number(n) => n.as_f64().map(number_text).unwrap_or_else(|| n.to_string()),
|
|
Value::String(s) => s.clone(),
|
|
Value::Object(o) => o.get("error").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| v.to_string()),
|
|
Value::Array(rows) => rows
|
|
.iter()
|
|
.map(|r| match r {
|
|
Value::Array(cells) => cells.iter().map(value_text).collect::<Vec<_>>().join("\t"),
|
|
other => value_text(other),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
}
|
|
}
|
|
|
|
fn csv_field(s: &str, delim: char) -> String {
|
|
if s.contains(delim) || s.contains('"') || s.contains('\n') || s.contains('\r') {
|
|
format!("\"{}\"", s.replace('"', "\"\""))
|
|
} else {
|
|
s.to_string()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- subcommands
|
|
|
|
fn info(args: &[String]) -> Result<(), String> {
|
|
let a = Args::parse(args, &[], &["json"])?;
|
|
let path = a.pos(0, "<file>")?;
|
|
a.no_extra(1)?;
|
|
let mut s = open(path)?;
|
|
let v = exec(&mut s, "document.inspect", json!({}))?;
|
|
if a.has("json") {
|
|
outln!("{}", pretty(&v));
|
|
return Ok(());
|
|
}
|
|
outln!("{}", v["title"].as_str().unwrap_or(path));
|
|
for (i, sh) in v["sheets"].as_array().into_iter().flatten().enumerate() {
|
|
let active = if v["activeSheet"].as_u64() == Some(i as u64) { "*" } else { " " };
|
|
let vis = sh["visibility"].as_str().filter(|x| *x != "Visible").map(|x| format!(" ({x})")).unwrap_or_default();
|
|
outln!(
|
|
"{active} {}{vis}: used {}, {} cells",
|
|
sh["name"].as_str().unwrap_or("?"),
|
|
sh["usedRange"].as_str().unwrap_or("(empty)"),
|
|
sh["cells"].as_u64().unwrap_or(0)
|
|
);
|
|
for t in sh["tables"].as_array().into_iter().flatten() {
|
|
outln!(" table {} {} ({})", t["name"].as_str().unwrap_or("?"), t["range"].as_str().unwrap_or("?"), t["style"].as_str().unwrap_or(""));
|
|
}
|
|
for c in sh["charts"].as_array().into_iter().flatten() {
|
|
let title = c["title"].as_str().map(|t| format!(" \"{t}\"")).unwrap_or_default();
|
|
outln!(" chart {}{title} at {}, {} series", c["kind"].as_str().unwrap_or("?"), c["at"].as_str().unwrap_or("?"), c["series"]);
|
|
}
|
|
if let Some(f) = sh["autofilter"].as_str() {
|
|
outln!(" filter {f}");
|
|
}
|
|
}
|
|
let names = v["names"].as_array().cloned().unwrap_or_default();
|
|
if !names.is_empty() {
|
|
outln!("names:");
|
|
for n in names {
|
|
outln!(" {} {}", n["name"].as_str().unwrap_or("?"), n["refersTo"].as_str().unwrap_or(""));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn convert(args: &[String]) -> Result<(), String> {
|
|
let a = Args::parse(args, &["sheet"], &[])?;
|
|
let input = a.pos(0, "<in>")?;
|
|
let out = a.pos(1, "<out>")?;
|
|
a.no_extra(2)?;
|
|
if gridcraft_engine::io::FileKind::from_path(out).is_none() {
|
|
return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json or .html)"));
|
|
}
|
|
let mut s = open(input)?;
|
|
activate_sheet(&mut s, a.opt("sheet"))?;
|
|
let d = s.doc().map_err(|e| e.to_string())?;
|
|
let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?;
|
|
gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?;
|
|
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
|
Ok(())
|
|
}
|
|
|
|
fn eval(args: &[String]) -> Result<(), String> {
|
|
let a = Args::parse(args, &["in", "sheet", "cell"], &["json"])?;
|
|
let formula = a.pos(0, "<formula>")?;
|
|
a.no_extra(1)?;
|
|
let mut s = match a.opt("in") {
|
|
Some(p) => open(p)?,
|
|
None => blank(),
|
|
};
|
|
activate_sheet(&mut s, a.opt("sheet"))?;
|
|
let formula = if formula.starts_with('=') { formula.to_string() } else { format!("={formula}") };
|
|
let mut p = json!({"formula": formula});
|
|
if let Some(c) = a.opt("cell") {
|
|
p["cell"] = json!(c);
|
|
}
|
|
let v = exec(&mut s, "formulas.evaluate", p)?;
|
|
if a.has("json") {
|
|
outln!("{}", serde_json::to_string(&v).unwrap_or_default());
|
|
} else {
|
|
outln!("{}", value_text(&v));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Prints `range` of the active (or `sheet`) sheet as an aligned table or CSV.
|
|
fn print_range(s: &mut Session, range: Option<&str>, sheet: Option<&str>, formulas: bool, csv: bool) -> Result<(), String> {
|
|
let mut p = json!({});
|
|
if let Some(r) = range {
|
|
p["range"] = json!(r);
|
|
}
|
|
if let Some(sh) = sheet {
|
|
p["sheet"] = json!(sh);
|
|
}
|
|
let raw = exec(s, "sheet.read", p.clone())?;
|
|
p["formatted"] = json!(true);
|
|
p["formulas"] = json!(formulas);
|
|
let shown = exec(s, "sheet.read", p)?;
|
|
let rows: Vec<Vec<String>> = shown["values"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.map(|r| r.as_array().into_iter().flatten().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| value_text(c))).collect())
|
|
.collect();
|
|
if csv {
|
|
for r in &rows {
|
|
outln!("{}", r.iter().map(|c| csv_field(c, ',')).collect::<Vec<_>>().join(","));
|
|
}
|
|
return Ok(());
|
|
}
|
|
let numeric = |ri: usize, ci: usize| raw["values"][ri][ci].is_number() && !formulas;
|
|
let ncols = rows.iter().map(Vec::len).max().unwrap_or(0);
|
|
let widths: Vec<usize> = (0..ncols).map(|c| rows.iter().filter_map(|r| r.get(c)).map(|t| t.chars().count()).max().unwrap_or(0).min(60)).collect();
|
|
for (ri, r) in rows.iter().enumerate() {
|
|
let mut line = String::new();
|
|
for (ci, cell) in r.iter().enumerate() {
|
|
let w = widths.get(ci).copied().unwrap_or(0);
|
|
let cell: String = cell.chars().map(|c| if c == '\n' || c == '\t' { ' ' } else { c }).collect();
|
|
if ci > 0 {
|
|
line.push_str(" ");
|
|
}
|
|
if numeric(ri, ci) {
|
|
line.push_str(&format!("{cell:>w$}"));
|
|
} else {
|
|
line.push_str(&format!("{cell:<w$}"));
|
|
}
|
|
}
|
|
outln!("{}", line.trim_end());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cat(args: &[String]) -> Result<(), String> {
|
|
let a = Args::parse(args, &["range", "sheet"], &["formulas", "csv"])?;
|
|
let path = a.pos(0, "<file>")?;
|
|
a.no_extra(1)?;
|
|
let mut s = open(path)?;
|
|
print_range(&mut s, a.opt("range"), a.opt("sheet"), a.has("formulas"), a.has("csv"))
|
|
}
|
|
|
|
/// One command from `id={json}`, `id {json}`, `id` or `{"command": id, "params": {..}}`.
|
|
fn parse_command(text: &str) -> Result<Option<(String, Value)>, String> {
|
|
let t = text.trim();
|
|
if t.is_empty() || t.starts_with('#') || t.starts_with("//") {
|
|
return Ok(None);
|
|
}
|
|
if t.starts_with('{') {
|
|
let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?;
|
|
let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?;
|
|
return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({})))));
|
|
}
|
|
let split = t.find(['=', ' ', '\t']);
|
|
let (id, rest) = match split {
|
|
Some(i) => (t.get(..i).unwrap_or(t), t.get(i + 1..).unwrap_or("").trim()),
|
|
None => (t, ""),
|
|
};
|
|
let params = if rest.is_empty() { json!({}) } else { serde_json::from_str(rest).map_err(|e| format!("{id}: bad JSON params: {e}"))? };
|
|
Ok(Some((id.to_string(), params)))
|
|
}
|
|
|
|
fn run(args: &[String]) -> Result<(), String> {
|
|
let a = Args::parse(args, &["in", "sample", "cmd", "script", "out", "print", "sheet"], &["quiet", "csv", "formulas"])?;
|
|
a.no_extra(0)?;
|
|
let mut s = match (a.opt("in"), a.opt("sample")) {
|
|
(Some(_), Some(_)) => return Err("give --in or --sample, not both".into()),
|
|
(Some(p), None) => open(p)?,
|
|
(None, Some(name)) => {
|
|
let mut s = Session::new();
|
|
exec(&mut s, "file.new", json!({"sample": name}))?;
|
|
s
|
|
}
|
|
(None, None) => blank(),
|
|
};
|
|
let quiet = a.has("quiet");
|
|
let mut step = 0usize;
|
|
for (name, value) in &a.opts {
|
|
let cmds: Vec<(String, Value, String)> = match name.as_str() {
|
|
"cmd" => parse_command(value)?.map(|(id, p)| vec![(id, p, "--cmd".to_string())]).unwrap_or_default(),
|
|
"script" => {
|
|
let text = std::fs::read_to_string(value).map_err(|e| format!("{value}: {e}"))?;
|
|
let mut v = Vec::new();
|
|
for (i, line) in text.lines().enumerate() {
|
|
if let Some((id, p)) = parse_command(line).map_err(|e| format!("{value}:{}: {e}", i + 1))? {
|
|
v.push((id, p, format!("{value}:{}", i + 1)));
|
|
}
|
|
}
|
|
v
|
|
}
|
|
_ => continue,
|
|
};
|
|
for (id, p, origin) in cmds {
|
|
step += 1;
|
|
let r = exec(&mut s, &id, p).map_err(|e| format!("step {step} ({origin}, {id}): {e}"))?;
|
|
if !quiet && !r.is_null() {
|
|
outln!("{}", serde_json::to_string(&json!({"command": id, "result": r})).unwrap_or_default());
|
|
}
|
|
}
|
|
}
|
|
if let Some(out) = a.opt("out") {
|
|
let r = exec(&mut s, "file.save", json!({"path": out}))?;
|
|
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
|
|
}
|
|
for (name, range) in &a.opts {
|
|
if name == "print" {
|
|
let range = if range.is_empty() || range == "used" { None } else { Some(range.as_str()) };
|
|
print_range(&mut s, range, a.opt("sheet"), a.has("formulas"), a.has("csv"))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn commands(args: &[String]) -> Result<(), String> {
|
|
let a = Args::parse(args, &["search"], &["json"])?;
|
|
let q = a.opt("search").or(a.positional.first().map(String::as_str)).map(str::to_ascii_lowercase);
|
|
let s = Session::new();
|
|
let list: Vec<_> = s
|
|
.commands()
|
|
.into_iter()
|
|
.filter(|c| {
|
|
q.as_ref().is_none_or(|q| {
|
|
c.id.to_ascii_lowercase().contains(q.as_str())
|
|
|| c.label.to_ascii_lowercase().contains(q.as_str())
|
|
|| c.menu.iter().any(|m| m.to_ascii_lowercase().contains(q.as_str()))
|
|
})
|
|
})
|
|
.collect();
|
|
if a.has("json") {
|
|
// Enablement depends on a live workbook; not meaningful here.
|
|
let v: Vec<Value> =
|
|
list.iter().map(|c| json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params})).collect();
|
|
outln!("{}", pretty(&Value::Array(v)));
|
|
return Ok(());
|
|
}
|
|
let w = list.iter().map(|c| c.id.len()).max().unwrap_or(0);
|
|
for c in &list {
|
|
let sc = c.shortcut.map(|s| format!(" [{s}]")).unwrap_or_default();
|
|
let menu = if c.menu.is_empty() { String::new() } else { format!(" ({})", c.menu.join(" > ")) };
|
|
outln!("{:<w$} {}{menu}{sc}", c.id, c.label.trim_end_matches('…'));
|
|
outln!("{:<w$} {}", "", c.params);
|
|
}
|
|
eprintln!("{} commands", list.len());
|
|
Ok(())
|
|
}
|
|
|
|
fn functions(args: &[String]) -> Result<(), String> {
|
|
let a = Args::parse(args, &["search", "category"], &["json"])?;
|
|
let mut p = json!({});
|
|
if let Some(q) = a.opt("search").or(a.positional.first().map(String::as_str)) {
|
|
p["search"] = json!(q);
|
|
}
|
|
if let Some(c) = a.opt("category") {
|
|
p["category"] = json!(c);
|
|
}
|
|
let mut s = Session::new();
|
|
let v = exec(&mut s, "formulas.functions", p)?;
|
|
if a.has("json") {
|
|
outln!("{}", pretty(&v));
|
|
return Ok(());
|
|
}
|
|
let list = v.as_array().cloned().unwrap_or_default();
|
|
for f in &list {
|
|
let sig = f["signature"].as_str().or(f["name"].as_str()).unwrap_or("?");
|
|
outln!("{sig}");
|
|
let cat = f["category"].as_str().unwrap_or("");
|
|
let desc = f["description"].as_str().unwrap_or("");
|
|
outln!(" {cat}: {desc}");
|
|
}
|
|
eprintln!("{} functions", list.len());
|
|
Ok(())
|
|
}
|
|
|
|
/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app
|
|
/// started with `gridcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr.
|
|
fn mcp(args: &[String]) -> Result<(), String> {
|
|
use gridcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
|
|
let a = Args::parse(args, &["connect", "in", "sample"], &[])?;
|
|
a.no_extra(0)?;
|
|
let backend: Box<dyn Backend> = match a.opt("connect") {
|
|
Some(c) => {
|
|
let addr = control_addr(c);
|
|
Box::new(
|
|
Remote::connect(&addr)
|
|
.map_err(|e| format!("cannot connect to the GridCraft app at {addr}: {e} (start it with `gridcraft --control PORT`)"))?,
|
|
)
|
|
}
|
|
None => {
|
|
let session = match (a.opt("in"), a.opt("sample")) {
|
|
(Some(p), _) => open(p)?,
|
|
(None, Some(name)) => {
|
|
let mut s = Session::new();
|
|
exec(&mut s, "file.new", json!({"sample": name}))?;
|
|
s
|
|
}
|
|
(None, None) => blank(),
|
|
};
|
|
Box::new(Headless::with_session(session))
|
|
}
|
|
};
|
|
eprintln!("gridcraft-cli: MCP server on stdio ({})", backend.describe());
|
|
let stdin = std::io::stdin();
|
|
let stdout = std::io::stdout();
|
|
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// `send PORT METHOD [JSON]`: one control-channel request to a running app.
|
|
fn send(args: &[String]) -> Result<(), String> {
|
|
use gridcraft_mcp::{Backend, Remote, control_addr};
|
|
let port = args.first().ok_or_else(|| format!("missing <port>\n{USAGE}"))?;
|
|
let method = args.get(1).ok_or_else(|| format!("missing <method>\n{USAGE}"))?;
|
|
let params: Value = match args.get(2) {
|
|
Some(t) => serde_json::from_str(t).map_err(|e| format!("bad JSON params: {e}"))?,
|
|
None => json!({}),
|
|
};
|
|
if let Some(x) = args.get(3) {
|
|
return Err(format!("unexpected argument `{x}`"));
|
|
}
|
|
let addr = control_addr(port);
|
|
let mut r = Remote::connect(&addr)
|
|
.map_err(|e| format!("cannot connect to the GridCraft app at {addr}: {e} (start it with `gridcraft --control {port}`)"))?;
|
|
let v = r.call(method, params)?;
|
|
outln!("{}", pretty(&v));
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(clippy::unwrap_used)]
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_commands() {
|
|
assert_eq!(parse_command("home.bold={\"range\":\"A1\"}").unwrap(), Some(("home.bold".into(), json!({"range": "A1"}))));
|
|
assert_eq!(parse_command("edit.undo").unwrap(), Some(("edit.undo".into(), json!({}))));
|
|
assert_eq!(
|
|
parse_command("cell.set {\"cell\":\"A1\",\"input\":\"=1\"}").unwrap(),
|
|
Some(("cell.set".into(), json!({"cell": "A1", "input": "=1"})))
|
|
);
|
|
assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({}))));
|
|
assert_eq!(parse_command(" # comment").unwrap(), None);
|
|
assert!(parse_command("x={bad").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn value_texts() {
|
|
assert_eq!(value_text(&json!(6.0)), "6");
|
|
assert_eq!(value_text(&json!(0.25)), "0.25");
|
|
assert_eq!(value_text(&json!(true)), "TRUE");
|
|
assert_eq!(value_text(&json!({"error": "#DIV/0!"})), "#DIV/0!");
|
|
assert_eq!(value_text(&json!([[1.0, 2.0], [3.0, "x"]])), "1\t2\n3\tx");
|
|
}
|
|
|
|
#[test]
|
|
fn args_parsing() {
|
|
let v: Vec<String> = ["a", "--range", "A1", "--csv", "--sheet=S"].iter().map(|s| s.to_string()).collect();
|
|
let a = Args::parse(&v, &["range", "sheet"], &["csv"]).unwrap();
|
|
assert_eq!(a.positional, ["a"]);
|
|
assert_eq!(a.opt("range"), Some("A1"));
|
|
assert_eq!(a.opt("sheet"), Some("S"));
|
|
assert!(a.has("csv"));
|
|
assert!(Args::parse(&["--nope".to_string()], &[], &[]).is_err());
|
|
assert!(Args::parse(&["--range".to_string()], &["range"], &[]).is_err());
|
|
}
|
|
}
|