use schemars::JsonSchema;
use serde::Deserialize;
fn deserialize_optional_object<'de, D>(
deserializer: D,
) -> Result<Option<serde_json::Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
const CONTRACT: &str = "`args` must be a JSON object of {parameter_name: value} — or that \
object as a JSON string — (got a scalar, array, or non-object string)";
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
match value {
None | Some(serde_json::Value::Object(_)) => Ok(value),
Some(serde_json::Value::String(s)) => match serde_json::from_str::<serde_json::Value>(&s) {
Ok(obj @ serde_json::Value::Object(_)) => Ok(Some(obj)),
_ => Err(serde::de::Error::custom(CONTRACT)),
},
Some(_) => Err(serde::de::Error::custom(CONTRACT)),
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RegistryParams {
pub query: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct InvokeCommandParams {
pub command: String,
#[serde(default, deserialize_with = "deserialize_optional_object")]
pub args: Option<serde_json::Value>,
#[serde(alias = "window", alias = "window_label")]
pub webview_label: Option<String>,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum AppDir {
Data,
Config,
Log,
LocalData,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListAppDirParams {
pub directory: Option<AppDir>,
pub path: Option<String>,
pub pattern: Option<String>,
pub max_depth: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ReadAppFileParams {
pub directory: Option<AppDir>,
pub path: String,
pub max_bytes: Option<usize>,
pub binary: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[cfg_attr(not(feature = "sqlite"), allow(dead_code))]
pub struct QueryDbParams {
pub path: Option<String>,
#[serde(alias = "sql")]
pub query: String,
pub params: Option<Vec<serde_json::Value>>,
pub max_rows: Option<usize>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invoke_args_accepts_object_and_stringified_object() {
let direct: InvokeCommandParams =
serde_json::from_str(r#"{"command":"c","args":{"days":7,"maxNodes":150}}"#)
.expect("object args must deserialize");
let stringified: InvokeCommandParams =
serde_json::from_str(r#"{"command":"c","args":"{\"days\":7,\"maxNodes\":150}"}"#)
.expect("stringified object args must deserialize");
assert_eq!(
direct.args, stringified.args,
"both forms yield the same object"
);
assert_eq!(
direct
.args
.as_ref()
.and_then(|v| v.get("days"))
.and_then(serde_json::Value::as_i64),
Some(7)
);
let none: InvokeCommandParams =
serde_json::from_str(r#"{"command":"c"}"#).expect("absent args ok");
assert!(none.args.is_none());
}
#[test]
fn invoke_args_still_rejects_non_object_shapes() {
for bad in [
r#"{"command":"c","args":42}"#,
r#"{"command":"c","args":[1,2]}"#,
r#"{"command":"c","args":"not json"}"#,
r#"{"command":"c","args":"[1,2]"}"#,
r#"{"command":"c","args":"\"scalar\""}"#,
] {
let r: Result<InvokeCommandParams, _> = serde_json::from_str(bad);
assert!(r.is_err(), "must reject: {bad}");
}
}
#[test]
fn query_db_accepts_sql_alias() {
for key in ["query", "sql"] {
let json = format!(r#"{{"{key}":"SELECT 1"}}"#);
let p: QueryDbParams = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("key `{key}` must deserialize: {e}"));
assert_eq!(
p.query, "SELECT 1",
"key `{key}` must populate the query field"
);
}
}
}