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(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum DescriptionMode {
Short,
#[default]
Long,
}
#[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>,
pub description_mode: DescriptionMode,
pub description_modes: HashMap<String, DescriptionMode>,
pub descriptions: HashMap<String, String>,
}
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
}
#[must_use]
pub const fn description_mode(mut self, mode: DescriptionMode) -> Self {
self.description_mode = mode;
self
}
#[must_use]
pub fn description_mode_for(
mut self,
cmd_path: impl Into<String>,
mode: DescriptionMode,
) -> Self {
self.description_modes.insert(cmd_path.into(), mode);
self
}
#[must_use]
pub fn description(mut self, cmd_path: impl Into<String>, text: impl Into<String>) -> Self {
self.descriptions.insert(cmd_path.into(), text.into());
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 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_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_covers_every_setter() {
let imp = rmcp::model::Implementation::new("test-server", "0.1.0");
let cfg = Config::default()
.command_name("agent")
.tool_name_prefix("myapp")
.selector(Selector::default())
.default_env("LOG_FORMAT", "json")
.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)
.implementation(imp);
assert_eq!(cfg.command_name.as_deref(), Some("agent"));
assert_eq!(cfg.tool_name_prefix.as_deref(), Some("myapp"));
assert_eq!(cfg.selectors.len(), 1);
assert_eq!(
cfg.default_env.get("LOG_FORMAT").map(String::as_str),
Some("json")
);
assert!(cfg.annotations.contains_key("my-cli list"));
assert_eq!(cfg.annotations["my-cli list"].read_only_hint, Some(true));
assert!(cfg.deprecated_commands.contains("my-cli oldcmd"));
let schema_key = ("my-cli list".to_string(), "limit".to_string());
assert_eq!(
cfg.flag_schemas[&schema_key],
serde_json::json!({"type": "integer", "minimum": 0})
);
let type_key = ("my-cli list".to_string(), "filter".to_string());
assert_eq!(cfg.flag_type_overrides[&type_key], SchemaType::Array);
assert_eq!(cfg.log_level, Some(Level::DEBUG));
let stored_imp = cfg.implementation.expect("implementation stored");
assert_eq!(stored_imp.name, "test-server");
assert_eq!(stored_imp.version, "0.1.0");
}
}