github_mcp/core/
config_manager.rs1use std::path::{Path, PathBuf};
4
5use serde_json::{Map, Value};
6
7use super::config_schema::Config;
8use super::credential_storage::resolve_home_dir;
9use super::errors::McpifyError;
10
11const ENV_PREFIX: &str = "GITHUB_MCP";
12const CONFIG_DIR_NAME: &str = ".github-mcp";
13const LOCAL_CONFIG_FILE: &str = "github-mcp.config.yml";
14
15fn home_dir() -> PathBuf {
20 resolve_home_dir()
21}
22
23fn read_yaml_if_exists(path: &Path) -> Map<String, Value> {
24 let Ok(contents) = std::fs::read_to_string(path) else {
25 return Map::new();
26 };
27 match serde_yaml::from_str::<Value>(&contents) {
28 Ok(Value::Object(map)) => map,
29 _ => Map::new(),
30 }
31}
32
33fn parse_env_value(config_key: &str, value: &str) -> Value {
34 if matches!(
35 config_key,
36 "rate_limit" | "timeout_ms" | "cache_size" | "retry_attempts" | "port"
37 ) && let Ok(number) = value.parse::<u64>()
38 {
39 return Value::Number(number.into());
40 }
41 Value::String(value.to_string())
42}
43
44fn env_overrides() -> Map<String, Value> {
45 let mut overrides = Map::new();
46 for (config_key, env_suffix) in [
47 ("url", "URL"),
48 ("auth_method", "AUTH_METHOD"),
49 ("api_version", "API_VERSION"),
50 ("log_level", "LOG_LEVEL"),
51 ("transport", "TRANSPORT"),
52 ("host", "HOST"),
53 ("cors_allow", "CORS_ALLOW"),
54 ("rate_limit", "RATE_LIMIT"),
55 ("timeout_ms", "TIMEOUT_MS"),
56 ("cache_size", "CACHE_SIZE"),
57 ("retry_attempts", "RETRY_ATTEMPTS"),
58 ("port", "PORT"),
59 ] {
60 if let Ok(value) = std::env::var(format!("{ENV_PREFIX}_{env_suffix}")) {
61 overrides.insert(config_key.to_string(), parse_env_value(config_key, &value));
62 }
63 }
64 overrides
65}
66
67pub fn load_config(cli_flags: Map<String, Value>) -> Result<Config, McpifyError> {
74 let install_dir = std::env::current_exe()
75 .ok()
76 .and_then(|exe| exe.parent().map(Path::to_path_buf))
77 .unwrap_or_else(|| PathBuf::from("."));
78
79 let layers = [
80 cli_flags,
81 env_overrides(),
82 read_yaml_if_exists(&PathBuf::from(LOCAL_CONFIG_FILE)),
83 read_yaml_if_exists(&home_dir().join(CONFIG_DIR_NAME).join("config.yml")),
84 read_yaml_if_exists(&PathBuf::from("/etc/github-mcp/config.yml")),
85 read_yaml_if_exists(&install_dir.join("config.yml")),
86 ];
87
88 let mut merged = Map::new();
92 for layer in layers.into_iter().rev() {
93 merged.extend(layer);
94 }
95
96 serde_json::from_value(Value::Object(merged))
97 .map_err(|err| McpifyError::Configuration(format!("invalid configuration: {err}")))
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use serde_json::json;
104
105 fn base_flags() -> Map<String, Value> {
106 json!({ "url": "https://api.example.com", "auth_method": "pat" })
107 .as_object()
108 .unwrap()
109 .clone()
110 }
111
112 #[test]
113 fn applies_built_in_defaults_when_nothing_else_is_set() {
114 let config = load_config(base_flags()).unwrap();
115 assert_eq!(config.log_level, "info");
116 assert_eq!(config.rate_limit, 100);
117 assert_eq!(config.port, 3000);
118 }
119
120 #[test]
121 fn cli_flags_win_over_everything_else() {
122 let mut flags = base_flags();
123 flags.insert("log_level".to_string(), json!("debug"));
124 let config = load_config(flags).unwrap();
125 assert_eq!(config.log_level, "debug");
126 }
127
128 #[test]
129 fn missing_required_fields_report_a_configuration_error() {
130 let err = load_config(Map::new()).unwrap_err();
131 assert_eq!(err.code(), "CONFIGURATION_ERROR");
132 }
133
134 #[test]
135 fn numeric_environment_overrides_are_typed_before_deserialization() {
136 assert_eq!(parse_env_value("port", "33017"), json!(33017));
137 assert_eq!(parse_env_value("timeout_ms", "2500"), json!(2500));
138 assert_eq!(
139 parse_env_value("url", "https://api.example"),
140 json!("https://api.example")
141 );
142 assert_eq!(
143 parse_env_value("port", "not-a-number"),
144 json!("not-a-number")
145 );
146 }
147}