use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub use crate::mcp::listen_config::ListenConfig;
use serde::Deserialize;
pub struct Token(String);
impl Token {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Token({} bytes)", self.0.len())
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Limits {
pub frame_bytes: usize,
pub record_bytes: usize,
pub reply_timeout_ms: u64,
pub flow_steps: usize,
pub request_bytes: usize,
#[serde(default = "default_connect_timeout_ms")]
pub connect_timeout_ms: u64,
}
const fn default_connect_timeout_ms() -> u64 {
2_000
}
fn central_server() -> String {
crate::seat::CENTRAL_SERVER.to_owned()
}
impl Limits {
#[must_use]
pub const fn reply_timeout(&self) -> Duration {
Duration::from_millis(self.reply_timeout_ms)
}
#[must_use]
pub const fn connect_timeout(&self) -> Duration {
Duration::from_millis(self.connect_timeout_ms)
}
const fn validate(self) -> Result<Self, &'static str> {
if self.frame_bytes == 0 {
return Err("frame_bytes");
}
if self.record_bytes == 0 {
return Err("record_bytes");
}
if self.reply_timeout_ms == 0 {
return Err("reply_timeout_ms");
}
if self.flow_steps == 0 {
return Err("flow_steps");
}
if self.request_bytes == 0 {
return Err("request_bytes");
}
if self.connect_timeout_ms == 0 {
return Err("connect_timeout_ms");
}
Ok(self)
}
}
#[derive(Debug)]
pub struct Config {
pub server: String,
pub channel: String,
pub credential: Credential,
pub limits: Limits,
pub agent: Option<String>,
pub listen: ListenConfig,
}
#[derive(Debug)]
pub enum Credential {
Token(Token),
Lys(crate::lys::LysTable),
Open,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("the configuration {path} cannot be read: {message}")]
Unreadable {
path: PathBuf,
message: String,
},
#[error("the configuration {path} is refused: {message}")]
Invalid {
path: PathBuf,
message: String,
},
#[error("the configuration {path} is refused: {field} must be positive")]
Zero {
path: PathBuf,
field: &'static str,
},
#[error("the token file {path} cannot be read: {message}")]
Token {
path: PathBuf,
message: String,
},
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Written {
harness: Option<toml::Table>,
#[serde(default = "central_server")]
server: String,
channel: String,
token_file: Option<PathBuf>,
lys: Option<crate::lys::LysTable>,
limits: Limits,
agent: Option<String>,
#[serde(default)]
listen: ListenConfig,
}
impl Config {
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
path: path.to_path_buf(),
message: e.to_string(),
})?;
let file: Written = toml::from_str(&text).map_err(|e| ConfigError::Invalid {
path: path.to_path_buf(),
message: e.message().to_owned(),
})?;
crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
path: path.to_path_buf(),
field,
})?;
file.listen
.validate_bounds()
.map_err(|message| ConfigError::Invalid {
path: path.to_path_buf(),
message,
})?;
let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
path: path.to_path_buf(),
message,
})?;
file.listen
.validate(agent.as_deref())
.map_err(|message| ConfigError::Invalid {
path: path.to_path_buf(),
message,
})?;
if file.server.is_empty() {
return Err(ConfigError::Zero {
path: path.to_path_buf(),
field: "server",
});
}
if file.channel.is_empty() {
return Err(ConfigError::Zero {
path: path.to_path_buf(),
field: "channel",
});
}
let credential = credential(path, &file.server, file.token_file, file.lys)?;
Ok(Self {
server: file.server,
channel: file.channel,
credential,
limits,
agent,
listen: file.listen,
})
}
}
fn credential(
path: &Path,
server: &str,
token_file: Option<PathBuf>,
lys: Option<crate::lys::LysTable>,
) -> Result<Credential, ConfigError> {
let invalid = |message: String| ConfigError::Invalid {
path: path.to_path_buf(),
message,
};
let token_file = match (token_file, lys) {
(None, Some(lys)) => return Ok(Credential::Lys(lys)),
(None, None) if crate::seat::names_loopback(server) => return Ok(Credential::Open),
(None, None) => {
return Err(invalid(format!(
"an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
server {server}"
)));
}
(Some(token_file), None) if token_file.is_absolute() => token_file,
(Some(token_file), None) => path
.parent()
.map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
_ => {
return Err(invalid(
"give exactly one of token_file and [lys]".to_owned(),
));
}
};
let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
path: token_file.clone(),
message: e.to_string(),
})?;
let token = token.trim_end_matches(['\r', '\n']);
if token.is_empty() {
return Err(ConfigError::Token {
path: token_file,
message: "token file is empty".to_owned(),
});
}
Ok(Credential::Token(Token(token.to_owned())))
}
fn agent_name(file: &Written) -> Result<Option<String>, String> {
let named = file.agent.as_deref().or_else(|| {
file.lys
.as_ref()
.map(|lys| lys.agent.as_str())
.filter(|name| !name.is_empty())
});
let environment = match if named.is_some() || file.lys.is_some() {
std::env::var("LYS_AGENT")
} else {
Err(std::env::VarError::NotPresent)
} {
Ok(value) => Some(value),
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
};
for candidate in [
file.agent.as_deref(),
file.lys
.as_ref()
.map(|lys| lys.agent.as_str())
.filter(|name| !name.is_empty()),
environment.as_deref(),
]
.into_iter()
.flatten()
{
if candidate.is_empty()
|| candidate.len() > file.listen.agent_bytes
|| candidate.split('.').any(str::is_empty)
|| !candidate
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
{
return Err(format!(
"agent must be a plain identity of at most {} bytes",
file.listen.agent_bytes
));
}
}
if let (Some(named), Some(environment)) = (named, environment.as_deref())
&& named != environment
{
return Err(format!(
"seat agent {named} differs from LYS_AGENT {environment}"
));
}
if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
&& !lys.agent.is_empty()
&& root != lys.agent
{
return Err(format!(
"seat agent {root} differs from lys.agent {}",
lys.agent
));
}
let name = named.map(str::to_owned).or_else(|| {
if file.lys.is_some() {
environment
} else {
None
}
});
Ok(name)
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;