use std::env;
use std::path::{Path, PathBuf};
use crate::error::{AppError, AppResult};
pub const DEFAULT_PORT: u16 = 59_471;
pub const DEFAULT_RUN_RETENTION: usize = 100;
pub const DEFAULT_RUN_CONCURRENCY: usize = 4;
pub const DEFAULT_MCP_HTTP_CONCURRENCY: usize = 16;
pub const DEFAULT_DB_POOL_SIZE: usize = 4;
pub const DEFAULT_DB_ACQUIRE_TIMEOUT_MS: u64 = 5_000;
pub const DEFAULT_DB_QUERY_TIMEOUT_MS: u64 = 30_000;
pub const DEFAULT_HTTP_REQUEST_TIMEOUT_SECONDS: u64 = 60;
pub const DEFAULT_HTTP_MAX_BODY_BYTES: usize = 1_048_576;
pub const MIN_HTTP_MAX_BODY_BYTES: usize = 1_024;
pub const MAX_HTTP_MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
pub const DEFAULT_RUN_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
pub const MIN_RUN_LOG_MAX_BYTES: u64 = 1_024;
pub const MAX_RUN_LOG_MAX_BYTES: u64 = 1_024 * 1024 * 1024;
pub const DEFAULT_COMPACTION_AFTER_DAYS: u32 = 30;
pub const DEFAULT_COMPACTION_INTERVAL_SECONDS: u64 = 3_600;
pub const DEFAULT_COMPACTION_BATCH_SIZE: u32 = 100;
#[derive(Clone, Debug)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub default_repository_path: Option<PathBuf>,
pub common_db_path: PathBuf,
pub run_retention: usize,
pub run_concurrency: usize,
pub mcp_http_concurrency: usize,
pub db_pool_size: usize,
pub db_acquire_timeout_ms: u64,
pub db_query_timeout_ms: u64,
pub http_request_timeout_seconds: u64,
pub http_max_body_bytes: usize,
pub run_log_max_bytes: u64,
pub default_compaction_after_days: u32,
pub default_compaction_interval_seconds: u64,
pub default_compaction_batch_size: u32,
}
impl ServerConfig {
pub(crate) fn default_repository_path_string(&self) -> Option<String> {
self.default_repository_path
.as_ref()
.map(|path| path.to_string_lossy().into_owned())
}
pub fn from_environment(
host: Option<String>,
port: Option<u16>,
common_db_path: Option<PathBuf>,
) -> AppResult<Self> {
Self::from_environment_with_lookup(host, port, common_db_path, &|name| env::var(name).ok())
}
fn from_environment_with_lookup(
host: Option<String>,
port: Option<u16>,
common_db_path: Option<PathBuf>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> AppResult<Self> {
let host = host
.or_else(|| lookup("COVERAGE_MCP_HOST"))
.unwrap_or_else(|| "127.0.0.1".to_owned());
let port = match port {
Some(port) => port,
None => match lookup("COVERAGE_MCP_PORT") {
Some(raw) => raw.parse::<u16>().map_err(|_| {
AppError::Validation(
"COVERAGE_MCP_PORT must be an integer between 0 and 65535".to_owned(),
)
})?,
None => DEFAULT_PORT,
},
};
let common_db_path = common_db_path
.or_else(|| lookup("COVERAGE_MCP_COMMON_DB").map(PathBuf::from))
.unwrap_or_else(|| default_common_db_path_with_lookup(lookup));
let run_retention = env_usize(
"COVERAGE_MCP_RUN_RETENTION",
DEFAULT_RUN_RETENTION,
1,
10_000,
lookup,
)?;
let run_concurrency = env_usize(
"COVERAGE_MCP_RUN_CONCURRENCY",
DEFAULT_RUN_CONCURRENCY,
1,
32,
lookup,
)?;
let mcp_http_concurrency = env_usize(
"COVERAGE_MCP_HTTP_CONCURRENCY",
DEFAULT_MCP_HTTP_CONCURRENCY,
1,
128,
lookup,
)?;
let db_pool_size = env_usize(
"COVERAGE_MCP_DB_POOL_SIZE",
DEFAULT_DB_POOL_SIZE,
1,
16,
lookup,
)?;
let db_acquire_timeout_ms = env_u64(
"COVERAGE_MCP_DB_ACQUIRE_TIMEOUT_MS",
DEFAULT_DB_ACQUIRE_TIMEOUT_MS,
50,
120_000,
lookup,
)?;
let db_query_timeout_ms = env_u64(
"COVERAGE_MCP_DB_QUERY_TIMEOUT_MS",
DEFAULT_DB_QUERY_TIMEOUT_MS,
100,
3_600_000,
lookup,
)?;
let http_request_timeout_seconds = env_u64(
"COVERAGE_MCP_HTTP_REQUEST_TIMEOUT_SECONDS",
DEFAULT_HTTP_REQUEST_TIMEOUT_SECONDS,
1,
3_600,
lookup,
)?;
let http_max_body_bytes = env_usize(
"COVERAGE_MCP_HTTP_MAX_BODY_BYTES",
DEFAULT_HTTP_MAX_BODY_BYTES,
MIN_HTTP_MAX_BODY_BYTES,
MAX_HTTP_MAX_BODY_BYTES,
lookup,
)?;
let run_log_max_bytes = env_u64(
"COVERAGE_MCP_RUN_LOG_MAX_BYTES",
DEFAULT_RUN_LOG_MAX_BYTES,
MIN_RUN_LOG_MAX_BYTES,
MAX_RUN_LOG_MAX_BYTES,
lookup,
)?;
if db_query_timeout_ms >= http_request_timeout_seconds.saturating_mul(1_000) {
return Err(AppError::Validation(
"COVERAGE_MCP_DB_QUERY_TIMEOUT_MS must be shorter than the HTTP request timeout"
.to_owned(),
));
}
let default_compaction_after_days = env_u32(
"COVERAGE_MCP_COMPACTION_AFTER_DAYS",
DEFAULT_COMPACTION_AFTER_DAYS,
1,
36_500,
lookup,
)?;
let default_compaction_interval_seconds = env_u64(
"COVERAGE_MCP_COMPACTION_INTERVAL_SECONDS",
DEFAULT_COMPACTION_INTERVAL_SECONDS,
60,
86_400,
lookup,
)?;
let default_compaction_batch_size = env_u32(
"COVERAGE_MCP_COMPACTION_BATCH_SIZE",
DEFAULT_COMPACTION_BATCH_SIZE,
1,
10_000,
lookup,
)?;
Ok(Self {
host,
port,
default_repository_path: None,
common_db_path,
run_retention,
run_concurrency,
mcp_http_concurrency,
db_pool_size,
db_acquire_timeout_ms,
db_query_timeout_ms,
http_request_timeout_seconds,
http_max_body_bytes,
run_log_max_bytes,
default_compaction_after_days,
default_compaction_interval_seconds,
default_compaction_batch_size,
})
}
}
pub fn default_db_path(path: &Path) -> PathBuf {
path.join(".coverage-mcp").join("coverage.duckdb")
}
pub fn default_common_db_path() -> PathBuf {
default_common_db_path_with_lookup(&|name| env::var(name).ok())
}
fn default_common_db_path_with_lookup(lookup: &dyn Fn(&str) -> Option<String>) -> PathBuf {
lookup("COVERAGE_MCP_COMMON_DB")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(lookup("HOME").unwrap_or_else(|| ".".to_owned()))
.join(".coverage-mcp")
.join("common.duckdb")
})
}
fn env_usize(
name: &str,
default: usize,
min: usize,
max: usize,
lookup: &dyn Fn(&str) -> Option<String>,
) -> AppResult<usize> {
let value = lookup(name)
.map(|raw| {
raw.parse::<usize>()
.map_err(|_| AppError::Validation(format!("{name} must be an integer")))
})
.transpose()?
.unwrap_or(default);
if !(min..=max).contains(&value) {
return Err(AppError::Validation(format!(
"{name} must be between {min} and {max}"
)));
}
Ok(value)
}
fn env_u32(
name: &str,
default: u32,
min: u32,
max: u32,
lookup: &dyn Fn(&str) -> Option<String>,
) -> AppResult<u32> {
let value = lookup(name)
.map(|raw| {
raw.parse::<u32>()
.map_err(|_| AppError::Validation(format!("{name} must be an integer")))
})
.transpose()?
.unwrap_or(default);
if !(min..=max).contains(&value) {
return Err(AppError::Validation(format!(
"{name} must be between {min} and {max}"
)));
}
Ok(value)
}
fn env_u64(
name: &str,
default: u64,
min: u64,
max: u64,
lookup: &dyn Fn(&str) -> Option<String>,
) -> AppResult<u64> {
let value = lookup(name)
.map(|raw| {
raw.parse::<u64>()
.map_err(|_| AppError::Validation(format!("{name} must be an integer")))
})
.transpose()?
.unwrap_or(default);
if !(min..=max).contains(&value) {
return Err(AppError::Validation(format!(
"{name} must be between {min} and {max}"
)));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_and_explicit_overrides_are_stable() {
let config = ServerConfig::from_environment(
Some("localhost".to_owned()),
Some(1234),
Some(PathBuf::from("common.duckdb")),
)
.expect("configuration");
assert_eq!(config.host, "localhost");
assert_eq!(config.port, 1234);
assert_eq!(config.common_db_path, PathBuf::from("common.duckdb"));
assert_eq!(
config.default_compaction_after_days,
DEFAULT_COMPACTION_AFTER_DAYS
);
let defaults = ServerConfig::from_environment_with_lookup(
None,
None,
Some(PathBuf::from("common")),
&|_| None,
)
.expect("default configuration");
assert_eq!(defaults.host, "127.0.0.1");
assert_eq!(defaults.port, DEFAULT_PORT);
assert_eq!(defaults.run_retention, DEFAULT_RUN_RETENTION);
assert_eq!(defaults.run_concurrency, DEFAULT_RUN_CONCURRENCY);
assert_eq!(defaults.mcp_http_concurrency, DEFAULT_MCP_HTTP_CONCURRENCY);
assert_eq!(defaults.db_pool_size, DEFAULT_DB_POOL_SIZE);
assert_eq!(
defaults.db_acquire_timeout_ms,
DEFAULT_DB_ACQUIRE_TIMEOUT_MS
);
assert_eq!(defaults.db_query_timeout_ms, DEFAULT_DB_QUERY_TIMEOUT_MS);
assert_eq!(
defaults.http_request_timeout_seconds,
DEFAULT_HTTP_REQUEST_TIMEOUT_SECONDS
);
assert_eq!(defaults.http_max_body_bytes, DEFAULT_HTTP_MAX_BODY_BYTES);
assert_eq!(defaults.run_log_max_bytes, DEFAULT_RUN_LOG_MAX_BYTES);
assert_eq!(
defaults.default_compaction_interval_seconds,
DEFAULT_COMPACTION_INTERVAL_SECONDS
);
assert_eq!(
defaults.default_compaction_batch_size,
DEFAULT_COMPACTION_BATCH_SIZE
);
}
#[test]
fn repository_and_path_defaults_are_usable() {
let directory = tempfile::tempdir().expect("tempdir");
assert_eq!(
default_db_path(directory.path()),
directory.path().join(".coverage-mcp/coverage.duckdb")
);
assert!(default_common_db_path().ends_with("common.duckdb"));
let no_environment = |_: &str| None::<String>;
assert!(no_environment("HOME").is_none());
}
#[test]
fn numeric_helpers_validate_defaults_and_bad_environment_values() {
let real_env = |name: &str| env::var(name).ok();
assert_eq!(
env_usize("COVERAGE_MCP_TEST_MISSING", 5, 1, 10, &real_env).unwrap(),
5
);
assert_eq!(
env_u32("COVERAGE_MCP_TEST_MISSING", 5, 1, 10, &real_env).unwrap(),
5
);
assert_eq!(
env_u64("COVERAGE_MCP_TEST_MISSING", 5, 1, 10, &real_env).unwrap(),
5
);
assert!(env_usize("PATH", 5, 1, 10, &real_env).is_err());
assert!(env_u32("PATH", 5, 1, 10, &real_env).is_err());
assert!(env_u64("PATH", 5, 1, 10, &real_env).is_err());
let missing = |_: &str| None;
assert!(env_usize("COVERAGE_MCP_TEST_MISSING", 0, 1, 10, &missing).is_err());
assert!(env_u32("COVERAGE_MCP_TEST_MISSING", 0, 1, 10, &missing).is_err());
assert!(env_u64("COVERAGE_MCP_TEST_MISSING", 0, 1, 10, &missing).is_err());
let overrides = |name: &str| {
Some(
match name {
"COVERAGE_MCP_HOST" => "localhost",
"COVERAGE_MCP_PORT" => "1234",
"COVERAGE_MCP_COMMON_DB" => "common.duckdb",
"COVERAGE_MCP_RUN_RETENTION" => "7",
"COVERAGE_MCP_RUN_CONCURRENCY" => "2",
"COVERAGE_MCP_HTTP_CONCURRENCY" => "3",
"COVERAGE_MCP_DB_POOL_SIZE" => "5",
"COVERAGE_MCP_DB_ACQUIRE_TIMEOUT_MS" => "250",
"COVERAGE_MCP_DB_QUERY_TIMEOUT_MS" => "2000",
"COVERAGE_MCP_HTTP_REQUEST_TIMEOUT_SECONDS" => "90",
"COVERAGE_MCP_HTTP_MAX_BODY_BYTES" => "2048",
"COVERAGE_MCP_RUN_LOG_MAX_BYTES" => "4096",
"COVERAGE_MCP_COMPACTION_AFTER_DAYS" => "9",
"COVERAGE_MCP_COMPACTION_INTERVAL_SECONDS" => "120",
"COVERAGE_MCP_COMPACTION_BATCH_SIZE" => "11",
_ => return None,
}
.to_owned(),
)
};
assert!(overrides("UNKNOWN").is_none());
let configured =
ServerConfig::from_environment_with_lookup(None, None, None, &overrides).unwrap();
assert_eq!(configured.host, "localhost");
assert_eq!(configured.port, 1234);
assert_eq!(configured.run_retention, 7);
assert_eq!(configured.run_concurrency, 2);
assert_eq!(configured.mcp_http_concurrency, 3);
assert_eq!(configured.db_pool_size, 5);
assert_eq!(configured.db_acquire_timeout_ms, 250);
assert_eq!(configured.db_query_timeout_ms, 2000);
assert_eq!(configured.http_request_timeout_seconds, 90);
assert_eq!(configured.http_max_body_bytes, 2048);
assert_eq!(configured.run_log_max_bytes, 4096);
assert_eq!(configured.default_compaction_after_days, 9);
assert_eq!(configured.default_compaction_interval_seconds, 120);
assert_eq!(configured.default_compaction_batch_size, 11);
let invalid_deadline = |name: &str| match name {
"COVERAGE_MCP_DB_QUERY_TIMEOUT_MS" => Some("30000".to_owned()),
"COVERAGE_MCP_HTTP_REQUEST_TIMEOUT_SECONDS" => Some("30".to_owned()),
_ => None,
};
assert!(
ServerConfig::from_environment_with_lookup(
None,
None,
Some(PathBuf::from("common")),
&invalid_deadline,
)
.is_err()
);
for invalid_name in [
"COVERAGE_MCP_RUN_RETENTION",
"COVERAGE_MCP_RUN_CONCURRENCY",
"COVERAGE_MCP_HTTP_CONCURRENCY",
"COVERAGE_MCP_DB_POOL_SIZE",
"COVERAGE_MCP_DB_ACQUIRE_TIMEOUT_MS",
"COVERAGE_MCP_DB_QUERY_TIMEOUT_MS",
"COVERAGE_MCP_HTTP_REQUEST_TIMEOUT_SECONDS",
"COVERAGE_MCP_HTTP_MAX_BODY_BYTES",
"COVERAGE_MCP_RUN_LOG_MAX_BYTES",
"COVERAGE_MCP_COMPACTION_AFTER_DAYS",
"COVERAGE_MCP_COMPACTION_INTERVAL_SECONDS",
"COVERAGE_MCP_COMPACTION_BATCH_SIZE",
] {
let invalid = |name: &str| (name == invalid_name).then(|| "not-an-integer".to_owned());
assert!(
ServerConfig::from_environment_with_lookup(None, None, None, &invalid).is_err()
);
}
let invalid_port =
|name: &str| (name == "COVERAGE_MCP_PORT").then(|| "not-an-integer".to_owned());
assert!(
ServerConfig::from_environment_with_lookup(
None,
None,
Some(PathBuf::from("common")),
&invalid_port,
)
.is_err()
);
let home = |name: &str| (name == "HOME").then(|| "/tmp/test-home".to_owned());
assert_eq!(
default_common_db_path_with_lookup(&home),
PathBuf::from("/tmp/test-home/.coverage-mcp/common.duckdb")
);
let no_environment = |_: &str| None;
assert_eq!(
default_common_db_path_with_lookup(&no_environment),
PathBuf::from("./.coverage-mcp/common.duckdb")
);
for invalid in [
"COVERAGE_MCP_RUN_RETENTION",
"COVERAGE_MCP_RUN_CONCURRENCY",
"COVERAGE_MCP_HTTP_CONCURRENCY",
"COVERAGE_MCP_HTTP_MAX_BODY_BYTES",
"COVERAGE_MCP_RUN_LOG_MAX_BYTES",
"COVERAGE_MCP_COMPACTION_AFTER_DAYS",
"COVERAGE_MCP_COMPACTION_INTERVAL_SECONDS",
"COVERAGE_MCP_COMPACTION_BATCH_SIZE",
] {
let lookup = |name: &str| (name == invalid).then(|| "not-an-integer".to_owned());
assert!(
ServerConfig::from_environment_with_lookup(
None,
None,
Some(PathBuf::from("common")),
&lookup,
)
.is_err()
);
}
for (name, value) in [
("COVERAGE_MCP_RUN_RETENTION", "10001"),
("COVERAGE_MCP_RUN_CONCURRENCY", "33"),
("COVERAGE_MCP_HTTP_CONCURRENCY", "129"),
("COVERAGE_MCP_DB_POOL_SIZE", "17"),
("COVERAGE_MCP_DB_ACQUIRE_TIMEOUT_MS", "49"),
("COVERAGE_MCP_DB_QUERY_TIMEOUT_MS", "99"),
("COVERAGE_MCP_HTTP_REQUEST_TIMEOUT_SECONDS", "0"),
("COVERAGE_MCP_HTTP_MAX_BODY_BYTES", "1023"),
("COVERAGE_MCP_RUN_LOG_MAX_BYTES", "1023"),
("COVERAGE_MCP_COMPACTION_AFTER_DAYS", "36501"),
("COVERAGE_MCP_COMPACTION_INTERVAL_SECONDS", "86401"),
("COVERAGE_MCP_COMPACTION_BATCH_SIZE", "10001"),
] {
let lookup = |candidate: &str| (candidate == name).then(|| value.to_owned());
assert!(
ServerConfig::from_environment_with_lookup(
None,
None,
Some(PathBuf::from("common")),
&lookup,
)
.is_err()
);
}
}
}