use serde_json::Value;
pub mod autolaunch;
pub mod figures;
pub mod lint;
pub mod schema;
pub mod tools;
pub use figures::{export_figures, plan_figures, FigurePlan, FigureResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Facet {
Core,
Editor,
Mermaid,
Vcs,
UiParity,
Swarm,
}
#[derive(Clone, Copy)]
pub struct DataSchema {
pub version: u32,
pub schema: fn() -> Value,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ToolError {
pub code: String,
pub message: String,
}
impl ToolError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct ToolOutcome {
pub data: Value,
pub tool_error: Option<ToolError>,
}
impl ToolOutcome {
pub fn ok(data: Value) -> Self {
Self {
data,
tool_error: None,
}
}
pub fn fail(data: Value, error: ToolError) -> Self {
Self {
data,
tool_error: Some(error),
}
}
pub fn is_ok(&self) -> bool {
self.tool_error.is_none()
}
}
pub struct ToolSpec {
pub name: &'static str,
pub description: &'static str,
pub input_schema: fn() -> Value,
pub data_schema: DataSchema,
pub mutates: bool,
pub facet: Facet,
pub handler: fn(&Ctx, &Value) -> Result<ToolOutcome, EngineFault>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum EngineFault {
#[error("unknown tool: {0}")]
UnknownTool(String),
#[error("invalid arguments: {0}")]
BadArgs(String),
#[error("transport error: {0}")]
Transport(String),
}
#[derive(Debug, thiserror::Error)]
pub enum TransportError {
#[error("no Scrybe app is running")]
NoApp,
#[error("app error {}: {}", .0.code, .0.message)]
Remote(scrybe_rpc::RpcError),
#[error("{0}")]
Transport(String),
}
pub trait Transport {
fn call(&self, method: &str, params: Value) -> Result<Value, TransportError>;
fn is_live(&self) -> bool;
}
pub struct Headless;
impl Transport for Headless {
fn call(&self, _method: &str, _params: Value) -> Result<Value, TransportError> {
Err(TransportError::NoApp)
}
fn is_live(&self) -> bool {
false
}
}
pub struct LiveApp;
fn map_send(res: Result<Value, scrybe_rpc::ClientError>) -> Result<Value, TransportError> {
match res {
Ok(result) => Ok(result),
Err(e) if e.is_not_running() => Err(TransportError::NoApp),
Err(scrybe_rpc::ClientError::Remote(err)) => Err(TransportError::Remote(err)),
Err(e) => Err(TransportError::Transport(e.to_string())),
}
}
impl Transport for LiveApp {
fn call(&self, method: &str, params: Value) -> Result<Value, TransportError> {
match scrybe_rpc::client::send(method, params.clone()) {
Ok(result) => Ok(result),
Err(e) if e.is_not_running() => {
if crate::autolaunch::autolaunch_and_wait() {
map_send(scrybe_rpc::client::send(method, params))
} else {
Err(TransportError::NoApp)
}
}
Err(scrybe_rpc::ClientError::Remote(err)) => Err(TransportError::Remote(err)),
Err(e) => Err(TransportError::Transport(e.to_string())),
}
}
fn is_live(&self) -> bool {
scrybe_rpc::client::is_live()
}
}
pub struct Ctx {
pub transport: Box<dyn Transport>,
}
impl Ctx {
pub fn headless() -> Self {
Self {
transport: Box::new(Headless),
}
}
pub fn live() -> Self {
Self {
transport: Box::new(LiveApp),
}
}
pub fn with_transport(transport: Box<dyn Transport>) -> Self {
Self { transport }
}
}
pub struct Registry {
tools: Vec<ToolSpec>,
}
impl Registry {
pub fn new() -> Self {
Self { tools: Vec::new() }
}
pub fn register(&mut self, spec: ToolSpec) {
assert!(
self.get(spec.name).is_none(),
"duplicate tool registered: {}",
spec.name
);
self.tools.push(spec);
}
pub fn get(&self, name: &str) -> Option<&ToolSpec> {
self.tools.iter().find(|t| t.name == name)
}
pub fn names(&self) -> Vec<&'static str> {
self.tools.iter().map(|t| t.name).collect()
}
pub fn len(&self) -> usize {
self.tools.len()
}
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
pub fn call(&self, name: &str, ctx: &Ctx, args: &Value) -> Result<ToolOutcome, EngineFault> {
let spec = self
.get(name)
.ok_or_else(|| EngineFault::UnknownTool(name.to_string()))?;
require_args(&(spec.input_schema)(), args)?;
(spec.handler)(ctx, args)
}
}
impl Default for Registry {
fn default() -> Self {
let mut reg = Self::new();
tools::register_defaults(&mut reg);
reg
}
}
fn require_args(schema: &Value, args: &Value) -> Result<(), EngineFault> {
if let Some(required) = schema.get("required").and_then(Value::as_array) {
for key in required.iter().filter_map(Value::as_str) {
if args.get(key).is_none() {
return Err(EngineFault::BadArgs(format!(
"missing required argument: {key}"
)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn default_registry_registers_render() {
let reg = Registry::default();
assert!(reg.get("render").is_some());
assert!(reg.names().contains(&"render"));
assert!(!reg.is_empty());
}
#[test]
fn unknown_tool_is_engine_fault() {
let reg = Registry::default();
let err = reg
.call("does_not_exist", &Ctx::headless(), &json!({}))
.unwrap_err();
assert_eq!(err, EngineFault::UnknownTool("does_not_exist".into()));
}
#[test]
fn missing_required_arg_is_bad_args() {
let reg = Registry::default();
let err = reg
.call("render", &Ctx::headless(), &json!({}))
.unwrap_err();
assert!(
matches!(err, EngineFault::BadArgs(ref m) if m.contains("source")),
"expected BadArgs mentioning source, got {err:?}"
);
}
#[test]
fn headless_transport_has_no_live_app() {
let ctx = Ctx::headless();
assert!(!ctx.transport.is_live());
assert!(ctx.transport.call("open", json!({})).is_err());
}
#[test]
fn live_app_transport_is_wired_and_fails_cleanly_without_an_app() {
let ctx = Ctx::live();
if !ctx.transport.is_live() {
assert!(
ctx.transport.call("state", json!({})).is_err(),
"no live app → call must error"
);
}
}
#[test]
#[should_panic(expected = "duplicate tool")]
fn duplicate_registration_panics() {
let mut reg = Registry::new();
reg.register(tools::render::spec());
reg.register(tools::render::spec());
}
#[test]
fn every_data_schema_is_an_honest_envelope_never_a_placeholder() {
let reg = Registry::default();
for name in reg.names() {
let spec = reg.get(name).unwrap();
let schema = (spec.data_schema.schema)();
assert_eq!(
schema["properties"]["kind"]["const"], name,
"tool {name}: `kind` must be pinned to the tool name"
);
assert_eq!(
schema["properties"]["v"]["const"], spec.data_schema.version,
"tool {name}: `v` must be pinned to the data-schema version"
);
assert!(
schema["properties"]["tool_error"].is_object(),
"tool {name}: envelope must describe the optional tool_error"
);
let required = schema["required"].as_array().unwrap();
assert!(
required.contains(&json!("v")) && required.contains(&json!("kind")),
"tool {name}: v and kind are required envelope keys"
);
assert!(
!required.contains(&json!("tool_error")),
"tool {name}: tool_error is never required (absent on success)"
);
}
}
}