use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CliBindingConfig {
pub cmd: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args_template: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_format: Option<CliOutputFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_all_flag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dry_run_flag: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub exit_code_map: BTreeMap<i32, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CliOutputFormat {
#[default]
Json,
Ndjson,
Lines,
}
#[derive(Debug, thiserror::Error)]
pub enum CliBindingConfigError {
#[error("CLI binding config is not a JSON object: {0}")]
NotAnObject(String),
#[error("CLI binding config missing required field `cmd`")]
MissingCmd,
#[error("CLI binding config invalid: {0}")]
Invalid(#[from] serde_json::Error),
}
impl CliBindingConfig {
pub fn from_value(v: &serde_json::Value) -> Result<Self, CliBindingConfigError> {
if !v.is_object() {
return Err(CliBindingConfigError::NotAnObject(v.to_string()));
}
if v.get("cmd").and_then(|c| c.as_str()).is_none() {
return Err(CliBindingConfigError::MissingCmd);
}
let parsed: Self = serde_json::from_value(v.clone())?;
Ok(parsed)
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("CliBindingConfig is always serializable")
}
}
impl crate::tool::ToolBinding {
pub fn cli_config(&self) -> Result<Option<CliBindingConfig>, CliBindingConfigError> {
if !matches!(self.protocol, crate::enums::BindingProtocol::Cli) {
return Ok(None);
}
CliBindingConfig::from_value(&self.config).map(Some)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::enums::BindingProtocol;
use crate::tool::ToolBinding;
use serde_json::json;
fn minimal_v1() -> serde_json::Value {
json!({"cmd": "cat"})
}
fn full_v2() -> CliBindingConfig {
CliBindingConfig {
cmd: "mycli".into(),
args: vec!["api".into(), "v1".into()],
args_template: Some("{tool_id} --params {params_json} {dry_run}".into()),
env: BTreeMap::from([("MYCLI_TOKEN".into(), "$ATD_BEARER".into())]),
output_format: Some(CliOutputFormat::Json),
page_all_flag: Some("--page-all".into()),
dry_run_flag: Some("--dry-run".into()),
exit_code_map: BTreeMap::from([
(2, "auth_required".into()),
(3, "invalid_args".into()),
]),
}
}
#[test]
fn pre_v2_minimal_config_round_trips_byte_identical() {
let parsed = CliBindingConfig::from_value(&minimal_v1()).unwrap();
assert_eq!(parsed.cmd, "cat");
assert!(parsed.args.is_empty());
assert!(parsed.args_template.is_none());
let back = parsed.to_value();
assert_eq!(back, minimal_v1());
}
#[test]
fn v2_full_config_round_trips() {
let cfg = full_v2();
let v = cfg.to_value();
let back = CliBindingConfig::from_value(&v).unwrap();
assert_eq!(back, cfg);
}
#[test]
fn parse_rejects_non_object() {
let err = CliBindingConfig::from_value(&json!(42)).unwrap_err();
assert!(matches!(err, CliBindingConfigError::NotAnObject(_)));
}
#[test]
fn parse_rejects_missing_cmd() {
let err = CliBindingConfig::from_value(&json!({"args": ["x"]})).unwrap_err();
assert!(matches!(err, CliBindingConfigError::MissingCmd));
}
#[test]
fn output_format_snake_case() {
assert_eq!(
serde_json::to_string(&CliOutputFormat::Ndjson).unwrap(),
"\"ndjson\""
);
assert_eq!(
serde_json::to_string(&CliOutputFormat::Lines).unwrap(),
"\"lines\""
);
assert_eq!(
serde_json::to_string(&CliOutputFormat::Json).unwrap(),
"\"json\""
);
}
#[test]
fn output_format_default_is_json() {
assert_eq!(CliOutputFormat::default(), CliOutputFormat::Json);
}
#[test]
fn empty_collections_skip_serialization() {
let cfg = CliBindingConfig {
cmd: "x".into(),
..Default::default()
};
let s = serde_json::to_string(&cfg).unwrap();
assert_eq!(s, "{\"cmd\":\"x\"}");
}
#[test]
fn parse_tolerates_unknown_fields_for_forward_compat() {
let v = json!({
"cmd": "x",
"future_field": {"foo": "bar"},
});
let parsed = CliBindingConfig::from_value(&v).unwrap();
assert_eq!(parsed.cmd, "x");
}
#[test]
fn tool_binding_helper_cli_returns_some() {
let b = ToolBinding {
protocol: BindingProtocol::Cli,
config: full_v2().to_value(),
};
let parsed = b.cli_config().unwrap().expect("expected Some");
assert_eq!(parsed, full_v2());
}
#[test]
fn tool_binding_helper_non_cli_returns_none() {
let b = ToolBinding {
protocol: BindingProtocol::Mcp,
config: json!({"endpoint": "..."}),
};
assert!(b.cli_config().unwrap().is_none());
}
#[test]
fn tool_binding_helper_cli_with_bad_config_errors() {
let b = ToolBinding {
protocol: BindingProtocol::Cli,
config: json!({"no_cmd": "oops"}),
};
let err = b.cli_config().unwrap_err();
assert!(matches!(err, CliBindingConfigError::MissingCmd));
}
#[test]
fn exit_code_map_serialization_key_is_string() {
let cfg = CliBindingConfig {
cmd: "x".into(),
exit_code_map: BTreeMap::from([(2, "auth".into()), (3, "args".into())]),
..Default::default()
};
let v = cfg.to_value();
let map = v["exit_code_map"].as_object().unwrap();
assert_eq!(map.get("2").unwrap(), "auth");
assert_eq!(map.get("3").unwrap(), "args");
}
}