use std::collections::{HashMap, HashSet};
use serde_json::Value;
use tracing::Level;
use crate::annotations::ToolAnnotations;
use crate::schema::SchemaType;
use crate::selector::Selector;
#[derive(Default, Clone)]
#[non_exhaustive]
pub struct Config {
pub command_name: Option<String>,
pub tool_name_prefix: Option<String>,
pub selectors: Vec<Selector>,
pub default_env: HashMap<String, String>,
pub annotations: HashMap<String, ToolAnnotations>,
pub deprecated_commands: HashSet<String>,
pub flag_schemas: HashMap<(String, String), Value>,
pub flag_type_overrides: HashMap<(String, String), SchemaType>,
pub log_level: Option<Level>,
pub implementation: Option<rmcp::model::Implementation>,
}
impl Config {
#[must_use]
pub fn command_name(mut self, name: impl Into<String>) -> Self {
self.command_name = Some(name.into());
self
}
#[must_use]
pub fn tool_name_prefix(mut self, prefix: impl Into<String>) -> Self {
self.tool_name_prefix = Some(prefix.into());
self
}
#[must_use]
pub fn selector(mut self, s: Selector) -> Self {
self.selectors.push(s);
self
}
#[must_use]
pub fn default_env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.default_env.insert(k.into(), v.into());
self
}
#[must_use]
pub fn annotation(mut self, cmd_path: impl Into<String>, ann: ToolAnnotations) -> Self {
self.annotations.insert(cmd_path.into(), ann);
self
}
#[must_use]
pub fn deprecate(mut self, cmd_path: impl Into<String>) -> Self {
self.deprecated_commands.insert(cmd_path.into());
self
}
#[must_use]
pub fn flag_schema(
mut self,
cmd_path: impl Into<String>,
flag: impl Into<String>,
schema: Value,
) -> Self {
self.flag_schemas
.insert((cmd_path.into(), flag.into()), schema);
self
}
#[must_use]
pub fn flag_type_override(
mut self,
cmd_path: impl Into<String>,
flag: impl Into<String>,
ty: SchemaType,
) -> Self {
self.flag_type_overrides
.insert((cmd_path.into(), flag.into()), ty);
self
}
#[must_use]
pub const fn log_level(mut self, lvl: Level) -> Self {
self.log_level = Some(lvl);
self
}
#[must_use]
pub fn implementation(mut self, imp: rmcp::model::Implementation) -> Self {
self.implementation = Some(imp);
self
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
#[test]
fn default_yields_empty_config() {
let cfg = Config::default();
assert!(cfg.command_name.is_none());
assert!(cfg.tool_name_prefix.is_none());
assert!(cfg.selectors.is_empty());
assert!(cfg.default_env.is_empty());
assert!(cfg.annotations.is_empty());
assert!(cfg.deprecated_commands.is_empty());
assert!(cfg.flag_schemas.is_empty());
assert!(cfg.flag_type_overrides.is_empty());
assert!(cfg.log_level.is_none());
assert!(cfg.implementation.is_none());
}
#[test]
fn command_name_sets_field() {
let cfg = Config::default().command_name("agent");
assert_eq!(cfg.command_name.as_deref(), Some("agent"));
}
#[test]
fn tool_name_prefix_sets_field() {
let cfg = Config::default().tool_name_prefix("myapp");
assert_eq!(cfg.tool_name_prefix.as_deref(), Some("myapp"));
}
#[test]
fn selector_pushes_in_order() {
let cfg = Config::default()
.selector(Selector {
cmd: Some(Arc::new(|p: &str| p == "first")),
..Default::default()
})
.selector(Selector {
cmd: Some(Arc::new(|p: &str| p == "second")),
..Default::default()
});
assert_eq!(cfg.selectors.len(), 2);
assert!((cfg.selectors[0].cmd.as_ref().unwrap())("first"));
assert!((cfg.selectors[1].cmd.as_ref().unwrap())("second"));
}
#[test]
fn default_env_inserts_key_value() {
let cfg = Config::default().default_env("LOG_FORMAT", "json");
assert_eq!(
cfg.default_env.get("LOG_FORMAT").map(String::as_str),
Some("json")
);
}
#[test]
fn default_env_accumulates_multiple_entries() {
let cfg = Config::default()
.default_env("K1", "V1")
.default_env("K2", "V2");
assert_eq!(cfg.default_env.len(), 2);
assert_eq!(cfg.default_env.get("K1").map(String::as_str), Some("V1"));
assert_eq!(cfg.default_env.get("K2").map(String::as_str), Some("V2"));
}
#[test]
fn annotation_inserts_by_path() {
let cfg = Config::default().annotation(
"my-cli list",
ToolAnnotations {
read_only_hint: Some(true),
..Default::default()
},
);
assert!(cfg.annotations.contains_key("my-cli list"));
assert_eq!(cfg.annotations["my-cli list"].read_only_hint, Some(true));
}
#[test]
fn deprecate_inserts_path() {
let cfg = Config::default().deprecate("my-cli oldcmd");
assert!(cfg.deprecated_commands.contains("my-cli oldcmd"));
}
#[test]
fn flag_schema_inserts_by_key() {
let schema = serde_json::json!({"type": "integer", "minimum": 0});
let cfg = Config::default().flag_schema("my-cli list", "limit", schema.clone());
let key = ("my-cli list".to_string(), "limit".to_string());
assert!(cfg.flag_schemas.contains_key(&key));
assert_eq!(cfg.flag_schemas[&key], schema);
}
#[test]
fn flag_type_override_inserts_by_key() {
let cfg = Config::default().flag_type_override("my-cli list", "filter", SchemaType::Array);
let key = ("my-cli list".to_string(), "filter".to_string());
assert!(cfg.flag_type_overrides.contains_key(&key));
assert_eq!(cfg.flag_type_overrides[&key], SchemaType::Array);
}
#[test]
fn log_level_sets_field() {
let cfg = Config::default().log_level(Level::DEBUG);
assert_eq!(cfg.log_level, Some(Level::DEBUG));
}
#[test]
fn implementation_sets_field() {
let imp = rmcp::model::Implementation::new("test-server", "0.1.0");
let cfg = Config::default().implementation(imp);
assert!(cfg.implementation.is_some());
let stored = cfg.implementation.unwrap();
assert_eq!(stored.name, "test-server");
assert_eq!(stored.version, "0.1.0");
}
#[test]
fn default_env_last_writer_wins() {
let cfg = Config::default()
.default_env("X", "1")
.default_env("X", "2");
assert_eq!(cfg.default_env.get("X").map(String::as_str), Some("2"));
assert_eq!(cfg.default_env.len(), 1);
}
#[test]
fn annotation_last_writer_wins() {
let cfg = Config::default()
.annotation(
"my-cli list",
ToolAnnotations {
read_only_hint: Some(true),
..Default::default()
},
)
.annotation(
"my-cli list",
ToolAnnotations {
read_only_hint: Some(false),
destructive_hint: Some(true),
..Default::default()
},
);
let ann = cfg
.annotations
.get("my-cli list")
.expect("annotation present");
assert_eq!(ann.read_only_hint, Some(false));
assert_eq!(ann.destructive_hint, Some(true));
assert_eq!(cfg.annotations.len(), 1);
}
#[test]
fn fluent_chain_composes() {
let cfg = Config::default()
.command_name("agent")
.selector(Selector::default())
.annotation(
"my-cli list",
ToolAnnotations {
read_only_hint: Some(true),
..Default::default()
},
)
.deprecate("my-cli oldcmd")
.flag_schema(
"my-cli list",
"limit",
serde_json::json!({"type": "integer", "minimum": 0}),
)
.flag_type_override("my-cli list", "filter", SchemaType::Array)
.log_level(Level::DEBUG);
assert_eq!(cfg.command_name.as_deref(), Some("agent"));
assert_eq!(cfg.selectors.len(), 1);
assert!(cfg.annotations.contains_key("my-cli list"));
assert!(cfg.deprecated_commands.contains("my-cli oldcmd"));
assert!(
cfg.flag_schemas
.contains_key(&("my-cli list".into(), "limit".into()))
);
assert!(
cfg.flag_type_overrides
.contains_key(&("my-cli list".into(), "filter".into()))
);
assert_eq!(cfg.log_level, Some(Level::DEBUG));
}
}