use super::policy;
use crate::{Error, InvokeOptions, Result};
use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
fn unsupported(message: &str) -> Error {
Error::new("DOCUMENT_RPC_INPUT_UNSUPPORTED", message)
}
pub(crate) fn normalize_aliases(operation: &Value, params: &Value) -> Result<Map<String, Value>> {
let mut params = params
.as_object()
.cloned()
.ok_or_else(|| Error::new("INVALID_ARGUMENT", "operation parameters must be an object"))?;
let id = operation["operationId"].as_str().unwrap_or("");
if let Some(aliases) = policy()["operationParamAliases"][id].as_object() {
for (canonical, names) in aliases {
for alias in names
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
if let Some(value) = params.remove(alias) {
if params
.get(canonical)
.is_some_and(|existing| existing != &value)
{
return Err(Error::new(
"INVALID_ARGUMENT",
"conflicting canonical and alias parameter values",
));
}
params.insert(canonical.clone(), value);
}
}
}
}
Ok(params)
}
pub(crate) fn argv(
operation: &Value,
params: &Value,
options: &InvokeOptions,
default_change_mode: Option<&str>,
) -> Result<Vec<String>> {
let mut params = normalize_aliases(operation, params)?;
let specs = operation["params"].as_array().ok_or_else(|| {
Error::new(
"HOST_PROTOCOL_ERROR",
"operation parameter metadata is missing",
)
})?;
for (name, value) in [
(
"expectedRevision",
options.expected_revision.clone().map(Value::String),
),
(
"changeMode",
options
.change_mode
.clone()
.or_else(|| default_change_mode.map(str::to_owned))
.map(Value::String),
),
("dryRun", options.dry_run.map(Value::Bool)),
] {
if let Some(value) = value {
if params.get(name).is_none_or(Value::is_null)
&& specs.iter().any(|spec| spec["name"] == name)
{
params.insert(name.to_owned(), value);
}
}
}
if params
.keys()
.any(|key| !specs.iter().any(|spec| spec["name"] == *key))
{
return Err(Error::new(
"INVALID_ARGUMENT",
"operation contains an undeclared parameter",
));
}
let mut argv: Vec<_> = operation["commandTokens"]
.as_array()
.ok_or_else(|| {
Error::new(
"HOST_PROTOCOL_ERROR",
"operation command metadata is missing",
)
})?
.iter()
.map(|value| {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| Error::new("HOST_PROTOCOL_ERROR", "command token must be a string"))
})
.collect::<Result<_>>()?;
for spec in specs {
let name = spec["name"]
.as_str()
.ok_or_else(|| Error::new("HOST_PROTOCOL_ERROR", "parameter name is missing"))?;
let Some(value) = params.get(name) else {
continue;
};
let kind = spec["kind"].as_str().unwrap_or("");
if value.is_null() && kind != "jsonFlag" {
continue;
}
let flag = format!("--{}", spec["flag"].as_str().unwrap_or(name));
match kind {
"doc" => argv.push(
value
.as_str()
.ok_or_else(|| {
Error::new("INVALID_ARGUMENT", "document path must be a string")
})?
.to_owned(),
),
"jsonFlag" => {
argv.push(flag);
argv.push(serde_json::to_string(value).map_err(|_| {
Error::new("INVALID_ARGUMENT", "could not encode JSON parameter")
})?);
}
"flag" => {
if spec["type"] == "string[]" {
let values = value.as_array().ok_or_else(|| {
Error::new("INVALID_ARGUMENT", "array parameter must contain strings")
})?;
for item in values {
argv.push(flag.clone());
argv.push(
item.as_str()
.ok_or_else(|| {
Error::new(
"INVALID_ARGUMENT",
"array parameter must contain strings",
)
})?
.to_owned(),
);
}
} else {
let text = match spec["type"].as_str() {
Some("boolean") => value.as_bool().map(|value| value.to_string()),
Some("number") => value.is_number().then(|| value.to_string()),
Some("string") => value.as_str().map(str::to_owned),
_ => None,
}
.ok_or_else(|| {
Error::new(
"INVALID_ARGUMENT",
"scalar parameter has an incorrect JSON type",
)
})?;
argv.push(flag);
argv.push(text);
}
}
_ => {
return Err(Error::new(
"HOST_PROTOCOL_ERROR",
"unknown parameter transport kind",
))
}
}
}
if let Some(timeout) = options.timeout {
argv.extend(["--timeout-ms".to_owned(), timeout.as_millis().to_string()]);
}
argv.extend(["--output".to_owned(), "json".to_owned()]);
Ok(argv)
}
pub(crate) fn can_open_document(params: &Value, options: &InvokeOptions) -> bool {
if options.stdin_bytes.is_some()
|| options.dry_run.is_some()
|| options.expected_revision.is_some()
|| options.change_mode.is_some()
{
return false;
}
if !params["doc"]
.as_str()
.is_some_and(|path| !path.trim().is_empty() && path != "-")
{
return false;
}
if params.get("runtime").is_some_and(|runtime| runtime != "v2") {
return false;
}
if params
.get("sessionId")
.is_some_and(|id| id.as_str().is_none_or(|id| id.is_empty()))
{
return false;
}
let Some(params) = params.as_object() else {
return false;
};
params.keys().all(|name| {
policy()["documentOpenFields"]
.as_array()
.is_some_and(|fields| fields.iter().any(|field| field == name))
})
}
pub(crate) fn document_request(
session_id: &str,
operation: &Value,
params: &Value,
options: &InvokeOptions,
features: &BTreeSet<String>,
) -> Result<(String, Value)> {
let operation_id = operation["operationId"].as_str().unwrap_or("");
let params = normalize_aliases(operation, params)?;
if options.stdin_bytes.is_some() {
return Err(unsupported(
"structured document RPC does not support stdin bytes",
));
}
if (operation_id == "doc.save" || operation_id == "doc.close")
&& (options.change_mode.is_some()
|| options.expected_revision.is_some()
|| options.dry_run.is_some())
{
return Err(unsupported(
"document lifecycle RPC does not support mutation invoke options",
));
}
if operation_id == "doc.save" {
let allowed = policy()["documentSaveFields"]
.as_array()
.ok_or_else(|| unsupported("save policy is missing"))?;
if params
.keys()
.any(|name| !allowed.iter().any(|field| field == name))
{
return Err(unsupported("save contains an unsupported field"));
}
if params
.get("inPlace")
.is_some_and(|value| !value.is_boolean())
{
return Err(unsupported("inPlace must be a boolean"));
}
if params.get("inPlace") == Some(&Value::Bool(true)) && params.contains_key("out") {
return Err(unsupported("save must choose either inPlace or out"));
}
if params
.get("out")
.is_some_and(|path| path.as_str().is_none_or(|path| path.is_empty()))
{
return Err(unsupported("save output must be a non-empty path"));
}
if !params.contains_key("out")
&& !features.contains(policy()["features"]["sourceSave"].as_str().unwrap_or(""))
{
return Err(unsupported("host does not support source saves"));
}
let mut result = json!({"sessionId":session_id});
if let Some(path) = params.get("out") {
result["path"] = path.clone();
}
if params.get("force") == Some(&Value::Bool(true)) {
result["overwrite"] = json!(true);
}
if let Some(mode) = params.get("mode") {
result["mode"] = mode.clone();
}
return Ok(("document.save".to_owned(), result));
}
if operation_id == "doc.close" {
let mut result = json!({"sessionId":session_id});
if let Some(discard) = params.get("discard") {
result["discard"] = discard.clone();
}
return Ok(("document.close".to_owned(), result));
}
let doc_id = operation["documentApiOperationId"]
.as_str()
.filter(|id| !id.is_empty())
.ok_or_else(|| unsupported("operation has no structured document route"))?;
if params
.get("out")
.and_then(Value::as_str)
.is_some_and(|path| !path.is_empty())
|| params.get("force") == Some(&Value::Bool(true))
{
return Err(unsupported(
"structured document operations do not support per-operation output paths",
));
}
let specs = operation["params"]
.as_array()
.ok_or_else(|| unsupported("parameter metadata is missing"))?;
let mut input = Map::new();
let mut invoke = Map::new();
for (name, value) in ¶ms {
if ["doc", "sessionId", "out", "force"].contains(&name.as_str()) {
continue;
}
let spec = specs
.iter()
.find(|spec| spec["name"] == *name)
.ok_or_else(|| unsupported("operation contains an unsupported field"))?;
if let Some(canonical) = spec["documentApiInputName"]
.as_str()
.filter(|name| !name.is_empty())
{
input.insert(canonical.to_owned(), value.clone());
} else if policy()["documentOptionFields"]
.as_array()
.is_some_and(|fields| fields.iter().any(|field| field == name))
{
invoke.insert(name.clone(), value.clone());
} else {
return Err(unsupported(
"operation field cannot be projected into document RPC",
));
}
}
for (name, value) in [
(
"expectedRevision",
options.expected_revision.clone().map(Value::String),
),
("changeMode", options.change_mode.clone().map(Value::String)),
("dryRun", options.dry_run.map(Value::Bool)),
] {
if let Some(value) = value {
if params.contains_key(name) {
continue;
}
if let Some(canonical) = specs
.iter()
.find(|spec| spec["name"] == name)
.and_then(|spec| spec["documentApiInputName"].as_str())
{
input.insert(canonical.to_owned(), value);
} else {
invoke.insert(name.to_owned(), value);
}
}
}
if let Some(mode) = invoke.get("changeMode") {
let tracked = matches!(
operation["trackedSupport"].as_str(),
Some("always" | "conditional")
) || operation["supportsTrackedMode"] == true
|| operation["supportsConditionalTrackedMode"] == true;
if !tracked {
if mode != "direct" {
return Err(unsupported("operation does not support tracked changes"));
}
invoke.remove("changeMode");
} else if mode != "direct" && mode != "tracked" {
return Err(unsupported("change mode is invalid"));
}
}
if invoke.contains_key("dryRun") && operation["supportsDryRun"] != true {
if invoke["dryRun"] == true {
return Err(unsupported("operation does not support dry run"));
}
invoke.remove("dryRun");
}
if invoke.contains_key("expectedRevision")
&& (operation["mutates"] != true
|| policy()["revisionPreflightOperations"]
.as_array()
.is_some_and(|ids| ids.iter().any(|id| id == operation_id)))
{
return Err(unsupported("operation requires CLI revision preflight"));
}
let mut result = json!({"sessionId":session_id,"operationId":doc_id,"input":input});
if !invoke.is_empty() {
result["options"] = Value::Object(invoke);
}
Ok(("document.invoke".to_owned(), result))
}
pub(crate) fn map_lifecycle(
operation_id: &str,
response: Value,
session_id: &str,
path: Option<&str>,
) -> Result<Value> {
if response["sessionId"] != session_id {
return Err(Error::new(
"HOST_PROTOCOL_ERROR",
"host returned a different document session",
));
}
match operation_id {
"doc.open" => {
if !response["byteLength"].is_number() {
return Err(Error::new(
"HOST_PROTOCOL_ERROR",
"open result is missing its byte length",
));
}
Ok(
json!({"active":true,"contextId":session_id,"runtime":"v2","sessionType":"local","dirty":false,"document":{"path":path,"source":"path","byteLength":response["byteLength"],"revision":0}}),
)
}
"doc.close" => {
if response["closed"] != true || !response["discarded"].is_boolean() {
return Err(Error::new(
"HOST_PROTOCOL_ERROR",
"host returned an invalid close result",
));
}
Ok(
json!({"contextId":session_id,"runtime":"v2","closed":true,"saved":false,"discarded":response["discarded"],"defaultSessionCleared":false}),
)
}
"doc.save" => {
if response["saved"] != true
|| !response["output"]["path"].is_string()
|| !response["output"]["byteLength"].is_number()
|| response
.get("inPlace")
.is_some_and(|value| !value.is_boolean())
{
return Err(Error::new(
"HOST_PROTOCOL_ERROR",
"host returned an invalid save result",
));
}
let mut result = json!({"contextId":session_id,"runtime":"v2","saved":true,"inPlace":response.get("inPlace").unwrap_or(&Value::Bool(false)),"output":response["output"]});
for field in ["mode", "report"] {
if let Some(value) = response.get(field) {
result[field] = value.clone();
}
}
Ok(result)
}
_ => Ok(response),
}
}
pub(crate) fn map_domain_result(operation: &Value, mut value: Value) -> Result<Value> {
let member = operation["documentApiOperationId"].as_str().unwrap_or("");
if value["success"] == true && (member == "create.table" || member.starts_with("tables.")) {
if let Some(object) = value.as_object_mut() {
for field in [
"affectedStories",
"insertedFirstBlockId",
"insertedColumnBlockIdsByRow",
] {
object.remove(field);
}
}
}
if operation["hasFailureResult"] != true || value["success"] != false {
return Ok(value);
}
let failure = &value["failure"];
let Some(code) = failure["code"].as_str().filter(|code| !code.is_empty()) else {
let mut error = Error::new("HOST_PROTOCOL_ERROR", "invalid domain failure receipt");
error.outcome_unknown = operation["mutates"] == true;
return Err(error);
};
let Some(message) = failure["message"].as_str() else {
let mut error = Error::new("HOST_PROTOCOL_ERROR", "invalid domain failure message");
error.outcome_unknown = operation["mutates"] == true;
return Err(error);
};
let mut error = Error::new(code, "SuperDoc rejected the document operation");
error.host_message = Some(message.into());
error.outcome_unknown = operation["mutates"] == true && code == "TIMEOUT";
error.details = Some(Box::new(value));
Err(error)
}
pub(crate) fn map_cli(operation: &Value, response: Value) -> Result<Value> {
let Some(object) = response.as_object() else {
return Err(Error::new(
"HOST_PROTOCOL_ERROR",
"host returned an invalid CLI result",
));
};
let data = object
.get("data")
.ok_or_else(|| Error::new("HOST_PROTOCOL_ERROR", "CLI result is missing data"))?;
if let Some(envelope) = operation["documentApiResultKey"]
.as_str()
.or_else(|| operation["responseEnvelopeKey"].as_str())
{
if let Some(result) = data.get(envelope) {
return Ok(result.clone());
}
}
let mut result = data.clone();
if operation["documentApiOperationId"].is_string() {
if let Some(object) = result.as_object_mut() {
object.remove("document");
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn captured_native_result_families_decode_without_losing_public_fields() {
use crate::generated::types::*;
fn equivalent(left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Number(left), Value::Number(right)) => left.as_f64() == right.as_f64(),
(Value::Array(left), Value::Array(right)) => {
left.len() == right.len()
&& left.iter().zip(right).all(|(a, b)| equivalent(a, b))
}
(Value::Object(left), Value::Object(right)) => {
left.len() == right.len()
&& left.iter().all(|(key, value)| {
right.get(key).is_some_and(|other| equivalent(value, other))
})
}
_ => left == right,
}
}
fn decode<T: serde::de::DeserializeOwned + serde::Serialize>(
value: Value,
) -> std::result::Result<(), String> {
let decoded =
serde_json::from_value::<T>(value.clone()).map_err(|error| error.to_string())?;
let encoded = serde_json::to_value(decoded).map_err(|error| error.to_string())?;
if !equivalent(&value, &encoded) {
return Err("typed result lost or changed public fields".into());
}
Ok(())
}
let rows: Vec<Value> =
serde_json::from_str(include_str!("../../tests/fixtures/native-results.json")).unwrap();
let families = [
"doc.create.paragraph",
"doc.comments.create",
"doc.comments.list",
"doc.comments.get",
"doc.create.table",
"doc.tables.get",
"doc.insert",
"doc.trackChanges.list",
"doc.trackChanges.get",
"doc.trackChanges.decide",
"doc.styles.getCatalog",
];
let mut expected = std::collections::BTreeSet::new();
for mode in ["cli", "document"] {
for family in families {
expected.insert((mode, family, "live"));
}
expected.insert((mode, "doc.comments.list", "saved"));
}
expected.insert(("cli", "doc.open", "collaboration-join"));
let mut observed = std::collections::BTreeSet::new();
for row in &rows {
let key = (
row["mode"].as_str().unwrap(),
row["operationId"].as_str().unwrap(),
row["stage"].as_str().unwrap(),
);
assert!(observed.insert(key), "duplicate native result {key:?}");
}
assert_eq!(observed, expected, "native result inventory changed");
let mut errors = Vec::new();
for row in rows {
let id = row["operationId"].as_str().unwrap();
let operation = &super::super::contract()["operations"][id];
let value = if row["mode"] == "cli" {
map_cli(operation, json!({"data":row["value"]})).unwrap()
} else {
row["value"].clone()
};
let value = map_domain_result(operation, value).unwrap();
let result = match id {
"doc.open" => decode::<DocOpenResult>(value),
"doc.create.paragraph" => decode::<DocCreateParagraphResult>(value),
"doc.comments.create" => decode::<DocCommentsCreateResult>(value),
"doc.comments.list" => decode::<DocCommentsListResult>(value),
"doc.comments.get" => decode::<DocCommentsGetResult>(value),
"doc.create.table" => {
assert!(value.get("insertedFirstBlockId").is_none());
assert!(value.get("affectedStories").is_none());
decode::<DocCreateTableResult>(value)
}
"doc.tables.get" => decode::<DocTablesGetResult>(value),
"doc.insert" => decode::<DocInsertResult>(value),
"doc.trackChanges.list" => decode::<DocTrackChangesListResult>(value),
"doc.trackChanges.get" => decode::<DocTrackChangesGetResult>(value),
"doc.trackChanges.decide" => decode::<DocTrackChangesDecideResult>(value),
"doc.styles.getCatalog" => decode::<DocStylesGetCatalogResult>(value),
_ => panic!("unreviewed native result family {id}"),
};
if let Err(error) = result {
errors.push(format!("{} {id}: {error}", row["mode"]));
}
}
assert!(errors.is_empty(), "{}", errors.join("\n"));
let native = json!({"success":true,"table":{"kind":"block","nodeType":"table","nodeId":"synthetic"},"trackedChangeRefs":[],"affectedStories":[],"insertedFirstBlockId":"private","insertedColumnBlockIdsByRow":["private"],"futureField":true});
let projected = map_domain_result(
&json!({"documentApiOperationId":"create.table"}),
native.clone(),
)
.unwrap();
assert_eq!(projected["trackedChangeRefs"], json!([]));
assert_eq!(
projected["futureField"], true,
"projection must not hide unknown public fields"
);
assert!(projected.get("insertedColumnBlockIdsByRow").is_none());
assert_eq!(
map_domain_result(
&json!({"documentApiOperationId":"create.paragraph"}),
native.clone()
)
.unwrap(),
native
);
}
#[test]
fn flat_document_results_remove_only_cli_document_metadata() {
let info = json!({"counts":{"words":0,"characters":0,"paragraphs":1,"headings":0,"tables":0,"images":0,"comments":0,"trackedChanges":0,"sdtFields":0,"lists":0},"outline":[],"capabilities":{"canFind":true,"canGetNode":true,"canComment":true,"canReplace":true},"revision":"0"});
let mut data = info.clone();
data["document"] = json!({"path":"synthetic.docx","revision":0});
let result = map_cli(
&json!({"documentApiOperationId":"info","documentApiResultKey":null}),
json!({"data":data}),
)
.unwrap();
assert_eq!(result, info);
serde_json::from_value::<crate::generated::types::DocInfoResult>(result).unwrap();
}
#[test]
fn typed_results_use_the_declared_document_api_plane_without_dropping_receipt_fields() {
let receipt = json!({"success":true,"txId":"synthetic","effects":{"insertedText":[]}});
let result = map_cli(
&json!({"responseEnvelopeKey":null,"documentApiResultKey":"receipt"}),
json!({"data":{"document":{"revision":1},"receipt":receipt}}),
)
.unwrap();
assert_eq!(result, receipt);
}
}